"""Callback mode stub (CONTRACT.md; INTENT.md, Callback stub). Speaks the mode contract and simulates how a callback can develop. It never calls anyone. Scenarios are scripted and deterministic: nothing happens until `step()` (or POST /step) emits the next status. A scenario is chosen by the request's `details.scenario`, or else by the stub's default. stub = CallbackStub({"email": key}, default="no-answer-retry") base = serve(stub.server()) # POST /handoffs (signed), /step (loopback) Requests are accepted only when signed by email's peer id, and statuses are signed with the key shared with email (CONTRACT.md section 7). """ from __future__ import annotations import threading from dataclasses import dataclass, field from typing import Optional from .. import _load, message_errors from ..contract import TERMINAL, Idempotent, json_server, message, post, refuse WAIT = "wait-for-details" # negotiation: block until handoff.update supplies the needed fields def _scenarios() -> tuple[dict, tuple]: """The scenarios from the bundle (conformance/scenarios.json), as steps of (status, extra fields) or WAIT.""" doc = _load("conformance/scenarios.json") needed = () out = {} for name, steps in doc["scenarios"].items(): script = [] for step in steps: if "wait_for_details" in step: needed = tuple(step["wait_for_details"]) script.append(WAIT) else: extra = {k: v for k, v in step.items() if k != "status"} script.append((step["status"], extra)) out[name] = script return out, needed SCENARIOS, NEEDED = _scenarios() @dataclass class Handoff: request: dict script: list details: dict seq: int = 0 pos: int = 0 status: Optional[str] = None sent: list = field(default_factory=list) class CallbackStub: def __init__(self, keys: dict[str, bytes], default: str = "completed", peer_id: str = "callback-1", email_peer: str = "email"): if default not in SCENARIOS: raise ValueError(f"unknown scenario {default!r}") self.keys, self.peer_id, self.email_peer = keys, peer_id, email_peer self.default = default self.handoffs: dict[str, Handoff] = {} self.received: list[dict] = [] self.lock = threading.Lock() self._once = Idempotent() # -- contract endpoint --------------------------------------------------------- def receive(self, msg: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]: if peer != self.email_peer: return refuse("wrong-peer", "only email may hand off", 403) return self._once(msg, self._receive) def _receive(self, msg): errors = message_errors(msg) if errors: return refuse("schema", "; ".join(errors), 400) with self.lock: self.received.append(msg) t, hid = msg["type"], msg["handoff_id"] if t == "handoff.request": name = msg["details"].get("scenario", self.default) if name not in SCENARIOS: return refuse("unknown-scenario", name, 400) script = list(SCENARIOS[name]) details = dict(msg["details"]) if script and script[0] == WAIT and all(details.get(k) for k in NEEDED): script.pop(0) # nothing to negotiate self.handoffs[hid] = Handoff(msg, script, details) return 202, {"ok": True, "handoff_id": hid} h = self.handoffs.get(hid) if h is None: return refuse("unknown-handoff", hid, 404) if t == "handoff.update": h.details.update(msg["details"]) return 200, {"ok": True} if t == "handoff.cancel": if h.status not in TERMINAL: h.script[h.pos:] = [("cancelled", {"detail": msg.get("reason", "")})] return 200, {"ok": True} return refuse("unsupported", t, 400) # -- simulation ---------------------------------------------------------------- def step(self, handoff_id: Optional[str] = None) -> Optional[dict]: """Emit the next status of one handoff (the oldest one with a step due). Returns the status message sent, or None if nothing is due.""" with self.lock: h = self._due(handoff_id) if h is None: return None item = h.script[h.pos] if item == WAIT: missing = [k for k in NEEDED if not h.details.get(k)] if not missing: h.pos += 2 # past the wait, and the step it releases item = h.script[h.pos - 1] elif h.status == "negotiating": return None # still waiting for handoff.update else: item = ("negotiating", {"needs": missing}) else: h.pos += 1 status, extra = item h.seq += 1 h.status = status msg = message("handoff.status", h.request["case"], handoff_id=h.request["handoff_id"], seq=h.seq, status=status, **extra) h.sent.append(msg) url = h.request["status_url"] post(url, msg, (self.peer_id, self.keys[self.email_peer])) return msg def run(self, handoff_id: Optional[str] = None, limit: int = 20) -> list[dict]: """Step until nothing more is due (terminal, waiting, or silent).""" out = [] for _ in range(limit): m = self.step(handoff_id) if m is None: break out.append(m) return out def _due(self, handoff_id): for hid, h in self.handoffs.items(): if handoff_id not in (None, hid) or h.status in TERMINAL: continue if h.pos < len(h.script): return h return None def server(self, host="127.0.0.1", port=0): step = lambda body, peer, rest: (200, {"ok": True, "sent": self.step(body.get("handoff_id"))}) # noqa: E731 return json_server({"/handoffs": self.receive, "/step": step}, self.keys, frozenset({"/step"}), host, port)