273 lines
13 KiB
Python
273 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from tools.connectors.contract import Actor, SettleReason, TargetState, allowed
|
|
from tools.connectors.gateway.config import operation_session_key
|
|
from tools.connectors.gateway.errors import RateLimited
|
|
from tools.connectors.operation import ConnectionOperation, DetachedOperation, IllegalTransition, Target
|
|
from tools.connectors.run import Kind, run_operation
|
|
from tools.connectors.targets import catalog_names, hosted_names, misrouted_to_hosted_error
|
|
from tools.registry import tool_error
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The account route carries its own 180/min budget, so one read per pending target per second stays
|
|
# inside it and still flips the card within a second of the user finishing at the vendor.
|
|
WATCH_TICK_SECONDS = 1.0
|
|
|
|
# A read never outlives the operation, never asks for less than one second, and never holds the
|
|
# loop for more than ten: Continue must be able to return the tool while a gateway hangs.
|
|
_MIN_READ_SECONDS = 1.0
|
|
_MAX_READ_SECONDS = 10.0
|
|
|
|
# The six-state account vocabulary -> the state that read ends the attempt in, and who caused it.
|
|
# `pending` is not here: it is the attempt still running, and moves nothing.
|
|
_ACCOUNT_OUTCOME: dict[str, tuple[TargetState, Actor]] = {
|
|
"active": (TargetState.connected, Actor.backend_watcher),
|
|
"failed": (TargetState.failed, Actor.backend_watcher),
|
|
"revoked": (TargetState.failed, Actor.backend_watcher),
|
|
"inactive": (TargetState.failed, Actor.backend_watcher),
|
|
# The link's TTL ran out; the gateway reports it, the clock caused it.
|
|
"expired": (TargetState.expired, Actor.clock),
|
|
}
|
|
|
|
NOTE = (
|
|
"Settled once. connected → use the app now; skipped → the user chose Not now, do not connect it "
|
|
"or route around it; not_connected → ask the user what to do, never re-mint on your own. "
|
|
"A later request from the USER for that same app is not a re-ask — run it."
|
|
)
|
|
|
|
|
|
def managed_client():
|
|
from tools.connectors.gateway.client import ConnectorClient
|
|
|
|
return ConnectorClient()
|
|
|
|
|
|
def managed_kind(client: Any, action: str, force: bool) -> Kind:
|
|
return Kind(prepare=_prepare(client, action, force), observe=lambda operation: _observe(client, operation), note=NOTE)
|
|
|
|
|
|
def _status_by_slug(client: Any) -> dict[str, dict[str, Any]]:
|
|
"""The toolkit list, by slug. Only the reconnect repair check reads it: it answers "is this app
|
|
already connected" before any account exists for the watcher to read."""
|
|
return {str(i.get("connector", "")).lower(): i for i in client.list_connectors() if isinstance(i, dict)}
|
|
|
|
|
|
def mint(client: Any, operation: ConnectionOperation, names: list[str], *, reinitiate: bool, actor: Actor) -> None:
|
|
"""Mint links for ``names`` and apply the gateway's per-app answer to the operation. ``actor`` is
|
|
the watcher on the first mint and the user on Try again. The operation id rides along so the
|
|
vendor's done page can name it on the way back to the desktop."""
|
|
from tools.connectors.gateway.client import return_to_args
|
|
|
|
if not names:
|
|
return
|
|
response = client.connections(names, reinitiate=reinitiate, **return_to_args(op=operation.op_id))
|
|
for entry in response.get("results", []):
|
|
name = str(entry.get("connector") or "").lower()
|
|
target = operation.target(name)
|
|
if target is None:
|
|
continue
|
|
status = str(entry.get("status") or "")
|
|
detail = str(entry.get("status_reason") or "")
|
|
connection_id = entry.get("connection_id")
|
|
if status == "active":
|
|
operation.transition(name, TargetState.initiated, actor)
|
|
operation.transition(name, TargetState.connected, Actor.backend_watcher, connection_id=connection_id)
|
|
elif status == "initiated":
|
|
if not connection_id:
|
|
logger.warning("connector %s: the mint named no account, so the watcher cannot read it; "
|
|
"only the card or the deadline can end the row", name)
|
|
operation.transition(
|
|
name, TargetState.initiated, actor,
|
|
connect_url=entry.get("connect_url"), connection_id=connection_id,
|
|
detail=detail,
|
|
)
|
|
elif target.state == TargetState.failed:
|
|
# Failed again: no state change to emit, but the old link is dead and the vendor's text is new.
|
|
operation.refresh(name, connect_url=None, detail=detail)
|
|
elif target.state == TargetState.expired:
|
|
# The table has no expired → failed; the re-mint attempt is the user's, so step through initiated.
|
|
operation.transition(name, TargetState.initiated, actor)
|
|
operation.transition(name, TargetState.failed, Actor.backend_watcher, detail=detail)
|
|
operation.refresh(name, connect_url=None, detail=detail)
|
|
else:
|
|
# `detail` is the vendor's text or empty; the state itself is never written into it (the card prints it).
|
|
operation.transition(name, TargetState.failed, Actor.backend_watcher, detail=detail)
|
|
|
|
|
|
def _status_for(client: Any, target: Target, *, timeout: float) -> Optional[dict[str, Any]]:
|
|
"""The one route the watcher reads: that target's own account row. ``None`` means "nothing to
|
|
apply this tick" — no account to read, a rate-limit still in force, an account the gateway does
|
|
not know yet (404 until the deadline), or a read that failed. A 429 is raised to the tick: its
|
|
budget is the principal's, so it is not this one target's to wait out."""
|
|
if not target.connection_id or time.time() < target.next_read_at:
|
|
return None
|
|
try:
|
|
return client.account_status(target.connection_id, timeout=timeout)
|
|
except RateLimited:
|
|
raise
|
|
except Exception as exc:
|
|
logger.debug("connector account read failed for %s: %s", target.name, exc)
|
|
return None
|
|
|
|
|
|
def _apply_read(operation: ConnectionOperation, target: Target, status: str, reason: str) -> None:
|
|
"""Apply one account read to one target. The RPC thread can move the row while the read is in
|
|
flight — a Skip resolves it, a Continue freezes the whole result — and the read then has no
|
|
live row to move: it is dropped, not raised into the tool result (that would end the watch
|
|
with the operation still open and no card to answer it). Any other refusal is a real
|
|
contract violation."""
|
|
outcome = _ACCOUNT_OUTCOME.get(status)
|
|
if outcome is None:
|
|
return
|
|
to, actor = outcome
|
|
try:
|
|
if allowed(target.kind, target.state, to) is None:
|
|
# No edge from pending: the read is itself the witness that the attempt started.
|
|
operation.transition(target.name, TargetState.initiated, Actor.backend_watcher)
|
|
operation.transition(target.name, to, actor, detail=reason or target.detail)
|
|
except IllegalTransition:
|
|
if not operation.settled and _live(target):
|
|
raise
|
|
logger.debug("connector %s: %s read dropped, the row is %s", target.name, status, target.state.value)
|
|
|
|
|
|
def _live(target: Target) -> bool:
|
|
"""Only a live attempt (pending, initiated) can be advanced by a gateway read; a failed or
|
|
expired link waits for the user, and a resolved row is done."""
|
|
return target.state in (TargetState.pending, TargetState.initiated)
|
|
|
|
|
|
def _park(operation: ConnectionOperation, until: float) -> None:
|
|
"""A 429 is per principal, not per account: every live target waits out the same Retry-After."""
|
|
for target in operation.targets:
|
|
if _live(target):
|
|
target.next_read_at = until
|
|
|
|
|
|
def _observe(client: Any, operation: ConnectionOperation) -> None:
|
|
"""One account read per live target per tick, sequential: this is the only thread reading them.
|
|
A 429 ends the tick: the next read would spend the same refused budget."""
|
|
for target in operation.targets:
|
|
# A settled op is frozen; a row that is not live waits for the user or is done.
|
|
if operation.settled or not _live(target):
|
|
continue
|
|
timeout = min(_MAX_READ_SECONDS, max(_MIN_READ_SECONDS, operation.remaining_seconds()))
|
|
try:
|
|
row = _status_for(client, target, timeout=timeout)
|
|
except RateLimited as exc:
|
|
_park(operation, time.time() + exc.retry_after)
|
|
return
|
|
if row is None:
|
|
continue
|
|
_apply_read(operation, target, str(row.get("status") or "").lower(), str(row.get("statusReason") or ""))
|
|
|
|
|
|
def _mark_misrouted(operation: ConnectionOperation) -> None:
|
|
unminted = [t for t in operation.targets
|
|
if t.state == TargetState.failed and not t.connection_id]
|
|
if not unminted:
|
|
return
|
|
catalog = catalog_names()
|
|
candidates = [t for t in unminted if t.name in catalog]
|
|
if not candidates:
|
|
return
|
|
hosted = hosted_names()
|
|
if hosted is None:
|
|
return
|
|
misrouted = [t for t in candidates if t.name not in hosted]
|
|
for target in misrouted:
|
|
operation.refresh(target.name, connect_url=None, actor=Actor.backend_watcher,
|
|
detail=misrouted_to_hosted_error(target.name))
|
|
if misrouted and len(misrouted) == len(operation.targets):
|
|
operation.settle(SettleReason.all_resolved)
|
|
|
|
|
|
def _prepare(client: Any, action: str, force: bool) -> Callable[[ConnectionOperation], None]:
|
|
def prepare(operation: ConnectionOperation) -> None:
|
|
names = [t.name for t in operation.targets]
|
|
if action == "connect":
|
|
mint(client, operation, names, reinitiate=False, actor=Actor.backend_watcher)
|
|
_mark_misrouted(operation)
|
|
return
|
|
if force:
|
|
# The re-mint names a new account; the watcher reads that one, never the old row.
|
|
mint(client, operation, names, reinitiate=True, actor=Actor.backend_watcher)
|
|
_mark_misrouted(operation)
|
|
return
|
|
status = _status_by_slug(client)
|
|
repair = []
|
|
for name in names:
|
|
if status.get(name, {}).get("connected"):
|
|
operation.transition(name, TargetState.initiated, Actor.backend_watcher)
|
|
operation.transition(name, TargetState.connected, Actor.backend_watcher)
|
|
else:
|
|
repair.append(name)
|
|
mint(client, operation, repair, reinitiate=True, actor=Actor.backend_watcher)
|
|
_mark_misrouted(operation)
|
|
|
|
return prepare
|
|
|
|
|
|
def _no_card_result(client: Any, action: str, names: list[str], force: bool, session_id: str) -> str:
|
|
operation = DetachedOperation([Target(n, "connector", action) for n in names], session_key=session_id)
|
|
_prepare(client, action, force)(operation)
|
|
payload = operation.result(with_urls=True)
|
|
payload["status"] = "initiated" if any(t.state == TargetState.initiated for t in operation.targets) else "settled"
|
|
payload["note"] = (
|
|
"Show each connect_url to the user; they open it in a browser to authorize. Ask them to tell you "
|
|
"when they are done, then check with action 'status'. Do not call connect again for the same app."
|
|
)
|
|
return json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
def run_managed_action(
|
|
action: str,
|
|
connectors: list[str],
|
|
args: dict[str, Any],
|
|
*,
|
|
client_factory: Optional[Callable[[], Any]] = None,
|
|
session_id: Optional[str] = None,
|
|
tool_call_id: Optional[str] = None,
|
|
connection_callback: Optional[Callable[[dict[str, Any]], Optional[str]]] = None,
|
|
connectors_available: Optional[Callable[[], bool]] = None,
|
|
) -> str:
|
|
if connectors_available is not None and not connectors_available():
|
|
return tool_error("Connectors are not available in this session.")
|
|
try:
|
|
client = (client_factory or managed_client)()
|
|
if action == "status":
|
|
items = client.list_connectors()
|
|
if connectors:
|
|
wanted = set(connectors)
|
|
items = [i for i in items if str(i.get("connector", "")).lower() in wanted]
|
|
return json.dumps({"connectors": items, "hint": (
|
|
"connected=false means calls to that connector will return CONNECTION_REQUIRED. "
|
|
"Use action 'connect' to start an authorization.")}, ensure_ascii=False)
|
|
if not connectors:
|
|
return tool_error(
|
|
f"'{action}' requires 'connectors': the connector slugs to authorize (e.g. [\"gmail\"]). "
|
|
"Use action 'status' to list them."
|
|
)
|
|
force = bool(args.get("force", False))
|
|
session_key = operation_session_key(session_id)
|
|
if connection_callback is None:
|
|
return _no_card_result(client, action, connectors, force, session_key)
|
|
return run_operation(
|
|
[Target(n, "connector", action) for n in connectors],
|
|
managed_kind(client, action, force),
|
|
session_key=session_key, tool_call_id=tool_call_id, tick_seconds=WATCH_TICK_SECONDS,
|
|
connection_callback=connection_callback, with_urls_in_result=False,
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("manage_connections %s failed: %s", action, exc)
|
|
return tool_error(
|
|
f"The connector gateway request failed: {exc}. "
|
|
"If this persists, the user can manage connections in the Nous Portal."
|
|
)
|