mail-address-semantics/mas_modes/external.py
tegwick a841ceec73 MAS-WP-0009 T01: independent JS implementation (impl/js), differential test, SCP-9
impl/js (Node, zero deps, written from the specs): parser, v0.2
statute-aware rules, signing, outside master. Passes addresses.json,
statute-aware.json and auth.json unchanged; its master passes the 11
slave-setup mode flows against the Python email side (ExternalMaster).
Differential testing vs mas_core found two unstated stand-in rules, now
SCP-9 in the v0.2 draft with fixtures si-011..013, dt-109; ABNF comments
no longer say v0.1. Runner: external masters, step errors fail the flow.

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
2026-10-19 13:22:22 +02:00

78 lines
2.9 KiB
Python

"""An outside master in another process, driven through its control API.
Same interface as `stubs.master.StubMaster`, so the harness and the flow
runner can swap it in. Used for the independent JS master in
`impl/js/src/master.mjs` (MAS-WP-0009).
"""
from __future__ import annotations
import json
import subprocess
import urllib.request
from pathlib import Path
from typing import Optional
ROOT = Path(__file__).resolve().parents[1]
JS_MASTER = ROOT / "impl/js/src/master.mjs"
def _call(url: str, body: Optional[dict] = None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method="GET" if body is None else "POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode())
class ExternalMaster:
def __init__(self, argv: list[str]):
self.proc = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
info = json.loads(self.proc.stdout.readline())
self.events_url, self.control_url = info["events_url"], info["control_url"]
self._email_url = None
@classmethod
def js(cls, key: bytes, peer_id: str = "cms-1") -> "ExternalMaster":
return cls(["node", str(JS_MASTER), "--peer-id", peer_id, "--key-hex", key.hex()])
@property
def email_url(self):
return self._email_url
@email_url.setter
def email_url(self, url):
self._email_url = url
_call(self.control_url + "/config", {"email_url": url})
@property
def received(self) -> list[dict]:
return _call(self.control_url + "/received")
def of_type(self, type_: str) -> list[dict]:
return [m for m in self.received if m["type"] == type_]
def command(self, type_: str, case: Optional[dict] = None, **fields):
out = _call(self.control_url + "/command",
{"type": type_, **({"case": case} if case else {}),
**{k: v for k, v in fields.items() if v is not None}})
return out["status"], out["body"]
def assign(self, intake_ref: str, resource_id: str):
return self.command("case.assign", intake_ref=intake_ref, resource_id=resource_id)
def policy(self, case: dict, autonomous_send=None, transfers=None):
return self.command("case.policy", case, autonomous_send=autonomous_send, transfers=transfers)
def send(self, case: dict, in_reply_to: str, body: str, subject: Optional[str] = None):
return self.command("mail.send", case, in_reply_to=in_reply_to, body=body, subject=subject)
def cancel(self, case: dict, handoff_id: str, reason: str = "timed-out"):
return self.command("handoff.cancel", case, handoff_id=handoff_id, reason=reason)
def pump(self):
return []
def close(self):
self.proc.terminate()
self.proc.wait(timeout=10)