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
103 lines
3.8 KiB
Python
103 lines
3.8 KiB
Python
"""Contract building blocks shared by the email side and the stubs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Callable, Optional
|
|
|
|
from . import CONTRACT_VERSION, transitions
|
|
|
|
_T = transitions()
|
|
TERMINAL = frozenset(_T["terminal"])
|
|
|
|
|
|
def allowed(old: Optional[str], new: str) -> bool:
|
|
"""CONTRACT.md section 4. `old` None means a new handoff."""
|
|
return new in _T["transitions"].get(old or "(new)", [])
|
|
|
|
|
|
def message(type_: str, case: Optional[dict] = None, **fields) -> dict:
|
|
msg = {"mas_modes_version": CONTRACT_VERSION, "type": type_, "id": uuid.uuid4().hex}
|
|
if case is not None:
|
|
msg["case"] = case
|
|
msg.update({k: v for k, v in fields.items() if v is not None})
|
|
return msg
|
|
|
|
|
|
def post(url: str, msg: dict, timeout: float = 10.0) -> tuple[Optional[int], object]:
|
|
"""POST a contract message. (None, error) when the peer is unreachable."""
|
|
req = urllib.request.Request(url, data=json.dumps(msg).encode(), method="POST",
|
|
headers={"Content-Type": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return r.status, json.loads(r.read().decode() or "null")
|
|
except urllib.error.HTTPError as e:
|
|
try:
|
|
return e.code, json.loads(e.read().decode() or "null")
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return e.code, None
|
|
except (OSError, urllib.error.URLError) as e:
|
|
return None, str(e)
|
|
|
|
|
|
def refuse(error: str, detail: str = "", status: int = 409, **extra) -> tuple[int, dict]:
|
|
return status, {"ok": False, "error": error, "detail": detail, **extra}
|
|
|
|
|
|
class Idempotent:
|
|
"""Remember responses by message id (CONTRACT.md section 2)."""
|
|
|
|
def __init__(self):
|
|
self.seen: dict[str, tuple[int, dict]] = {}
|
|
self.lock = threading.Lock()
|
|
|
|
def __call__(self, msg: dict, handler: Callable[[dict], tuple[int, dict]]) -> tuple[int, dict]:
|
|
mid = msg.get("id")
|
|
with self.lock:
|
|
if mid in self.seen:
|
|
return self.seen[mid]
|
|
out = handler(msg)
|
|
if mid and out[0] < 500:
|
|
with self.lock:
|
|
self.seen[mid] = out
|
|
return out
|
|
|
|
|
|
def json_server(routes: dict[str, Callable[[dict], tuple[int, dict]]], host="127.0.0.1",
|
|
port=0) -> ThreadingHTTPServer:
|
|
"""A loopback JSON-over-HTTP server: POST path -> handler(body) -> (status, body)."""
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
|
fn = routes.get(self.path)
|
|
if fn is None:
|
|
return self._send(404, {"ok": False, "error": "not-found"})
|
|
try:
|
|
data = json.loads(body.decode())
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return self._send(400, {"ok": False, "error": "invalid-json"})
|
|
self._send(*fn(data))
|
|
|
|
def _send(self, status, obj):
|
|
out = json.dumps(obj).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(out)))
|
|
self.end_headers()
|
|
self.wfile.write(out)
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
return ThreadingHTTPServer((host, port), Handler)
|
|
|
|
|
|
def serve(server: ThreadingHTTPServer) -> str:
|
|
"""Run in a daemon thread; returns the base URL."""
|
|
threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start()
|
|
return f"http://{server.server_address[0]}:{server.server_address[1]}"
|