Files
simbarag/blueprints/ynab/sync.py
T
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:04 -04:00

655 lines
22 KiB
Python

"""Sync the YNAB budget into the local Postgres mirror.
YNAB has no event feed, but every budget response carries a
``server_knowledge`` counter; passing it back as ``last_knowledge_of_server``
returns only entities changed since then. The first sync downloads the whole
budget, later syncs are deltas, and a full resync runs once a day as a safety
net (deltas can't express things like month rollovers).
Each sync diffs incoming entities against the mirror and appends what changed
to ``ynab_changes``, which is the only history of edits we get.
"""
import asyncio
import logging
import os
import time
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, Callable
from tortoise.models import Model
from tortoise.transactions import in_transaction
from utils.ynab_service import YNABService
from .models import (
YnabAccount,
YnabCategory,
YnabCategoryGroup,
YnabChange,
YnabMonth,
YnabMonthCategory,
YnabPayee,
YnabSubtransaction,
YnabSyncState,
YnabTransaction,
)
logger = logging.getLogger(__name__)
FULL_RESYNC_INTERVAL = timedelta(hours=24)
# After a failed sync, tools answer from the mirror without retrying YNAB
FAILURE_BACKOFF_SECONDS = 60
# Cap on rows passed to a single ``id IN (...)`` lookup
LOOKUP_CHUNK = 2000
_sync_lock = asyncio.Lock()
tool_data_freshness: ContextVar[dict | None] = ContextVar(
"ynab_tool_data_freshness", default=None
)
_last_failure = float("-inf")
def ynab_configured() -> bool:
return bool(os.getenv("YNAB_ACCESS_TOKEN"))
# YNAB accepts these in place of a budget id; they can point at different
# budgets over time, so the mirror resolves them once and keeps the real id
BUDGET_ID_ALIASES = ("last-used", "default")
def _configured_budget_id() -> str | None:
"""YNAB_BUDGET_ID if it names a specific budget, else None."""
budget_id = os.getenv("YNAB_BUDGET_ID", "").strip()
return None if budget_id in ("", *BUDGET_ID_ALIASES) else budget_id
def _money(milliunits: int | None) -> Decimal | None:
return None if milliunits is None else Decimal(milliunits).scaleb(-3)
def _date(value: str | None) -> date | None:
return date.fromisoformat(value) if value else None
def _datetime(value: str | None) -> datetime | None:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _norm(value: Any) -> Any:
"""Normalize values so DB rows and freshly mapped rows compare equal."""
if isinstance(value, datetime) and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _jsonable(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
def _map_account(a: dict) -> dict:
return {
"id": a["id"],
"name": a["name"],
"type": a["type"],
"on_budget": a["on_budget"],
"closed": a["closed"],
"note": a.get("note"),
"balance": _money(a["balance"]),
"cleared_balance": _money(a["cleared_balance"]),
"uncleared_balance": _money(a["uncleared_balance"]),
"last_reconciled_at": _datetime(a.get("last_reconciled_at")),
"deleted": a["deleted"],
}
def _map_payee(p: dict) -> dict:
return {
"id": p["id"],
"name": p["name"],
"transfer_account_id": p.get("transfer_account_id"),
"deleted": p["deleted"],
}
def _map_category_group(g: dict) -> dict:
return {
"id": g["id"],
"name": g["name"],
"hidden": g["hidden"],
"internal": g.get("internal") or False,
"deleted": g["deleted"],
}
def _map_category(c: dict) -> dict:
return {
"id": c["id"],
"category_group_id": c["category_group_id"],
"name": c["name"],
"hidden": c["hidden"],
"internal": c.get("internal") or False,
"note": c.get("note"),
"goal_type": c.get("goal_type"),
"goal_target": _money(c.get("goal_target")),
"goal_target_month": _date(c.get("goal_target_month")),
"deleted": c["deleted"],
}
def _map_month(m: dict) -> dict:
return {
"id": m["month"],
"month": _date(m["month"]),
"note": m.get("note"),
"income": _money(m["income"]),
"budgeted": _money(m["budgeted"]),
"activity": _money(m["activity"]),
"to_be_budgeted": _money(m["to_be_budgeted"]),
"age_of_money": m.get("age_of_money"),
"deleted": m["deleted"],
}
def _map_month_category(c: dict) -> dict:
return {
"id": f"{c['month']}:{c['id']}",
"month": _date(c["month"]),
"category_id": c["id"],
"budgeted": _money(c["budgeted"]),
"activity": _money(c["activity"]),
"balance": _money(c["balance"]),
"deleted": c["deleted"],
}
def _map_transaction(t: dict) -> dict:
return {
"id": t["id"],
"date": _date(t["date"]),
"amount": _money(t["amount"]),
"memo": t.get("memo"),
"cleared": t["cleared"],
"approved": t["approved"],
"flag_color": t.get("flag_color"),
"account_id": t["account_id"],
"payee_id": t.get("payee_id"),
"category_id": t.get("category_id"),
"transfer_account_id": t.get("transfer_account_id"),
"import_payee_name": t.get("import_payee_name"),
"deleted": t["deleted"],
}
def _map_subtransaction(s: dict) -> dict:
return {
"id": s["id"],
"transaction_id": s["transaction_id"],
"amount": _money(s["amount"]),
"memo": s.get("memo"),
"payee_id": s.get("payee_id"),
"category_id": s.get("category_id"),
"transfer_account_id": s.get("transfer_account_id"),
"deleted": s["deleted"],
}
# Filled in from payees/categories/accounts after each sync, not from YNAB
DENORMALIZED_FIELDS = ("account_name", "payee_name", "category_name")
@dataclass
class EntitySpec:
entity_type: str
model: type[Model]
mapper: Callable[[dict], dict]
# Fields worth recording in the change log. Derived fields (balances,
# activity) change with every transaction and would drown out real edits.
logged_fields: tuple[str, ...]
def _fields(model: type[Model], exclude: tuple[str, ...] = ()) -> tuple[str, ...]:
return tuple(f for f in model._meta.fields_map if f != "id" and f not in exclude)
SPECS = {
"accounts": EntitySpec(
"account",
YnabAccount,
_map_account,
("name", "type", "on_budget", "closed", "note", "deleted"),
),
"payees": EntitySpec("payee", YnabPayee, _map_payee, ("name", "deleted")),
"category_groups": EntitySpec(
"category_group",
YnabCategoryGroup,
_map_category_group,
("name", "hidden", "deleted"),
),
"categories": EntitySpec(
"category", YnabCategory, _map_category, _fields(YnabCategory)
),
"months": EntitySpec("month", YnabMonth, _map_month, ("note",)),
"month_categories": EntitySpec(
"month_category", YnabMonthCategory, _map_month_category, ("budgeted",)
),
"transactions": EntitySpec(
"transaction",
YnabTransaction,
_map_transaction,
_fields(YnabTransaction, exclude=DENORMALIZED_FIELDS),
),
"subtransactions": EntitySpec(
"subtransaction",
YnabSubtransaction,
_map_subtransaction,
_fields(YnabSubtransaction),
),
}
def _budget_entities(budget: dict) -> dict[str, list[dict]]:
"""Split a budget payload into raw entity lists keyed like ``SPECS``."""
month_categories = [
{**c, "month": m["month"]}
for m in budget.get("months") or []
for c in m.get("categories") or []
]
return {
"accounts": budget.get("accounts") or [],
"payees": budget.get("payees") or [],
"category_groups": budget.get("category_groups") or [],
"categories": budget.get("categories") or [],
"months": budget.get("months") or [],
"month_categories": month_categories,
"transactions": budget.get("transactions") or [],
"subtransactions": budget.get("subtransactions") or [],
}
# Id fields that reference a named entity (account, payee, category, group);
# the change log records these by name
NAMED_ID_FIELDS = (
"account_id",
"payee_id",
"category_id",
"category_group_id",
"transfer_account_id",
)
class _Labeler:
"""Resolves ids to names for change log labels and diffs."""
def __init__(self, names: dict[str, str]):
self.names = names
def name(self, entity_id: str | None) -> str | None:
return None if entity_id is None else self.names.get(entity_id, entity_id)
def __call__(self, entity_type: str, row: dict) -> str:
if entity_type == "transaction":
payee = self.name(row.get("payee_id")) or row.get("import_payee_name") or ""
return f"{row['date']} {payee} {row['amount']:+.2f}"
if entity_type == "subtransaction":
category = self.name(row.get("category_id")) or ""
return f"split line {category} {row['amount']:+.2f}"
if entity_type == "month_category":
return f"{self.name(row['category_id'])} {row['month']:%Y-%m}"
if entity_type == "month":
return row["id"][:7]
return row.get("name") or row["id"]
def diff_entry(self, field: str, old: Any, new: Any) -> tuple[str, list]:
"""Log e.g. ``category_id`` changes as ``category`` names, not ids."""
if field in NAMED_ID_FIELDS:
return field.removesuffix("_id"), [self.name(old), self.name(new)]
return field, [_jsonable(old), _jsonable(new)]
async def _load_names(conn) -> dict[str, str]:
names: dict[str, str] = {}
for model in (YnabAccount, YnabPayee, YnabCategory, YnabCategoryGroup):
for row in await model.all().using_db(conn).values("id", "name"):
names[row["id"]] = row["name"]
return names
async def _fetch_existing(spec: EntitySpec, ids: list[str], conn, fields) -> dict:
existing: dict[str, dict] = {}
for i in range(0, len(ids), LOOKUP_CHUNK):
rows = (
await spec.model.filter(id__in=ids[i : i + LOOKUP_CHUNK])
.using_db(conn)
.values("id", *fields)
)
existing.update({row["id"]: row for row in rows})
return existing
async def _apply(
spec: EntitySpec,
raw_rows: list[dict],
conn,
server_knowledge: int,
log_changes: bool,
labeler: _Labeler,
complete: bool = False,
) -> int:
"""Upsert rows that differ from the mirror; return how many changed.
``complete`` means ``raw_rows`` is every live entity of this type (a full
download). Full downloads omit deleted entities, so mirror rows missing
from it are marked deleted here; otherwise deletions that happened right
before a full sync would never reach the mirror.
"""
incoming = {row["id"]: row for row in map(spec.mapper, raw_rows)}
# Only YNAB-sourced columns; denormalized names are filled in afterwards
field_names = tuple(f for f in _fields(spec.model) if f not in DENORMALIZED_FIELDS)
existing = await _fetch_existing(spec, list(incoming), conn, field_names)
to_write: list[Model] = []
changes: list[YnabChange] = []
def log(entity_id: str, action: str, diff: dict, row: dict) -> None:
changes.append(
YnabChange(
server_knowledge=server_knowledge,
entity_type=spec.entity_type,
entity_id=entity_id,
action=action,
label=labeler(spec.entity_type, row)[:500],
changes=diff,
)
)
for entity_id, new in incoming.items():
old = existing.get(entity_id)
if old is not None and all(_norm(old[f]) == _norm(new[f]) for f in field_names):
continue
to_write.append(spec.model(**new))
if not log_changes or not spec.logged_fields:
continue
if old is None:
# Created and deleted between two syncs: nothing worth logging
if new.get("deleted"):
continue
diff = dict(
labeler.diff_entry(f, None, new[f])
for f in spec.logged_fields
if new[f] is not None
)
# e.g. a month rollover creates every category's month row with
# nothing assigned; that's not an edit anyone made
if not any(values[1] for values in diff.values()):
continue
log(entity_id, "created", diff, new)
else:
diff = dict(
labeler.diff_entry(f, old[f], new[f])
for f in spec.logged_fields
if _norm(old[f]) != _norm(new[f])
)
if diff:
deleted = diff.get("deleted") == [False, True]
log(entity_id, "deleted" if deleted else "updated", diff, new)
removed = 0
if complete:
live_ids = (
await spec.model.filter(deleted=False)
.using_db(conn)
.values_list("id", flat=True)
)
missing = [i for i in live_ids if i not in incoming]
if missing:
if log_changes and "deleted" in spec.logged_fields:
gone = await _fetch_existing(spec, missing, conn, field_names)
for entity_id, row in gone.items():
log(entity_id, "deleted", {"deleted": [False, True]}, row)
for i in range(0, len(missing), LOOKUP_CHUNK):
removed += (
await spec.model.filter(id__in=missing[i : i + LOOKUP_CHUNK])
.using_db(conn)
.update(deleted=True)
)
if to_write:
await spec.model.bulk_create(
to_write,
batch_size=500,
on_conflict=["id"],
update_fields=list(field_names),
using_db=conn,
)
if changes:
await YnabChange.bulk_create(changes, batch_size=500, using_db=conn)
return len(to_write) + removed
DENORMALIZE_SQL = (
"""UPDATE ynab_transactions AS t SET payee_name = p.name FROM ynab_payees p
WHERE t.payee_id = p.id AND t.payee_name IS DISTINCT FROM p.name""",
"""UPDATE ynab_transactions AS t SET category_name = c.name FROM ynab_categories c
WHERE t.category_id = c.id AND t.category_name IS DISTINCT FROM c.name""",
"""UPDATE ynab_transactions AS t SET account_name = a.name FROM ynab_accounts a
WHERE t.account_id = a.id AND t.account_name IS DISTINCT FROM a.name""",
"""UPDATE ynab_transactions SET payee_name = NULL
WHERE payee_id IS NULL AND payee_name IS NOT NULL""",
"""UPDATE ynab_transactions SET category_name = NULL
WHERE category_id IS NULL AND category_name IS NOT NULL""",
)
# Entity types whose changes can affect the denormalized name columns
NAME_SOURCES = ("accounts", "payees", "categories", "transactions")
REFERENCE_KEYS = ("accounts", "payees", "category_groups", "categories")
DATA_KEYS = ("months", "month_categories", "transactions", "subtransactions")
async def apply_budget(
budget: dict,
server_knowledge: int,
conn,
log_changes: bool,
full: bool = False,
) -> dict[str, int]:
"""Write a full or delta budget payload into the mirror.
With ``full``, entity types present in the payload are treated as
complete and anything missing from them is marked deleted.
"""
entities = _budget_entities(budget)
present = {key for key in SPECS if budget.get(key) is not None}
if budget.get("months") is not None:
present.add("month_categories")
labeler = _Labeler({})
if log_changes and any(entities.values()):
labeler.names = await _load_names(conn)
counts: dict[str, int] = {}
for key in REFERENCE_KEYS + DATA_KEYS:
counts[key] = await _apply(
SPECS[key],
entities[key],
conn,
server_knowledge,
log_changes,
labeler,
complete=full and key in present,
)
if key in REFERENCE_KEYS:
# Later labels (e.g. a new payee on a new transaction) need these
labeler.names.update({row["id"]: row["name"] for row in entities[key]})
if any(counts[key] for key in NAME_SOURCES):
for sql in DENORMALIZE_SQL:
await conn.execute_query(sql)
return counts
async def _current_state() -> YnabSyncState | None:
"""The sync state for the mirrored budget.
The mirror holds a single budget, keyed by its real YNAB id. With
``YNAB_BUDGET_ID`` unset or an alias, the first sync resolves "last-used"
and the mirror stays pinned to that budget, so switching budgets in YNAB
can't mix two budgets' rows.
"""
configured = _configured_budget_id()
states = await YnabSyncState.all().order_by("budget_id")
ids = [s.budget_id for s in states]
if len(ids) > 1 or (configured and ids and ids[0] != configured):
raise ValueError(
f"YNAB mirror holds budget(s) {', '.join(ids)} but YNAB_BUDGET_ID "
f"is {configured or 'unset'}; clear the ynab_* tables to switch budgets"
)
return states[0] if states else None
def _backing_off() -> bool:
return time.monotonic() - _last_failure < FAILURE_BACKOFF_SECONDS
def _is_fresh(state: YnabSyncState | None, max_age_seconds: int) -> bool:
return (
state is not None
and state.last_synced_at is not None
and datetime.now(timezone.utc) - _norm(state.last_synced_at)
<= timedelta(seconds=max_age_seconds)
)
async def sync_ynab(full: bool = False, max_age_seconds: int | None = None) -> dict:
"""Sync YNAB into Postgres. Returns per-entity counts of changed rows.
``max_age_seconds`` (on-demand syncs from tools) skips the sync if the
mirror is already that fresh or YNAB failed recently. Both are checked
under the lock, so parallel tool calls share one sync or one failure.
The YNAB request runs before the DB transaction. An asyncio lock
serializes syncs in this process; a Postgres advisory lock plus a
server_knowledge check discards a payload if the other blue/green
container synced in the meantime.
"""
global _last_failure
async with _sync_lock:
state = await _current_state()
if max_age_seconds is not None and (
_is_fresh(state, max_age_seconds) or _backing_off()
):
return {"skipped": True}
now = datetime.now(timezone.utc)
base_knowledge = state.server_knowledge if state else None
first_sync = base_knowledge is None
full = (
full
or first_sync
or state.last_full_sync_at is None
or now - _norm(state.last_full_sync_at) > FULL_RESYNC_INTERVAL
)
budget_id = state.budget_id if state else _configured_budget_id()
budget_id = budget_id or "last-used"
try:
data = await YNABService().fetch_budget(
budget_id, None if full else base_knowledge
)
budget = data["budget"]
knowledge = data["server_knowledge"]
if state is not None and budget["id"] != state.budget_id:
raise ValueError(
f"YNAB returned budget {budget['id']} for {state.budget_id}"
)
async with in_transaction() as conn:
await conn.execute_query(
"SELECT pg_advisory_xact_lock(hashtext('ynab_sync'))"
)
latest = await YnabSyncState.get_or_none(
budget_id=budget["id"]
).using_db(conn)
if (latest.server_knowledge if latest else None) != base_knowledge:
logger.info("YNAB sync skipped: mirror changed during fetch")
return {"skipped": True}
counts = await apply_budget(
budget, knowledge, conn, log_changes=not first_sync, full=full
)
await YnabSyncState.update_or_create(
budget_id=budget["id"],
defaults={
"budget_name": budget.get("name"),
"server_knowledge": knowledge,
"last_synced_at": now,
"last_error": None,
**({"last_full_sync_at": now} if full else {}),
},
using_db=conn,
)
except Exception as e:
_last_failure = time.monotonic()
if state is not None:
await YnabSyncState.filter(budget_id=state.budget_id).update(
last_error=f"{type(e).__name__}: {e}"[:2000]
)
raise
changed = {k: v for k, v in counts.items() if v}
if changed:
logger.info(f"YNAB {'full' if full else 'delta'} sync @{knowledge}: {changed}")
return {"full": full, "server_knowledge": knowledge, "changed": changed}
async def ensure_fresh(max_age_seconds: int = 120) -> YnabSyncState | None:
"""Sync if the mirror is older than ``max_age_seconds``.
Used by agent tools before answering. A failed sync is logged and the
(possibly stale) mirror is used anyway, and YNAB isn't retried for
``FAILURE_BACKOFF_SECONDS``. Returns None only if the mirror has never
been populated.
"""
tool_data_freshness.set(None)
state = await _current_state()
if not _is_fresh(state, max_age_seconds) and not _backing_off():
try:
await sync_ynab(max_age_seconds=max_age_seconds)
except Exception:
logger.exception("YNAB sync failed; answering from existing mirror")
state = await _current_state()
if state is None or state.server_knowledge is None:
return None
stamp = _norm(state.last_synced_at) if state.last_synced_at else None
tool_data_freshness.set(
{
"system": "ynab",
"as_of": stamp.isoformat() if stamp else None,
"stale": not _is_fresh(state, max_age_seconds),
}
)
return state
async def ynab_sync_loop() -> None:
"""Background task: delta-sync YNAB every ``YNAB_SYNC_INTERVAL`` seconds."""
# Below ensure_fresh's 120s window, so tool calls rarely sync inline.
interval = int(os.getenv("YNAB_SYNC_INTERVAL", "90"))
logger.info(f"YNAB sync loop started (interval={interval}s)")
while True:
try:
await sync_ynab()
except Exception:
logger.exception("YNAB sync error")
await asyncio.sleep(interval)