5809ad3adb
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>
655 lines
22 KiB
Python
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)
|