conformance/FLOWS.md defines setup, steps, captures and expectations, all observable at the contract boundary. flows.json carries setups (peer ids), fixed test keys, statute, scenario and explicit assign steps; scenarios.json holds the callback scenarios (the stub loads them). CONTRACT.md defines the case.assign response. Runner reads only the bundle and rejects undefined keys; new flow s-006 (held over cap). 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
161 lines
6.3 KiB
Python
161 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({"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)
|