mas-modes-v0.1-spec/ (contract, message + gateway-event schemas, transition table, message fixtures, statute) vendored into mas_modes. EmailMode implements the master role over the reference ticket service (MasterStore): handoff, negotiation by email, statuses in the thread, timeouts, resume by email, principle 10 shared via mas_modes.mailer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
"""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(default="no-answer-retry")
|
|
base = serve(stub.server()) # POST /handoffs, /step
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from .. import message_errors
|
|
from ..contract import TERMINAL, Idempotent, json_server, message, post, refuse
|
|
|
|
AT = "2026-10-20T10:00:00Z"
|
|
NEEDED = ("phone", "time")
|
|
WAIT = "wait-for-details" # negotiation: block until handoff.update supplies NEEDED
|
|
|
|
# Each step is (status, extra fields), or WAIT.
|
|
SCENARIOS: dict[str, list] = {
|
|
"completed": [("scheduled", {"at": AT}), ("running", {}), ("completed", {})],
|
|
"negotiate": [WAIT, ("scheduled", {"at": AT}), ("running", {}), ("completed", {})],
|
|
"no-answer-retry": [("scheduled", {"at": AT}), ("attempted", {"outcome": "no-answer"}),
|
|
("scheduled", {"at": "2026-10-20T14:00:00Z"}), ("running", {}),
|
|
("completed", {})],
|
|
"no-answer-fail": [("scheduled", {"at": AT}), ("attempted", {"outcome": "no-answer"}),
|
|
("scheduled", {"at": "2026-10-20T14:00:00Z"}),
|
|
("attempted", {"outcome": "no-answer"}),
|
|
("failed", {"detail": "two attempts, no answer"})],
|
|
"call-failed": [("scheduled", {"at": AT}), ("running", {}),
|
|
("failed", {"detail": "line dropped"})],
|
|
"expired": [("scheduled", {"at": AT}), ("expired", {"detail": "slot passed"})],
|
|
"unavailable": [("refused", {"reason": "mode-unavailable"})],
|
|
"declined": [("refused", {"reason": "declined"})],
|
|
"out-of-hours": [("refused", {"reason": "out-of-hours"})],
|
|
"silent": [], # never answers: the timeout owner has to act
|
|
}
|
|
|
|
|
|
@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, default: str = "completed"):
|
|
if default not in SCENARIOS:
|
|
raise ValueError(f"unknown scenario {default!r}")
|
|
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) -> tuple[int, dict]:
|
|
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)
|
|
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):
|
|
return json_server({"/handoffs": self.receive,
|
|
"/step": lambda body: (200, {"ok": True, "sent": self.step(body.get("handoff_id"))})},
|
|
host, port)
|