relayloom 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- relayloom/__init__.py +3 -0
- relayloom/__main__.py +39 -0
- relayloom/ap/__init__.py +0 -0
- relayloom/ap/__main__.py +3 -0
- relayloom/ap/alerts.py +228 -0
- relayloom/ap/app.py +244 -0
- relayloom/ap/benchmark.py +221 -0
- relayloom/ap/casefile.py +72 -0
- relayloom/ap/cases.py +206 -0
- relayloom/ap/crossing.py +48 -0
- relayloom/ap/diagnosis.py +79 -0
- relayloom/ap/gateway.py +113 -0
- relayloom/ap/i18n.py +44 -0
- relayloom/ap/investigator.py +236 -0
- relayloom/ap/ledger.py +224 -0
- relayloom/ap/lookagent.py +146 -0
- relayloom/ap/masking.py +32 -0
- relayloom/ap/matcher.py +57 -0
- relayloom/ap/models.py +235 -0
- relayloom/ap/phone_page.py +82 -0
- relayloom/ap/pipeline.py +537 -0
- relayloom/ap/policy.json +75 -0
- relayloom/ap/policy.py +66 -0
- relayloom/ap/portal.py +121 -0
- relayloom/ap/py.typed +0 -0
- relayloom/ap/receipts.py +159 -0
- relayloom/ap/receiving.py +92 -0
- relayloom/ap/sam_worker.py +85 -0
- relayloom/ap/schema.py +83 -0
- relayloom/ap/track_worker.py +62 -0
- relayloom/ap/video.py +158 -0
- relayloom/cli.py +447 -0
- relayloom/controller.py +493 -0
- relayloom/meta.py +209 -0
- relayloom/policy.json +37 -0
- relayloom/py.typed +0 -0
- relayloom/server.py +190 -0
- relayloom-0.3.0.dist-info/METADATA +101 -0
- relayloom-0.3.0.dist-info/RECORD +43 -0
- relayloom-0.3.0.dist-info/WHEEL +5 -0
- relayloom-0.3.0.dist-info/entry_points.txt +2 -0
- relayloom-0.3.0.dist-info/licenses/LICENSE +21 -0
- relayloom-0.3.0.dist-info/top_level.txt +1 -0
relayloom/__init__.py
ADDED
relayloom/__main__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Start Relayloom. Rehearsal is the default; Meta requires explicit opt-in."""
|
|
2
|
+
import argparse
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .controller import Controller
|
|
6
|
+
from .server import LocalServer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
11
|
+
parser.add_argument("--port", type=int, default=8800)
|
|
12
|
+
parser.add_argument("--database", type=Path, default=Path("data/relayloom.sqlite3"))
|
|
13
|
+
parser.add_argument("--enable-meta-synthetic", action="store_true", help="Allow billable Meta calls only after explicit environment configuration; synthetic fixture only")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
if not 1 <= args.port <= 65535:
|
|
16
|
+
parser.error("port must be between 1 and 65535")
|
|
17
|
+
if args.enable_meta_synthetic:
|
|
18
|
+
from .meta import MetaWorker
|
|
19
|
+
try:
|
|
20
|
+
worker = MetaWorker.from_environment()
|
|
21
|
+
except (ValueError, OSError) as error:
|
|
22
|
+
parser.error(str(error))
|
|
23
|
+
controller = Controller(args.database, worker=worker)
|
|
24
|
+
else:
|
|
25
|
+
controller = Controller(args.database)
|
|
26
|
+
server = LocalServer(("127.0.0.1", args.port), controller)
|
|
27
|
+
print(f"Relayloom {controller.mode} mode: http://127.0.0.1:{args.port}")
|
|
28
|
+
print("Synthetic specialist outputs. No live inference or external actions." if controller.mode == "rehearsal" else "Meta enabled for the bundled fictional fixture. Run specialists may consume credits. No external actions.")
|
|
29
|
+
try:
|
|
30
|
+
server.serve_forever()
|
|
31
|
+
except KeyboardInterrupt:
|
|
32
|
+
pass
|
|
33
|
+
finally:
|
|
34
|
+
server.server_close()
|
|
35
|
+
controller.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
relayloom/ap/__init__.py
ADDED
|
File without changes
|
relayloom/ap/__main__.py
ADDED
relayloom/ap/alerts.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Alerts to a phone on the same local network. Nothing leaves the building and no internet is needed.
|
|
2
|
+
|
|
3
|
+
AlertHub keeps the alerts the agents raise (a delivery needs the owner, the network was cut, hidden text tried to change
|
|
4
|
+
a bank account, a readback failed). PhoneServer is a separate, tiny, read only server that starts only when asked
|
|
5
|
+
(`--phone`). It shows the alerts on a phone page and nothing else: no approvals, no bills, no ledger. Every request needs
|
|
6
|
+
the random token that is printed when it starts, compared in constant time. The main app stays on loopback.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hmac
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
import json
|
|
13
|
+
import secrets
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from urllib.parse import parse_qs, urlsplit
|
|
18
|
+
|
|
19
|
+
from . import i18n
|
|
20
|
+
from .phone_page import page
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AlertHub:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.items: list[dict] = []
|
|
26
|
+
self.changed = threading.Condition()
|
|
27
|
+
|
|
28
|
+
def publish(self, kind: str, title: str, body: str, case: str = "", key: str = "", params: dict | None = None) -> dict:
|
|
29
|
+
with self.changed:
|
|
30
|
+
item = {"id": len(self.items) + 1, "time": time.strftime("%H:%M:%S"), "kind": kind, "title": title,
|
|
31
|
+
"body": body, "case": case, "key": key, "params": params or {}}
|
|
32
|
+
self.items.append(item)
|
|
33
|
+
self.changed.notify_all()
|
|
34
|
+
return item
|
|
35
|
+
|
|
36
|
+
def after(self, last_id: int, wait: float = 0.0) -> list[dict]:
|
|
37
|
+
"""Alerts newer than last_id. Waits up to `wait` seconds for one to arrive (long polling)."""
|
|
38
|
+
with self.changed:
|
|
39
|
+
if wait and not [i for i in self.items if i["id"] > last_id]:
|
|
40
|
+
self.changed.wait(timeout=wait)
|
|
41
|
+
return [i for i in self.items if i["id"] > last_id]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lan_address() -> str:
|
|
45
|
+
"""This machine's address on the local network. Connecting a UDP socket sends no packet."""
|
|
46
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
47
|
+
try:
|
|
48
|
+
probe.connect(("10.255.255.255", 1))
|
|
49
|
+
return probe.getsockname()[0]
|
|
50
|
+
except OSError:
|
|
51
|
+
return "127.0.0.1"
|
|
52
|
+
finally:
|
|
53
|
+
probe.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def lan_addresses() -> list[str]:
|
|
57
|
+
"""Every address a phone on a connected network could use, the default route first. A laptop on Wi-Fi and a USB cable has two."""
|
|
58
|
+
first = lan_address()
|
|
59
|
+
found = []
|
|
60
|
+
try:
|
|
61
|
+
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
|
62
|
+
ip = info[4][0]
|
|
63
|
+
if not ip.startswith(("127.", "169.254.")):
|
|
64
|
+
found.append(ip)
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
ordered = ([first] if first != "127.0.0.1" else []) + [ip for ip in dict.fromkeys(found) if ip != first]
|
|
68
|
+
return ordered or [first]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def announce(server: "PhoneServer") -> None:
|
|
72
|
+
"""Print the phone links, one per network, and a QR code for each so a phone camera can scan it."""
|
|
73
|
+
print("Phone alerts, local network only, read only. Open the link that is on the SAME network as the phone,")
|
|
74
|
+
print("or scan its QR code with the phone camera. Anyone with a link sees the alerts, so keep it to yourself.", flush=True)
|
|
75
|
+
for ip in lan_addresses():
|
|
76
|
+
link = server.link(ip)
|
|
77
|
+
print(f"\n {link}", flush=True)
|
|
78
|
+
try:
|
|
79
|
+
import segno
|
|
80
|
+
segno.make(link, error="m").terminal(compact=False, border=2) # colour blocks made of spaces: safe on any console encoding
|
|
81
|
+
except ImportError:
|
|
82
|
+
print(" (install segno with: python -m pip install segno, to get a QR code here)", flush=True)
|
|
83
|
+
except Exception: # a QR code that cannot be drawn never stops the alerts
|
|
84
|
+
print(" (the QR code could not be drawn in this window, use the link above)", flush=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
IMAGE_TYPES = {"image/jpeg": bytes([0xFF, 0xD8, 0xFF]), "image/png": bytes([0x89]) + b"PNG", "image/webp": b"RIFF"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PhoneHandler(BaseHTTPRequestHandler):
|
|
94
|
+
server_version = "RelayloomPhone"
|
|
95
|
+
|
|
96
|
+
def log_message(self, format, *args): # quiet: no tokens in logs
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
def reply(self, status: int, kind: str, body: bytes):
|
|
100
|
+
self.send_response(status)
|
|
101
|
+
self.send_header("Content-Type", kind)
|
|
102
|
+
self.send_header("Content-Length", str(len(body)))
|
|
103
|
+
self.send_header("Cache-Control", "no-store")
|
|
104
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
105
|
+
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'")
|
|
106
|
+
try:
|
|
107
|
+
self.end_headers()
|
|
108
|
+
self.wfile.write(body)
|
|
109
|
+
except (ConnectionError, OSError): # the phone went away while waiting; nothing to report
|
|
110
|
+
self.close_connection = True
|
|
111
|
+
|
|
112
|
+
def do_GET(self):
|
|
113
|
+
url = urlsplit(self.path)
|
|
114
|
+
query = parse_qs(url.query)
|
|
115
|
+
given = (query.get("t") or [""])[0]
|
|
116
|
+
if not hmac.compare_digest(given.encode(), self.server.token.encode()):
|
|
117
|
+
return self.reply(403, "text/plain; charset=utf-8", b"forbidden")
|
|
118
|
+
if url.path == "/":
|
|
119
|
+
return self.reply(200, "text/html; charset=utf-8", page().encode())
|
|
120
|
+
if url.path == "/alerts":
|
|
121
|
+
try:
|
|
122
|
+
last = int((query.get("after") or ["0"])[0])
|
|
123
|
+
except ValueError:
|
|
124
|
+
last = 0
|
|
125
|
+
items = self.server.hub.after(last, wait=self.server.wait)
|
|
126
|
+
return self.reply(200, "application/json", json.dumps(items).encode())
|
|
127
|
+
self.reply(404, "text/plain; charset=utf-8", b"not found")
|
|
128
|
+
|
|
129
|
+
def do_POST(self): # read only, except one thing: a photo of the goods, counted on the laptop
|
|
130
|
+
url = urlsplit(self.path)
|
|
131
|
+
query = parse_qs(url.query)
|
|
132
|
+
if url.path != "/photo" or not hmac.compare_digest((query.get("t") or [""])[0].encode(), self.server.token.encode()):
|
|
133
|
+
self.drain()
|
|
134
|
+
return self.reply(405 if url.path != "/photo" else 403, "text/plain; charset=utf-8", b"read only" if url.path != "/photo" else b"forbidden")
|
|
135
|
+
handler = self.server.photo_handler
|
|
136
|
+
if handler is None:
|
|
137
|
+
self.drain()
|
|
138
|
+
return self.json_reply(404, {"error": "This laptop is not taking photos"})
|
|
139
|
+
kind = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
|
140
|
+
try:
|
|
141
|
+
length = int(self.headers.get("Content-Length") or "")
|
|
142
|
+
except ValueError:
|
|
143
|
+
return self.json_reply(411, {"error": "Length required"})
|
|
144
|
+
if length <= 0 or length > self.server.max_photo:
|
|
145
|
+
self.close_connection = True
|
|
146
|
+
return self.json_reply(413, {"error": f"Photo must be under {self.server.max_photo // 1048576} MB"})
|
|
147
|
+
body = self.rfile.read(length)
|
|
148
|
+
if kind not in IMAGE_TYPES or not body.startswith(IMAGE_TYPES[kind]):
|
|
149
|
+
return self.json_reply(415, {"error": "Only JPEG, PNG or WebP photos are accepted"})
|
|
150
|
+
word = (query.get("word") or ["box"])[0]
|
|
151
|
+
self.json_reply(202, handler(body, kind, word))
|
|
152
|
+
|
|
153
|
+
def drain(self):
|
|
154
|
+
"""Read (and drop) a small refused body, so the client sees the answer instead of a reset connection."""
|
|
155
|
+
try:
|
|
156
|
+
remaining = min(int(self.headers.get("Content-Length") or 0), 1048576)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return
|
|
159
|
+
while remaining > 0:
|
|
160
|
+
chunk = self.rfile.read(min(remaining, 65536))
|
|
161
|
+
if not chunk:
|
|
162
|
+
break
|
|
163
|
+
remaining -= len(chunk)
|
|
164
|
+
|
|
165
|
+
def json_reply(self, status: int, value: dict):
|
|
166
|
+
self.reply(status, "application/json", json.dumps(value).encode())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PhoneServer(ThreadingHTTPServer):
|
|
170
|
+
daemon_threads = True
|
|
171
|
+
allow_reuse_address = False # on Windows reuse lets a second copy share the port and answer with a different token
|
|
172
|
+
|
|
173
|
+
def __init__(self, address, hub: AlertHub, token: str | None = None, wait: float = 20.0, photo_handler=None,
|
|
174
|
+
max_photo: int = 12 * 1048576):
|
|
175
|
+
self.hub, self.token, self.wait = hub, token or secrets.token_urlsafe(16), wait
|
|
176
|
+
self.photo_handler, self.max_photo = photo_handler, max_photo
|
|
177
|
+
super().__init__(address, PhoneHandler)
|
|
178
|
+
|
|
179
|
+
def start(self) -> threading.Thread:
|
|
180
|
+
thread = threading.Thread(target=self.serve_forever, daemon=True, name="phone-alerts")
|
|
181
|
+
thread.start()
|
|
182
|
+
return thread
|
|
183
|
+
|
|
184
|
+
def link(self, host: str) -> str:
|
|
185
|
+
return f"http://{host}:{self.server_address[1]}/?t={self.token}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PhoneInbox:
|
|
189
|
+
"""Takes photos from the phone, saves them on this laptop, counts them in the background, and alerts with the result.
|
|
190
|
+
|
|
191
|
+
`counter.count(photo, prompt, key)` is the same interface the pipeline uses, so SAM 3.1 or the look agent both fit.
|
|
192
|
+
Nothing leaves the local network: the photo goes phone to laptop, and the count is shown on the phone page.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self, folder, hub: AlertHub, counter=None):
|
|
196
|
+
from pathlib import Path
|
|
197
|
+
self.folder, self.hub, self.counter, self.threads = Path(folder), hub, counter, []
|
|
198
|
+
|
|
199
|
+
def __call__(self, body: bytes, kind: str, word: str) -> dict:
|
|
200
|
+
import re
|
|
201
|
+
word = word.strip().lower()
|
|
202
|
+
if not re.fullmatch(r"[a-z][a-z \-]{1,30}", word):
|
|
203
|
+
word = "box"
|
|
204
|
+
self.folder.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
name = time.strftime("dock-%Y%m%d-%H%M%S") + f"-{secrets.token_hex(2)}" + {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[kind]
|
|
206
|
+
path = self.folder / name
|
|
207
|
+
path.write_bytes(body)
|
|
208
|
+
self.say("photo", "a.photo_received", name=name, kb=len(body) // 1024, word=word)
|
|
209
|
+
if self.counter is None:
|
|
210
|
+
self.say("photo", "a.photo_saved")
|
|
211
|
+
else:
|
|
212
|
+
thread = threading.Thread(target=self.count, args=(path, word), daemon=True, name="phone-count")
|
|
213
|
+
thread.start()
|
|
214
|
+
self.threads.append(thread)
|
|
215
|
+
return {"received": name, "word": word, "counting": self.counter is not None}
|
|
216
|
+
|
|
217
|
+
def say(self, kind: str, key: str, **params):
|
|
218
|
+
made = i18n.alert(kind, key, **params)
|
|
219
|
+
self.hub.publish(kind, made["title"], made["body"], "phone", key, made["params"])
|
|
220
|
+
|
|
221
|
+
def count(self, path, word: str):
|
|
222
|
+
try:
|
|
223
|
+
started = time.perf_counter()
|
|
224
|
+
result = self.counter.count(path, word, path.stem)
|
|
225
|
+
seconds = round(time.perf_counter() - started, 1)
|
|
226
|
+
self.say("counted", "a.counted", n=result["count"], word=word, name=path.name, s=seconds)
|
|
227
|
+
except Exception as error: # a failed count is an alert, never a silent miss
|
|
228
|
+
self.say("readback", "a.count_failed", name=path.name, error=str(error)[:120])
|
relayloom/ap/app.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Relayloom accounts payable: the local app. Loopback only.
|
|
2
|
+
|
|
3
|
+
python -m relayloom.ap recorded mode: recorded Meta model outputs, built in ledger
|
|
4
|
+
python -m relayloom.ap --live Muse Glimmer and SAM 3.1 on this machine, ERPNext from .env
|
|
5
|
+
Mix with --reader, --counter and --ledger. The screen and every receipt say which ran.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
import json
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from urllib.parse import parse_qs, urlsplit
|
|
18
|
+
|
|
19
|
+
from .ledger import ERPNextLedger, LocalLedger
|
|
20
|
+
from .models import GlimmerClient, RecordedGlimmer, SamCounter
|
|
21
|
+
from .pipeline import Network, Workbench
|
|
22
|
+
from .policy import Refused, load_policy
|
|
23
|
+
from .portal import BrowserDisputer, Portal
|
|
24
|
+
from .receipts import ReceiptChain, Signer, verify
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
27
|
+
WEB = ROOT / "web" / "ap"
|
|
28
|
+
FIXTURES = ROOT / "fixtures"
|
|
29
|
+
MAX_BODY = 4096
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_env(path: Path = ROOT / ".env"):
|
|
33
|
+
"""Read KEY=value lines into the environment without printing anything."""
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return
|
|
36
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
37
|
+
name, sep, value = line.partition("=")
|
|
38
|
+
if sep and name.strip() and not name.startswith("#") and value.strip():
|
|
39
|
+
os.environ.setdefault(name.strip(), value.strip())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build(args) -> tuple[Workbench, dict]:
|
|
43
|
+
data = json.loads((FIXTURES / "data.json").read_text(encoding="utf-8"))
|
|
44
|
+
run_dir = Path(args.data) / time.strftime("run-%Y%m%d-%H%M%S")
|
|
45
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
policy = load_policy()
|
|
47
|
+
signer = Signer(Path(args.data) / "receipt-signing.key")
|
|
48
|
+
chain = ReceiptChain(run_dir / "receipts.jsonl", signer, policy.hash)
|
|
49
|
+
if args.reader == "live":
|
|
50
|
+
reader = GlimmerClient(os.environ.get("GLIMMER_URL", "http://127.0.0.1:8082/v1"),
|
|
51
|
+
os.environ.get("GLIMMER_ROUTE", "Muse Glimmer 30B Q4_K_M, llama.cpp CPU, this machine"))
|
|
52
|
+
else:
|
|
53
|
+
reader = RecordedGlimmer()
|
|
54
|
+
counter = SamCounter(os.environ.get("RELAYLOOM_SAM_PYTHON", ""), os.environ.get("RELAYLOOM_SAM_WEIGHTS", ""),
|
|
55
|
+
os.environ.get("SAM_ROUTE", "SAM 3.1 (sam3.1_multiplex.pt), Ultralytics, CPU, this laptop"),
|
|
56
|
+
live=args.counter == "live")
|
|
57
|
+
if args.ledger == "erpnext":
|
|
58
|
+
def erp():
|
|
59
|
+
return ERPNextLedger(os.environ.get("ERPNEXT_URL", "http://localhost:8080"), os.environ["ERPNEXT_API_KEY"],
|
|
60
|
+
os.environ["ERPNEXT_API_SECRET"], os.environ.get("ERPNEXT_COMPANY", data["company"]["name"]))
|
|
61
|
+
ledger, checker = erp(), erp() # the Checker uses its own client and its own requests
|
|
62
|
+
data["tax_account"] = os.environ.get("ERPNEXT_TAX_ACCOUNT", "")
|
|
63
|
+
else:
|
|
64
|
+
ledger = LocalLedger(run_dir / "local-ledger.sqlite3")
|
|
65
|
+
ledger.seed(data["suppliers"], data["purchase_orders"], data["history_invoices"])
|
|
66
|
+
checker = LocalLedger(run_dir / "local-ledger.sqlite3")
|
|
67
|
+
portal_server = Portal(args.portal_port).start()
|
|
68
|
+
disputer = BrowserDisputer(f"http://127.0.0.1:{args.portal_port}", run_dir / "media")
|
|
69
|
+
network = Network(ledger, checker, disputer)
|
|
70
|
+
bench = Workbench(policy, ledger, checker, chain, reader, counter, network, FIXTURES, data, portal=disputer)
|
|
71
|
+
run = {"started": time.strftime("%Y-%m-%d %H:%M %z"), "dir": run_dir.name, "ledger": ledger.name,
|
|
72
|
+
"reader": "Muse Glimmer (live)" if args.reader == "live" else "Muse Glimmer (recorded outputs)",
|
|
73
|
+
"counter": "SAM 3.1 (live)" if args.counter == "live" else "SAM 3.1 (recorded outputs)",
|
|
74
|
+
"mode": "live" if args.reader == args.counter == "live" else "recorded" if args.reader == args.counter == "recorded" else "mixed",
|
|
75
|
+
"public_key": signer.public_key, "policy_hash": policy.hash}
|
|
76
|
+
(run_dir / "run.json").write_text(json.dumps(run, indent=1), encoding="utf-8")
|
|
77
|
+
bench.portal_server = portal_server
|
|
78
|
+
bench.run_dir = run_dir
|
|
79
|
+
return bench, run
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class App(ThreadingHTTPServer):
|
|
83
|
+
daemon_threads = True
|
|
84
|
+
|
|
85
|
+
def __init__(self, address, bench: Workbench, run: dict):
|
|
86
|
+
if address[0] != "127.0.0.1":
|
|
87
|
+
raise ValueError("bind to loopback only")
|
|
88
|
+
self.bench, self.run = bench, run
|
|
89
|
+
super().__init__(address, Handler)
|
|
90
|
+
|
|
91
|
+
def state(self) -> dict:
|
|
92
|
+
bench = self.bench
|
|
93
|
+
cases = [case.view() for case in bench.cases.values()]
|
|
94
|
+
counts = {k: sum(1 for c in cases if c["state"] in v) for k, v in {
|
|
95
|
+
"entered_checked": ("entered_checked",), "held_for_owner": ("held_for_owner",),
|
|
96
|
+
"held_offline": ("held_offline",), "disputes_sent": ("dispute_sent",),
|
|
97
|
+
"other": ("failed", "readback_failed", "closed_by_owner")}.items()}
|
|
98
|
+
gateway_log = ROOT / "data" / "gateway.jsonl"
|
|
99
|
+
gateway = [json.loads(l) for l in gateway_log.read_text(encoding="utf-8").splitlines()[-50:]] if gateway_log.exists() else []
|
|
100
|
+
masks = [{"case": c["id"], "kinds": [m["kind"] for m in c["masks"]]} for c in cases if c["masks"]]
|
|
101
|
+
return {"run": self.run, "online": bench.network.online, "cases": cases, "counts": counts,
|
|
102
|
+
"total_cases": len(bench.data["cases"]),
|
|
103
|
+
"policy": {"hash": bench.policy.hash, "operations": {k: v["level"] for k, v in bench.policy.data["operations"].items()},
|
|
104
|
+
"limits": bench.policy.limits, "egress": bench.policy.data["egress"]},
|
|
105
|
+
"boundary": {"gateway_log": gateway, "masks": masks, "model_calls_outside": 0 if not gateway else
|
|
106
|
+
sum(1 for g in gateway if g.get("decision") == "allowed")}}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Handler(BaseHTTPRequestHandler):
|
|
110
|
+
server_version = "Relayloom"
|
|
111
|
+
sys_version = ""
|
|
112
|
+
|
|
113
|
+
def log_message(self, format, *args):
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
def common(self, status, kind, length):
|
|
117
|
+
self.send_response(status)
|
|
118
|
+
self.send_header("Content-Type", kind)
|
|
119
|
+
self.send_header("Content-Length", str(length))
|
|
120
|
+
self.send_header("Cache-Control", "no-store")
|
|
121
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
122
|
+
self.send_header("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; "
|
|
123
|
+
"img-src 'self'; connect-src 'self'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'")
|
|
124
|
+
self.end_headers()
|
|
125
|
+
|
|
126
|
+
def json(self, status, value):
|
|
127
|
+
body = json.dumps(value, ensure_ascii=False).encode()
|
|
128
|
+
self.common(status, "application/json; charset=utf-8", len(body))
|
|
129
|
+
self.wfile.write(body)
|
|
130
|
+
|
|
131
|
+
def file(self, path: Path):
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
return self.json(404, {"error": "not found"})
|
|
134
|
+
body = path.read_bytes()
|
|
135
|
+
self.common(200, mimetypes.guess_type(path.name)[0] or "application/octet-stream", len(body))
|
|
136
|
+
self.wfile.write(body)
|
|
137
|
+
|
|
138
|
+
def do_GET(self):
|
|
139
|
+
url = urlsplit(self.path)
|
|
140
|
+
app = self.server
|
|
141
|
+
if url.path == "/api/state":
|
|
142
|
+
return self.json(200, app.state())
|
|
143
|
+
if url.path == "/api/receipts":
|
|
144
|
+
case = parse_qs(url.query).get("case", [None])[0]
|
|
145
|
+
receipts = app.bench.chain.read(case)
|
|
146
|
+
ok, problems = verify(app.bench.chain.read(), app.run["public_key"])
|
|
147
|
+
return self.json(200, {"receipts": receipts, "verified": ok, "problems": problems,
|
|
148
|
+
"public_key": app.run["public_key"]})
|
|
149
|
+
if url.path == "/api/benchmark":
|
|
150
|
+
path = ROOT / "docs" / "benchmark-results.json"
|
|
151
|
+
return self.file(path) if path.exists() else self.json(404, {"error": "no benchmark results yet"})
|
|
152
|
+
if url.path.startswith("/media/"):
|
|
153
|
+
name = Path(url.path[7:]).name
|
|
154
|
+
for folder in (app.bench.run_dir / "media", FIXTURES / "photos", FIXTURES / "recorded" / "sam"):
|
|
155
|
+
if (folder / name).is_file():
|
|
156
|
+
return self.file(folder / name)
|
|
157
|
+
return self.json(404, {"error": "not found"})
|
|
158
|
+
if url.path == "/strings.json":
|
|
159
|
+
return self.file(Path(__file__).with_name("strings.json"))
|
|
160
|
+
name = "index.html" if url.path in ("/", "") else Path(url.path).name
|
|
161
|
+
return self.file(WEB / name)
|
|
162
|
+
|
|
163
|
+
def do_POST(self):
|
|
164
|
+
app = self.server
|
|
165
|
+
origin = self.headers.get("Origin", "")
|
|
166
|
+
if self.headers.get("X-Relayloom") != "1" or (origin and urlsplit(origin).hostname != "127.0.0.1"):
|
|
167
|
+
return self.json(403, {"error": "same origin requests only"})
|
|
168
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
169
|
+
if length > MAX_BODY:
|
|
170
|
+
return self.json(413, {"error": "too large"})
|
|
171
|
+
try:
|
|
172
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
173
|
+
except ValueError:
|
|
174
|
+
return self.json(400, {"error": "bad json"})
|
|
175
|
+
bench = app.bench
|
|
176
|
+
try:
|
|
177
|
+
if self.path == "/api/batch":
|
|
178
|
+
names = body.get("cases") or [c["case"] for c in bench.data["cases"]]
|
|
179
|
+
records = [c for c in bench.data["cases"] if c["case"] in names and c["case"] not in bench.cases]
|
|
180
|
+
bench.submit(records)
|
|
181
|
+
return self.json(200, {"started": [r["case"] for r in records]})
|
|
182
|
+
if self.path == "/api/decide":
|
|
183
|
+
threading.Thread(target=self.safe, args=(bench.decide, body.get("case", ""), body.get("decision", ""), "Owner"),
|
|
184
|
+
daemon=True).start()
|
|
185
|
+
return self.json(202, {"ok": True})
|
|
186
|
+
if self.path == "/api/network":
|
|
187
|
+
if body.get("online"):
|
|
188
|
+
threading.Thread(target=self.safe, args=(bench.reconnect,), daemon=True).start()
|
|
189
|
+
else:
|
|
190
|
+
bench.network.cut()
|
|
191
|
+
return self.json(200, {"online": bool(body.get("online"))})
|
|
192
|
+
except (Refused, KeyError) as error:
|
|
193
|
+
return self.json(409, {"error": str(error)})
|
|
194
|
+
return self.json(404, {"error": "not found"})
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def safe(function, *args):
|
|
198
|
+
try:
|
|
199
|
+
function(*args)
|
|
200
|
+
except Exception as error: # Shown on the case through its receipts; the server keeps running.
|
|
201
|
+
print(f"action refused: {type(error).__name__}: {error}", flush=True)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main():
|
|
205
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
206
|
+
parser.add_argument("--port", type=int, default=8800)
|
|
207
|
+
parser.add_argument("--portal-port", type=int, default=8890)
|
|
208
|
+
parser.add_argument("--live", action="store_true", help="Glimmer and SAM 3.1 live on this machine, ERPNext ledger")
|
|
209
|
+
parser.add_argument("--reader", choices=("live", "recorded"), default="recorded")
|
|
210
|
+
parser.add_argument("--counter", choices=("live", "recorded"), default="recorded")
|
|
211
|
+
parser.add_argument("--ledger", choices=("local", "erpnext"), default="local")
|
|
212
|
+
parser.add_argument("--data", default=str(ROOT / "data" / "ap"))
|
|
213
|
+
parser.add_argument("--batch", action="store_true", help="start the five demo cases on launch")
|
|
214
|
+
parser.add_argument("--phone", action="store_true", help="alerts on a phone on your local network (read only, token link)")
|
|
215
|
+
parser.add_argument("--phone-port", type=int, default=8801)
|
|
216
|
+
args = parser.parse_args()
|
|
217
|
+
if args.live:
|
|
218
|
+
args.reader, args.counter, args.ledger = "live", "live", "erpnext"
|
|
219
|
+
load_env()
|
|
220
|
+
bench, run = build(args)
|
|
221
|
+
server = App(("127.0.0.1", args.port), bench, run)
|
|
222
|
+
if args.phone:
|
|
223
|
+
from .alerts import AlertHub, PhoneInbox, PhoneServer, announce
|
|
224
|
+
hub = AlertHub()
|
|
225
|
+
bench.notify = lambda kind, title, body, case, key="", params=None: hub.publish(kind, title, body, case, key, params)
|
|
226
|
+
inbox = PhoneInbox(Path(args.data) / "phone", hub, bench.counter if args.counter == "live" else None)
|
|
227
|
+
phone = PhoneServer(("0.0.0.0", args.phone_port), hub, photo_handler=inbox)
|
|
228
|
+
phone.start()
|
|
229
|
+
announce(phone)
|
|
230
|
+
print(f"Relayloom accounts payable, {run['mode']} mode: http://127.0.0.1:{args.port}")
|
|
231
|
+
print(f"Reader: {run['reader']}. Counter: {run['counter']}. Ledger: {run['ledger']}. Demo data.")
|
|
232
|
+
if args.batch:
|
|
233
|
+
bench.submit(bench.data["cases"])
|
|
234
|
+
try:
|
|
235
|
+
server.serve_forever()
|
|
236
|
+
except KeyboardInterrupt:
|
|
237
|
+
pass
|
|
238
|
+
finally:
|
|
239
|
+
server.server_close()
|
|
240
|
+
bench.portal_server.shutdown()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
main()
|