Resolve opencode workspace + session dynamically per request in notify forwarder
CI / build + unit (pull_request) Successful in 59s
CI / build + unit (pull_request) Successful in 59s
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mesh-bot notify forwarder: POST /notify {text, from?} -> opencode session message (basic auth).
|
||||
|
||||
The opencode workspace UUID is regenerated on host restarts, so the target workspace + session are
|
||||
resolved dynamically per request via the opencode API (no caching — messages are rare): list
|
||||
workspaces, pick the mesh-bot one by path (fallback: name), then its most recently updated session.
|
||||
|
||||
Note on semantics: the opencode API accepts a session message immediately but holds the HTTP
|
||||
response until the agent turn completes — so a read timeout here means DELIVERED, not failed.
|
||||
"""
|
||||
@@ -8,11 +12,43 @@ import json, base64, urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import os
|
||||
|
||||
BASE = os.environ.get("FORWARDER_BASE", "http://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance")
|
||||
SESSION = os.environ.get("FORWARDER_SESSION", "ses_f31037caeffeioRwixqGV7VS6m")
|
||||
API = os.environ.get("FORWARDER_API", "http://127.0.0.1:9898")
|
||||
WORKSPACE_PATH = os.environ.get("FORWARDER_WORKSPACE_PATH", "/home/krzyw/projects/sandbox/mesh-bot")
|
||||
SESSION_OVERRIDE = os.environ.get("FORWARDER_SESSION", "") # when set, skips session discovery
|
||||
USER = os.environ["FORWARDER_USER"] # required; fail fast if missing
|
||||
PASS = os.environ["FORWARDER_PASS"] # required; fail fast if missing
|
||||
|
||||
def _auth_headers():
|
||||
return {"Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()}
|
||||
|
||||
def _get_json(url):
|
||||
req = urllib.request.Request(url, headers=_auth_headers())
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"opencode API unreachable at {url}: {e!r}") from None
|
||||
|
||||
def resolve_target():
|
||||
"""Resolve (base_url, session_id) fresh per request; raises RuntimeError with a clear reason."""
|
||||
workspaces = _get_json(API + "/api/workspaces")
|
||||
matches = [w for w in workspaces if w.get("path") == WORKSPACE_PATH] or \
|
||||
[w for w in workspaces if w.get("name") == "mesh-bot"]
|
||||
pool = [w for w in matches if w.get("status") == "ready"] or matches
|
||||
if not pool:
|
||||
raise RuntimeError(f"workspace not found: path={WORKSPACE_PATH} name=mesh-bot")
|
||||
base = API + pool[0]["proxyPath"]
|
||||
sid = SESSION_OVERRIDE
|
||||
if not sid:
|
||||
sessions = _get_json(base + "/session")
|
||||
if not sessions:
|
||||
raise RuntimeError(f"no sessions in workspace {pool[0]['id']} ({WORKSPACE_PATH})")
|
||||
|
||||
def updated(s):
|
||||
return (s.get("time") or {}).get("updated") or 0
|
||||
sid = max(sessions, key=updated)["id"] if any(updated(s) for s in sessions) else sessions[0]["id"]
|
||||
return base, sid
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path != "/notify":
|
||||
@@ -22,23 +58,27 @@ class H(BaseHTTPRequestHandler):
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
text, frm = body.get("text"), body.get("from")
|
||||
msg = 'New mesh message' + (f' from node {frm}' if frm is not None else '') + f': "{text}" — please react to it.'
|
||||
base, sid = resolve_target() # fresh per request; RuntimeError on API/workspace/session failure
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/session/{SESSION}/message",
|
||||
f"{base}/session/{sid}/message",
|
||||
data=json.dumps({"parts": [{"type": "text", "text": msg}]}).encode(),
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()},
|
||||
headers={"Content-Type": "application/json", **_auth_headers()},
|
||||
method="POST")
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=15)
|
||||
code = r.status # headers received => opencode accepted the message
|
||||
try: out = r.read(200).decode(errors='replace')
|
||||
except Exception as e2: out = f'(body read failed after delivery: {e2!r})'
|
||||
print(f"forwarded to opencode session {SESSION}: HTTP {code} {out[:150]!r}", flush=True)
|
||||
print(f"forwarded to opencode session {sid}: HTTP {code} {out[:150]!r}", flush=True)
|
||||
self._reply(code if 200 <= code < 300 else 502, b'{"ok":true}' if 200 <= code < 300 else str(code).encode())
|
||||
except TimeoutError:
|
||||
# request was sent; opencode holds the response until the agent turn finishes => delivered
|
||||
print(f"forwarded to opencode session {SESSION}: accepted (response still pending after 15 s)", flush=True)
|
||||
print(f"forwarded to opencode session {sid}: accepted (response still pending after 15 s)", flush=True)
|
||||
self._reply(202, b'{"ok":true,"note":"accepted; opencode response pending"}')
|
||||
except RuntimeError as e:
|
||||
# resolve_target() failure modes: API unreachable / workspace not found / no sessions in workspace
|
||||
print(f"target resolution failed: {e!r}", flush=True)
|
||||
self._reply(502, repr(e).encode())
|
||||
except Exception as e:
|
||||
print(f"opencode call failed: {e!r}", flush=True)
|
||||
self._reply(502, repr(e).encode())
|
||||
|
||||
Reference in New Issue
Block a user