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 ADDED
@@ -0,0 +1,3 @@
1
+ """Relayloom: accounts payable that checks the goods, not just the paperwork."""
2
+
3
+ __version__ = "0.3.0"
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()
File without changes
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ main()
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()