Scaffold: fork of Chambers architecture, renamed to Boardroom Map

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-06 13:10:25 -05:00
co-authored by Claude Fable 5
commit 1dde915540
48 changed files with 4025 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
"""Local lead-reviewer synthesis — no frontier model.
After the panel finishes, one more hardened container (the "lead reviewer") reads
all the individual reports (mounted read-only at /reports) plus the documents
(/docs), runs a configured local model, and writes a single consolidated report
to /out/CONSOLIDATED_REPORT.md: shared themes, where reviewers disagree, the
consensus, and an overall recommendation.
It reuses the same one-shot reviewer image, switched to BM_ROLE=synthesizer.
"""
from __future__ import annotations
import shlex
import spark_client as sc
import serving
DEFAULT_LEAD_PERSONA = (
"You are the lead reviewer chairing the panel. You did not read the documents "
"first-hand for a fresh opinion — your job is to CONSOLIDATE the panel's "
"individual reports into one authoritative report. Identify the findings the "
"reviewers agree on, surface and adjudicate where they conflict, note anything "
"only one reviewer caught, and end with a prioritized recommendation. Attribute "
"points to the reviewer(s) who raised them. Do not invent findings."
)
def pick_model(cfg: dict) -> str:
alias = (cfg.get("synthesisModel") or "").strip()
if alias:
return alias
models = cfg.get("models") or []
return models[0]["alias"] if models else ""
def run_synthesis(cfg: dict, jobdir: str, rubric: str, log, wait_timeout: int = 1800) -> dict:
"""Launch the lead-reviewer container and wait for the consolidated report."""
head = sc.head(cfg)
q = shlex.quote
model = pick_model(cfg)
if not model:
raise RuntimeError("no model available for synthesis (configure a model catalog)")
persona = (cfg.get("synthesisPersona") or "").strip() or DEFAULT_LEAD_PERSONA
net = serving.net_name(cfg)
base = serving.reviewer_proxy_base(cfg)
rid = "lead-reviewer"
sc.run(head,
f"mkdir -p {q(jobdir)}/personas {q(jobdir)}/synth-out && "
f"printf '%s' {q(persona)} > {q(jobdir)}/personas/{rid}.md && "
f"printf '%s' {q(rubric)} > {q(jobdir)}/RUBRIC.md",
timeout=30)
env = (
f"-e BM_REVIEWER_ID={q(rid)} -e BM_REVIEWER_NAME=lead-reviewer -e BM_ROLE=synthesizer "
f"-e BM_MODEL={q(model)} -e BM_LLM_BASE={q(base)} -e BM_LLM_KEY=sk-local "
f"-e BM_MAX_MODEL_LEN={q(str(int(cfg.get('maxModelLen', 32768))))} -e HOME=/home/rev "
)
harden = (
"--user 1000:1000 --security-opt no-new-privileges --cap-drop ALL "
"--read-only --tmpfs /tmp:size=256m --tmpfs /home/rev:size=128m "
"--pids-limit 256 --memory 6g --cpus 4"
)
mounts = (
f"-v {q(jobdir)}/docs:/docs:ro "
f"-v {q(jobdir)}/out:/reports:ro "
f"-v {q(jobdir)}/synth-out:/out "
f"-v {q(jobdir)}/personas/{rid}.md:/persona/PERSONA.md:ro "
f"-v {q(jobdir)}/RUBRIC.md:/RUBRIC.md:ro "
)
cname = f"bm-grader-{rid}"
cmd = (
f"docker rm -f {cname} >/dev/null 2>&1; "
f"docker run -d --name {cname} --network {q(net)} {harden} {env} {mounts} {q(cfg['graderImage'])}"
)
log(f"[synthesis] lead reviewer up -> {model}")
r = sc.run(head, cmd, timeout=120)
if r.returncode != 0:
raise RuntimeError(f"synthesis launch failed: {r.stderr or r.stdout}")
w = sc.run(head, f"docker wait {cname}", timeout=wait_timeout)
code = (w.stdout or "").strip()
chk = sc.run(head, f"test -s {q(jobdir)}/synth-out/CONSOLIDATED_REPORT.md && echo OK || echo MISSING", timeout=30)
wrote = "OK" in (chk.stdout or "")
sc.run(head, f"docker rm -f {cname} 2>/dev/null; true", timeout=30)
log(f"[synthesis] lead reviewer exited (code={code or '?'}), report={'written' if wrote else 'MISSING'}")
return {"model": model, "exit": code, "report": wrote}
+194
View File
@@ -0,0 +1,194 @@
"""Boardroom Map orchestrator web app.
Serves the control-panel UI and a small JSON API, and starts the background job
runner (see jobs.py) that actually convenes the panel. Most configuration happens
through the StartOS *actions* (Configure Sparks / Models / Reviewers / Review);
this UI is for dropping documents, triggering a review, watching it run, and
reading reports.
"""
from __future__ import annotations
import os
import threading
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
import bm_config
import extraction
import reviewers as rev_mod
import serving
from jobs import runner, INBOX, REPORTS_DIR
DATA_DIR = os.environ.get("BM_DATA_DIR", "/data")
app = FastAPI(title="Boardroom Map Orchestrator")
templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates"))
@app.on_event("startup")
def _startup():
runner.start()
# ----------------------------------------------------------------------- UI
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/healthz")
def healthz():
return {"ok": True}
# ----------------------------------------------------------------------- status
@app.get("/api/status")
def status():
cfg = bm_config.load()
catalog = {m["alias"] for m in (cfg.get("models") or [])}
return {
"configured": {
"sparks": bool(cfg.get("primarySparkHost")),
"models": len(cfg.get("models") or []),
"reviewers": len(cfg.get("reviewers") or []),
},
"networkMode": cfg.get("networkMode"),
"synthesis": bool(cfg.get("synthesisEnabled")),
"wipeRemoteDocs": bool(cfg.get("wipeRemoteDocs")),
"autoRunOnDrop": bool(cfg.get("autoRunOnDrop")),
"models": [{"alias": m["alias"], "hfModel": m["hfModel"], "spark": m.get("spark", "primary")}
for m in (cfg.get("models") or [])],
"panel": [{"name": r.get("name"), "model": r.get("model"),
"persona": bool((r.get("persona") or "").strip()),
"known": (r.get("model") in catalog)}
for r in (cfg.get("reviewers") or [])],
"inbox": _inbox_list(),
"runtime": runner.snapshot(),
}
@app.get("/api/events")
def events():
return {"events": runner.events()}
def _inbox_list() -> list[dict]:
if not os.path.isdir(INBOX):
return []
out = []
for fn in sorted(os.listdir(INBOX)):
p = os.path.join(INBOX, fn)
if os.path.isfile(p):
ext = os.path.splitext(fn)[1].lower()
out.append({"name": fn, "bytes": os.path.getsize(p),
"supported": ext in extraction.SUPPORTED})
return out
@app.get("/api/inbox")
def inbox():
return {"inbox": _inbox_list()}
# ----------------------------------------------------------------------- documents
@app.post("/api/upload")
async def upload(files: list[UploadFile] = File(...)):
os.makedirs(INBOX, exist_ok=True)
saved = []
for f in files:
name = os.path.basename(f.filename or "document")
dest = os.path.join(INBOX, name)
with open(dest, "wb") as out:
while chunk := await f.read(1 << 20):
out.write(chunk)
saved.append(name)
return {"ok": True, "saved": saved}
@app.post("/api/inbox/clear")
def inbox_clear():
if os.path.isdir(INBOX):
for fn in os.listdir(INBOX):
p = os.path.join(INBOX, fn)
if os.path.isfile(p):
os.remove(p)
return {"ok": True}
# ----------------------------------------------------------------------- run
@app.post("/api/run")
def run_now():
cfg = bm_config.load()
if not cfg.get("primarySparkHost"):
raise HTTPException(400, "No Spark configured (Configure Sparks).")
if not (cfg.get("models") and cfg.get("reviewers")):
raise HTTPException(400, "Configure at least one model and one reviewer first.")
if not _inbox_list():
raise HTTPException(400, "Inbox is empty — upload documents first.")
runner.request_run()
return {"ok": True, "message": "Review requested — watch the activity log."}
@app.get("/api/serving")
def serving_status():
cfg = bm_config.load()
if not cfg.get("primarySparkHost"):
raise HTTPException(400, "No Spark configured.")
return {"serving": serving.health(cfg)}
@app.post("/api/reviewer/build-image")
def build_reviewer_image():
cfg = bm_config.load()
if not cfg.get("primarySparkHost"):
raise HTTPException(400, "No Spark configured.")
threading.Thread(target=lambda: _safe_build(cfg), daemon=True).start()
return {"ok": True, "message": "Building reviewer image on the head Spark — watch the activity log."}
def _safe_build(cfg: dict):
try:
rev_mod.ensure_reviewer_image(cfg, runner.log)
except Exception as e:
runner.log(f"[reviewers] image build failed: {e}")
@app.post("/api/stop")
def stop():
cfg = bm_config.load()
if not cfg.get("primarySparkHost"):
raise HTTPException(400, "No Spark configured.")
serving.tear_down_all(cfg, runner.log)
runner.phase = "idle"
runner._persist()
return {"ok": True}
# ----------------------------------------------------------------------- reports
@app.get("/api/reports")
def list_reports():
if not os.path.isdir(REPORTS_DIR):
return {"reports": []}
jobs = sorted((d for d in os.listdir(REPORTS_DIR)
if os.path.isdir(os.path.join(REPORTS_DIR, d))), reverse=True)
return {"reports": jobs}
@app.get("/api/report", response_class=PlainTextResponse)
def latest_report():
path = os.path.join(REPORTS_DIR, "latest.md")
if not os.path.exists(path):
return "(no report yet — drop documents in the inbox and run a review)"
return open(path, errors="replace").read().strip() or "(empty report)"
@app.get("/api/reports/{job}", response_class=PlainTextResponse)
def get_report(job: str):
job = os.path.basename(job)
path = os.path.join(REPORTS_DIR, job, "report.md")
if not os.path.exists(path):
raise HTTPException(404, "no such report")
return open(path, errors="replace").read()
+78
View File
@@ -0,0 +1,78 @@
"""Config loading for the Boardroom Map orchestrator.
Defaults mirror startos/file-models/config.ts. The StartOS actions only persist
the fields the user actually touched, and Python (unlike the zod schema) does not
auto-fill defaults — so we apply them here. Keep in sync with the zod schema.
"""
from __future__ import annotations
import os
import spark_client as sc
DATA_DIR = os.environ.get("BM_DATA_DIR", "/data")
HF_TOKEN_PATH = os.path.join(DATA_DIR, "secrets", "hf_token")
DEFAULT_RUBRIC = (
"Review the attached document(s). Produce a structured report: a 3-5 sentence "
"summary, the key findings and insights, risks or red flags, open questions, "
"and concrete recommendations. Cite the document and section for each point. "
"Be honest about uncertainty; never invent facts not present in the documents."
)
CONFIG_DEFAULTS = {
# Spark connection
"primarySparkHost": "",
"primarySparkUser": "nvidia",
"sshPort": 22,
"secondarySparkHost": None,
"useBothSparks": False,
"headInternalHost": "127.0.0.1",
"remoteWorkDir": "/home/nvidia/boardroom-map",
# Images
"servingImage": "boardroom-vllm:latest",
"graderImage": "boardroom-grader:latest",
# Serving
"gpuMemoryUtilization": "0.85",
"maxModelLen": 32768,
"toolCallParser": "hermes",
"proxyPort": 4000,
"maxConcurrentModels": 1,
"models": [
{"alias": "reviewer-a", "hfModel": "Qwen/Qwen3-32B-FP8", "spark": "primary", "port": 8001},
],
# Review panel
"reviewers": [
{"name": "reviewer-1", "model": "reviewer-a", "persona": "", "temperature": None},
],
# Review job settings
"reviewInstructions": DEFAULT_RUBRIC,
"networkMode": "airgapped",
"searxngUrl": "",
"synthesisEnabled": True,
"synthesisModel": "",
"synthesisPersona": "",
"wipeRemoteDocs": True,
"autoRunOnDrop": False,
"networkName": "boardroom-net",
# Flags
"hfTokenSet": False,
}
def load() -> dict:
"""Return the merged config (defaults <- saved), or just defaults if unset."""
merged = dict(CONFIG_DEFAULTS)
try:
saved = sc.load_config()
except FileNotFoundError:
return merged
merged.update({k: v for k, v in saved.items() if v is not None})
return merged
def hf_token() -> str | None:
if os.path.exists(HF_TOKEN_PATH):
t = open(HF_TOKEN_PATH).read().strip()
return t or None
return None
+115
View File
@@ -0,0 +1,115 @@
"""Document text extraction — runs on the StartOS box (CPU only).
Confidential documents are dropped into /data/inbox. Before anything is shipped
to the Sparks, we extract plain text here so that only normalized text (never the
original binaries) crosses to the review containers. Supported formats:
.pdf -> pypdf
.docx -> python-docx
.txt .md .text -> read as UTF-8
Anything else is skipped with a note. Each extracted document becomes a single
UTF-8 .txt file in the per-job staging directory, which is rsynced to the Spark
and mounted read-only into every reviewer container at /docs.
"""
from __future__ import annotations
import os
TEXT_EXTS = {".txt", ".md", ".text", ".markdown"}
SUPPORTED = TEXT_EXTS | {".pdf", ".docx"}
def _extract_pdf(path: str) -> str:
from pypdf import PdfReader
reader = PdfReader(path)
parts = []
for i, page in enumerate(reader.pages, 1):
try:
txt = page.extract_text() or ""
except Exception as e:
txt = f"(page {i}: extraction error: {e})"
parts.append(f"\n\n===== page {i} =====\n{txt}")
return "".join(parts).strip()
def _extract_docx(path: str) -> str:
import docx
doc = docx.Document(path)
lines = [p.text for p in doc.paragraphs]
# Include table cell text too — contracts/specs often hide content in tables.
for table in doc.tables:
for row in table.rows:
cells = [c.text.strip() for c in row.cells]
if any(cells):
lines.append(" | ".join(cells))
return "\n".join(lines).strip()
def _extract_text(path: str) -> str:
with open(path, errors="replace") as f:
return f.read().strip()
def extract_file(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
if ext == ".pdf":
return _extract_pdf(path)
if ext == ".docx":
return _extract_docx(path)
if ext in TEXT_EXTS:
return _extract_text(path)
raise ValueError(f"unsupported file type: {ext or '(none)'}")
def _safe_name(name: str) -> str:
base = os.path.splitext(os.path.basename(name))[0]
keep = "".join(c if (c.isalnum() or c in "-_ ") else "_" for c in base).strip()
return (keep or "document").replace(" ", "_")
def extract_inbox(inbox: str, out_dir: str, log=print) -> list[dict]:
"""Extract every supported file in `inbox` to a .txt in `out_dir`.
Returns a manifest: [{source, out, chars, ok, error}]. Skips unsupported
files (recorded with ok=False) rather than failing the whole job."""
os.makedirs(out_dir, exist_ok=True)
manifest: list[dict] = []
if not os.path.isdir(inbox):
return manifest
seen: dict[str, int] = {}
for fn in sorted(os.listdir(inbox)):
src = os.path.join(inbox, fn)
if not os.path.isfile(src):
continue
ext = os.path.splitext(fn)[1].lower()
rec = {"source": fn, "out": None, "chars": 0, "ok": False, "error": ""}
if ext not in SUPPORTED:
rec["error"] = f"unsupported type {ext or '(none)'}"
log(f"[extract] skip {fn}: {rec['error']}")
manifest.append(rec)
continue
try:
text = extract_file(src)
except Exception as e:
rec["error"] = str(e)[:300]
log(f"[extract] FAILED {fn}: {rec['error']}")
manifest.append(rec)
continue
stem = _safe_name(fn)
if stem in seen:
seen[stem] += 1
stem = f"{stem}-{seen[stem]}"
else:
seen[stem] = 1
out_name = f"{stem}.txt"
out_path = os.path.join(out_dir, out_name)
header = f"# Source document: {fn}\n\n"
with open(out_path, "w") as f:
f.write(header + text + "\n")
rec.update({"out": out_name, "chars": len(text), "ok": True})
log(f"[extract] {fn} -> {out_name} ({len(text)} chars)")
manifest.append(rec)
return manifest
+162
View File
@@ -0,0 +1,162 @@
"""Launch the reviewer panel on the head Spark over SSH.
Each reviewer is a ONE-SHOT, hardened, read-only container: it reads the document
text mounted at /docs, runs its model (through the on-Spark proxy) under its
persona + the shared rubric, writes a single report to /out/<id>.md, and exits.
There is no shared writable workspace and no git — reviewers cannot alter the
documents or each other's reports.
Sandbox (per the operator's confidentiality requirement):
* non-root, --cap-drop ALL, --security-opt no-new-privileges
* read-only rootfs + small writable tmpfs; only /docs (ro), /persona (ro), and
/out (rw, this reviewer's report dir) are mounted
* NO docker socket, cpu/mem/pid caps
* attached to the per-job network — in airgapped mode that network is
--internal, so the reviewer can reach ONLY the model proxy, never the internet
Reviewers hold no credentials beyond a dummy proxy key.
"""
from __future__ import annotations
import os
import re
import shlex
import spark_client as sc
import serving
SANDBOX_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sandbox")
def ensure_reviewer_image(cfg: dict, log) -> None:
"""Build the reviewer image on the head Spark if missing (aarch64, native)."""
head = sc.head(cfg)
image = cfg["graderImage"]
q = shlex.quote
r = sc.run(head, f"docker image inspect {q(image)} >/dev/null 2>&1 && echo PRESENT || echo MISSING",
timeout=30)
if "PRESENT" in (r.stdout or ""):
log(f"[reviewers] image {image} already present on {head.host}")
return
if not os.path.isdir(SANDBOX_SRC):
raise RuntimeError(f"reviewer build context missing at {SANDBOX_SRC} (image not baked in?)")
remote_dir = f"{cfg['remoteWorkDir']}/sandbox-build"
log(f"[reviewers] building reviewer image {image} on {head.host} (first run; a few minutes)…")
push = sc.push_dir(head, SANDBOX_SRC, remote_dir)
if push.returncode != 0:
raise RuntimeError(f"rsync reviewer build context to {head.host} failed: {push.stderr}")
b = sc.run(head, f"cd {q(remote_dir)} && IMAGE={q(image)} bash build.sh", timeout=1800)
if b.returncode != 0:
raise RuntimeError(f"reviewer image build failed on {head.host}: {b.stderr or b.stdout}")
log(f"[reviewers] reviewer image built: {image}")
def slug(name: str) -> str:
s = re.sub(r"[^A-Za-z0-9]+", "-", (name or "").strip().lower()).strip("-")
return s or "reviewer"
def roster(cfg: dict) -> list[dict]:
"""Reviewer roster from config. Each: {rid, name, model alias, persona, temperature}."""
out: list[dict] = []
seen: dict[str, int] = {}
for w in (cfg.get("reviewers") or []):
name = (w.get("name") or "reviewer").strip()
rid = slug(name)
if rid in seen:
seen[rid] += 1
rid = f"{rid}-{seen[rid]}"
else:
seen[rid] = 1
out.append({
"rid": rid, "name": name, "model": (w.get("model") or "").strip(),
"persona": (w.get("persona") or "").strip(),
"temperature": w.get("temperature"),
})
return out
def _container(cfg: dict, jobdir: str, rid: str, name: str, model: str, persona: str,
temperature, role: str, extra_mounts: str = "") -> str:
"""docker run command for one reviewer/synthesizer container (detached, one-shot)."""
q = shlex.quote
net = serving.net_name(cfg)
base = serving.reviewer_proxy_base(cfg)
searxng = (cfg.get("searxngUrl") or "").strip()
persona_path = f"{jobdir}/personas/{rid}.md"
env = (
f"-e BM_REVIEWER_ID={q(rid)} -e BM_REVIEWER_NAME={q(name)} -e BM_ROLE={q(role)} "
f"-e BM_MODEL={q(model)} -e BM_LLM_BASE={q(base)} -e BM_LLM_KEY=sk-local "
f"-e BM_MAX_MODEL_LEN={q(str(int(cfg.get('maxModelLen', 32768))))} "
f"-e HOME=/home/rev "
)
if temperature is not None:
env += f"-e BM_TEMPERATURE={q(str(temperature))} "
# web_search is offered ONLY in local_services mode with a SearXNG URL.
if cfg.get("networkMode") == "local_services" and searxng:
env += f"-e BM_SEARXNG_URL={q(searxng)} "
harden = (
"--user 1000:1000 --security-opt no-new-privileges --cap-drop ALL "
"--read-only --tmpfs /tmp:size=256m --tmpfs /home/rev:size=128m "
"--pids-limit 256 --memory 6g --cpus 4"
)
mounts = (
f"-v {q(jobdir)}/docs:/docs:ro "
f"-v {q(jobdir)}/out:/out "
f"-v {q(persona_path)}:/persona/PERSONA.md:ro "
+ extra_mounts
)
cname = f"bm-grader-{rid}"
return (
f"docker rm -f {cname} >/dev/null 2>&1; "
f"docker run -d --name {cname} --network {q(net)} {harden} {env} {mounts} {q(cfg['graderImage'])}"
)
def _write_persona(cfg: dict, jobdir: str, rid: str, persona: str) -> None:
q = shlex.quote
sc.run(sc.head(cfg),
f"mkdir -p {q(jobdir)}/personas && printf '%s' {q(persona)} > {q(jobdir)}/personas/{rid}.md",
timeout=30)
def run_wave_reviewers(cfg: dict, jobdir: str, panel: list[dict], rubric: str, log,
wait_timeout: int = 1800) -> list[dict]:
"""Launch every reviewer in `panel` (already filtered to this wave's models),
wait for them to finish, and report status. Reports land in <jobdir>/out."""
head = sc.head(cfg)
q = shlex.quote
# Rubric is shared; write it once into the job dir, mounted into every container.
sc.run(head, f"mkdir -p {q(jobdir)}/out && printf '%s' {q(rubric)} > {q(jobdir)}/RUBRIC.md", timeout=30)
launched = []
for r in panel:
_write_persona(cfg, jobdir, r["rid"], r["persona"])
cmd = _container(cfg, jobdir, r["rid"], r["name"], r["model"], r["persona"],
r.get("temperature"), role="reviewer",
extra_mounts=f"-v {q(jobdir)}/RUBRIC.md:/RUBRIC.md:ro ")
res = sc.run(head, cmd, timeout=120)
if res.returncode != 0:
log(f"[reviewers] launch {r['rid']} FAILED: {res.stderr or res.stdout}")
launched.append({**r, "ok": False, "error": (res.stderr or res.stdout)[:300]})
continue
log(f"[reviewers] up: {r['rid']} -> {r['model']}")
launched.append({**r, "ok": True, "error": ""})
# Wait for each launched container to exit (they run in parallel; waiting
# sequentially still finishes when the slowest does).
results = []
for r in launched:
if not r["ok"]:
results.append(r)
continue
cname = f"bm-grader-{r['rid']}"
w = sc.run(head, f"docker wait {cname}", timeout=wait_timeout)
code = (w.stdout or "").strip()
out_check = sc.run(head, f"test -s {q(jobdir)}/out/{q(r['rid'])}.md && echo OK || echo MISSING", timeout=30)
wrote = "OK" in (out_check.stdout or "")
log(f"[reviewers] {r['rid']} exited (code={code or '?'}), report={'written' if wrote else 'MISSING'}")
sc.run(head, f"docker rm -f {cname} 2>/dev/null; true", timeout=30)
results.append({**r, "exit": code, "report": wrote})
return results
+379
View File
@@ -0,0 +1,379 @@
"""The Boardroom Map job runner — convenes the review panel over dropped documents.
Runs as a background thread inside the FastAPI app. It does NOT run on a clock
like Nightshift; it reacts to triggers:
* an explicit "Run Review" (drops /data/state/run_request), or
* autoRunOnDrop: files landing in /data/inbox, once the inbox is stable.
One job at a time. A job:
1. extract text from the inbox locally (CPU) — only text crosses to the Sparks
2. rsync the text to a per-job dir on the head Spark
3. serve the needed models in WAVES; run the reviewers for each wave
4. optionally run the local lead-reviewer synthesis
5. pull the reports back to /data/reports/<job>, assemble latest.md
6. wipe the documents from the Sparks (unless disabled) and tear serving down
All state (phase, current job, per-reviewer status, last report) is mirrored to
/data/state/runtime.json so the Web UI can render it.
"""
from __future__ import annotations
import json
import os
import shutil
import threading
import time
import traceback
from collections import deque
from datetime import datetime
import bm_config
import extraction
import preflight
import reviewers as rev_mod
import serving
import spark_client as sc
import synthesis as synth_mod
DATA_DIR = os.environ.get("BM_DATA_DIR", "/data")
INBOX = os.path.join(DATA_DIR, "inbox")
PROCESSED = os.path.join(DATA_DIR, "processed")
STATE_DIR = os.path.join(DATA_DIR, "state")
JOBS_DIR = os.path.join(STATE_DIR, "jobs")
REPORTS_DIR = os.path.join(DATA_DIR, "reports")
RUNTIME_PATH = os.path.join(STATE_DIR, "runtime.json")
REQUEST_PATH = os.path.join(STATE_DIR, "run_request")
TICK_SECONDS = 10
def _inbox_signature() -> tuple[int, str]:
"""(count, signature) of supported files in the inbox, for stability checks."""
if not os.path.isdir(INBOX):
return (0, "")
items = []
for fn in sorted(os.listdir(INBOX)):
p = os.path.join(INBOX, fn)
if os.path.isfile(p) and os.path.splitext(fn)[1].lower() in extraction.SUPPORTED:
items.append(f"{fn}:{os.path.getsize(p)}:{int(os.path.getmtime(p))}")
return (len(items), "|".join(items))
class JobRunner:
def __init__(self):
self._events = deque(maxlen=500)
self._lock = threading.Lock()
self.phase = "idle" # idle | extracting | reviewing | synthesizing | collecting | done | error
self.job_id = None
self.message = ""
self.panel: list[dict] = []
self.waves_total = 0
self.wave_index = 0
self.last_report_path = None
self._thread = None
self._last_sig = None
self._stable_sig = None
self._last_done_sig = None
for d in (STATE_DIR, JOBS_DIR, REPORTS_DIR, INBOX, PROCESSED):
os.makedirs(d, exist_ok=True)
self._restore()
# ------------------------------------------------------------- logging
def log(self, msg: str):
line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}"
with self._lock:
self._events.append(line)
print(line, flush=True)
self._persist()
def events(self) -> list[str]:
with self._lock:
return list(self._events)
# ------------------------------------------------------------- persistence
def _persist(self):
try:
with open(RUNTIME_PATH, "w") as f:
json.dump(self.snapshot() | {"events": list(self._events)[-200:],
"updated": time.time()}, f)
except Exception:
pass
def _restore(self):
try:
with open(RUNTIME_PATH) as f:
d = json.load(f)
self.phase = d.get("phase", "idle")
self.job_id = d.get("job_id")
self.message = d.get("message", "")
self.panel = d.get("panel", [])
self.last_report_path = d.get("last_report_path")
for e in d.get("events", []):
self._events.append(e)
# A job can't survive a restart; reset a stuck running phase.
if self.phase in ("extracting", "reviewing", "synthesizing", "collecting"):
self.phase = "idle"
except Exception:
pass
def snapshot(self) -> dict:
return {
"phase": self.phase,
"job_id": self.job_id,
"message": self.message,
"panel": self.panel,
"waves_total": self.waves_total,
"wave_index": self.wave_index,
"last_report_path": self.last_report_path,
}
# ------------------------------------------------------------- lifecycle
def start(self):
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def request_run(self):
"""Public hook (used by the API) to request a review immediately."""
try:
with open(REQUEST_PATH, "w") as f:
f.write(str(time.time()))
except Exception:
pass
def _run(self):
while True:
try:
self._poll_once()
except Exception as e:
self.phase = "error"
self.message = str(e)[:300]
self.log(f"[runner] ERROR: {e}")
self.log(traceback.format_exc().splitlines()[-1])
time.sleep(TICK_SECONDS)
def _poll_once(self):
cfg = bm_config.load()
triggered = False
if os.path.exists(REQUEST_PATH):
os.remove(REQUEST_PATH)
triggered = True
self.log("[runner] review requested")
elif cfg.get("autoRunOnDrop"):
count, sig = _inbox_signature()
if count and sig == self._last_sig and sig != self._last_done_sig:
# stable across two ticks and not the batch we last processed
triggered = True
self.log("[runner] inbox stable — auto-running review")
self._last_sig = sig
if not triggered:
return
count, _ = _inbox_signature()
if not count:
self.log("[runner] nothing to review (inbox empty of supported files)")
self.phase = "idle"
self._persist()
return
self._run_job(cfg)
# ------------------------------------------------------------- the job
def _run_job(self, cfg: dict):
job_id = datetime.now().strftime("%Y-%m-%d_%H%M%S")
self.job_id = job_id
self.message = ""
self.waves_total = 0
self.wave_index = 0
self.panel = []
local_job = os.path.join(JOBS_DIR, job_id)
local_docs = os.path.join(local_job, "docs")
remote_job = f"{cfg['remoteWorkDir'].rstrip('/')}/jobs/{job_id}"
rubric = cfg.get("reviewInstructions") or bm_config.DEFAULT_RUBRIC
self.log(f"=== Review job {job_id} begins ===")
try:
# 1. Extract locally (only text crosses to the Sparks).
self.phase = "extracting"; self._persist()
manifest = extraction.extract_inbox(INBOX, local_docs, self.log)
ok_docs = [m for m in manifest if m["ok"]]
if not ok_docs:
raise RuntimeError("no documents could be extracted (unsupported or empty inbox)")
self.log(f"[runner] extracted {len(ok_docs)} document(s)")
# 2. Resolve the panel against the model catalog.
catalog = {m["alias"] for m in (cfg.get("models") or [])}
panel = rev_mod.roster(cfg)
valid = [r for r in panel if r["model"] in catalog]
invalid = [r for r in panel if r["model"] not in catalog]
for r in invalid:
self.log(f"[runner] WARNING: reviewer '{r['name']}' uses unknown model '{r['model']}' — skipped")
if not valid:
raise RuntimeError("no reviewers reference a configured model (see Configure Models/Reviewers)")
self.panel = [{"name": r["name"], "model": r["model"], "status": "pending"} for r in valid]
needed = {r["model"] for r in valid}
if cfg.get("synthesisEnabled"):
sm = synth_mod.pick_model(cfg)
if sm:
needed.add(sm)
# Air-gapped mode can't route to second-Spark models (internal net).
if cfg.get("networkMode") == "airgapped":
cat = {m["alias"]: m for m in (cfg.get("models") or [])}
offenders = [a for a in needed if cat.get(a, {}).get("spark") == "secondary"]
if offenders:
raise RuntimeError(
"air-gapped mode requires all models on the head Spark, but these are "
f"on the secondary: {', '.join(sorted(offenders))}. Move them to the "
"primary Spark or switch to local-services mode.")
# 3. Ship text to the Spark + ensure infra.
self.phase = "reviewing"; self._persist()
push = sc.push_dir(sc.head(cfg), local_docs, f"{remote_job}/docs")
if push.returncode != 0:
raise RuntimeError(f"shipping documents to the Spark failed: {push.stderr}")
rev_mod.ensure_reviewer_image(cfg, self.log)
serving.ensure_network(cfg, self.log)
# In local_services mode, warn early if the optional web_search backend
# (SearXNG, self-signed HTTPS) is unreachable — non-fatal.
preflight.check_searxng(cfg, self.log)
# 4. Run the panel in waves (synthesis is handled separately, below).
review_aliases = {r["model"] for r in valid}
waves = serving.plan_waves(cfg, review_aliases)
self.waves_total = len(waves)
hf = bm_config.hf_token()
collected = []
for i, wave in enumerate(waves, 1):
self.wave_index = i
wave_aliases = {m["alias"] for m in wave}
wpanel = [r for r in valid if r["model"] in wave_aliases]
self.log(f"[runner] wave {i}/{len(waves)}: models={sorted(wave_aliases)} "
f"reviewers={[r['name'] for r in wpanel]}")
serving.bring_up_wave(cfg, wave, hf, self.log)
self._await_serving(cfg, wave)
preflight.check_wave(cfg, wave, self.log)
res = rev_mod.run_wave_reviewers(cfg, remote_job, wpanel, rubric, self.log)
collected.extend(res)
self._mark_panel(res)
serving.tear_down_wave(cfg, wave, self.log)
# 5. Synthesis (its own single-model wave).
synth_ok = False
if cfg.get("synthesisEnabled"):
self.phase = "synthesizing"; self._persist()
sm = synth_mod.pick_model(cfg)
swave = serving.plan_waves(cfg, {sm})
for wave in swave:
serving.bring_up_wave(cfg, wave, hf, self.log)
self._await_serving(cfg, wave)
preflight.check_wave(cfg, wave, self.log)
sres = synth_mod.run_synthesis(cfg, remote_job, rubric, self.log)
synth_ok = bool(sres.get("report"))
serving.tear_down_wave(cfg, wave, self.log)
# 6. Collect reports + assemble.
self.phase = "collecting"; self._persist()
self._collect(cfg, job_id, remote_job, local_job, valid, manifest, synth_ok)
# 7. Confidentiality: wipe the documents from the Spark.
if cfg.get("wipeRemoteDocs", True):
sc.run(sc.head(cfg), f"rm -rf {remote_job}", timeout=60)
self.log("[runner] wiped document text from the Spark")
serving.tear_down_all(cfg, self.log)
# 8. Clear the inbox (move originals aside so they aren't re-reviewed).
self._drain_inbox(job_id)
self._last_done_sig = _inbox_signature()[1]
self.phase = "done"
self.message = f"Reviewed {len(ok_docs)} document(s) with {len(valid)} reviewer(s)."
self.log(f"=== Review job {job_id} complete ===")
self._persist()
except Exception as e:
self.phase = "error"
self.message = f"Review failed: {e}"
self.log(f"[runner] JOB FAILED — {e}")
self.log(traceback.format_exc().splitlines()[-1])
try:
serving.tear_down_all(cfg, self.log)
except Exception:
pass
self._persist()
# ------------------------------------------------------------- helpers
def _await_serving(self, cfg, wave, timeout=900):
self.log("[runner] waiting for wave serving to come online…")
deadline = time.time() + timeout
want = len(wave) + 1 # vLLMs + proxy
while time.time() < deadline:
running = serving.health(cfg).get("running", [])
if sum(1 for r in running if "Up" in r) >= want:
self.log("[runner] wave serving online")
return
time.sleep(15)
self.log("[runner] WARNING: wave serving not fully confirmed; continuing")
def _mark_panel(self, res: list[dict]):
by_name = {r["name"]: r for r in res}
for p in self.panel:
r = by_name.get(p["name"])
if not r:
continue
if not r.get("ok", True):
p["status"] = "launch-failed"
elif r.get("report"):
p["status"] = "done"
else:
p["status"] = "no-report"
self._persist()
def _collect(self, cfg, job_id, remote_job, local_job, valid, manifest, synth_ok):
out_local = os.path.join(REPORTS_DIR, job_id)
os.makedirs(out_local, exist_ok=True)
sc.pull_dir(sc.head(cfg), f"{remote_job}/out", os.path.join(out_local, "reviewers"))
if synth_ok:
sc.pull_dir(sc.head(cfg), f"{remote_job}/synth-out", os.path.join(out_local, "synthesis"))
# Assemble a single latest.md: the consolidated report if present, else a
# concatenation of the individual reports.
parts = [f"# Boardroom Map review — {job_id}\n",
"Documents reviewed: " + ", ".join(m["source"] for m in manifest if m["ok"]) + "\n",
"Panel: " + ", ".join(f"{r['name']} ({r['model']})" for r in valid) + "\n"]
consolidated = os.path.join(out_local, "synthesis", "CONSOLIDATED_REPORT.md")
if synth_ok and os.path.exists(consolidated):
parts.append("\n---\n\n## Consolidated report (lead reviewer)\n\n")
parts.append(open(consolidated, errors="replace").read())
parts.append("\n\n---\n")
parts.append("\n## Individual reviewer reports\n")
rev_local = os.path.join(out_local, "reviewers")
if os.path.isdir(rev_local):
for fn in sorted(os.listdir(rev_local)):
if fn.endswith(".md"):
parts.append(f"\n### {fn[:-3]}\n\n")
parts.append(open(os.path.join(rev_local, fn), errors="replace").read())
parts.append("\n")
assembled = "".join(parts)
with open(os.path.join(out_local, "report.md"), "w") as f:
f.write(assembled)
with open(os.path.join(REPORTS_DIR, "latest.md"), "w") as f:
f.write(assembled)
self.last_report_path = os.path.join(out_local, "report.md")
self.log(f"[runner] reports saved to {out_local}")
def _drain_inbox(self, job_id):
dest = os.path.join(PROCESSED, job_id)
os.makedirs(dest, exist_ok=True)
for fn in os.listdir(INBOX):
src = os.path.join(INBOX, fn)
if os.path.isfile(src):
try:
shutil.move(src, os.path.join(dest, fn))
except Exception:
pass
self.log(f"[runner] inbox cleared (originals moved to processed/{job_id})")
# Module-level singleton used by app.py
runner = JobRunner()
+92
View File
@@ -0,0 +1,92 @@
"""Preflight checks — fail LOUD before launching reviewers, so a dead model
endpoint is caught immediately instead of after a container spins fruitlessly.
The model proxy has no published host port (it lives on the per-job Docker
network so that air-gapped reviewers can reach it without the host exposing
anything). So we probe it the same way a reviewer would: from a throwaway
container attached to the same network.
"""
from __future__ import annotations
import shlex
import spark_client as sc
import serving
def _probe_in_net(cfg: dict, inner_cmd: str, timeout: int) -> sc.subprocess.CompletedProcess:
"""Run a shell command inside a throwaway container on the per-job network,
using the reviewer image (it has curl)."""
q = shlex.quote
net = serving.net_name(cfg)
image = cfg["graderImage"]
cmd = (
f"docker run --rm --network {q(net)} --entrypoint sh {q(image)} "
f"-c {q(inner_cmd)}"
)
return sc.run(sc.head(cfg), cmd, timeout=timeout)
def check_wave(cfg: dict, wave: list[dict], log) -> None:
"""The proxy answers AND each model alias in the wave returns a completion."""
base = serving.reviewer_proxy_base(cfg).rstrip("/") # http://boardroom-proxy:PORT/v1
# 1. Proxy reachable at all.
reach = f"curl -sf -m 8 {shlex.quote(base + '/models')} -o /dev/null && echo OK || echo FAIL"
r = _probe_in_net(cfg, reach, timeout=40)
if "OK" not in (r.stdout or ""):
raise RuntimeError(
f"model proxy not answering at {base} from inside the network. "
f"Check that the router container ({serving.PROXY_NAME}) came up.")
log("[preflight] model proxy answering")
# 2. Each alias must actually return a completion.
dead = []
for m in wave:
alias = m["alias"]
payload = '{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}' % alias
probe = (
f"curl -sf -m 60 -X POST {shlex.quote(base + '/chat/completions')} "
f"-H 'content-type: application/json' -H 'authorization: Bearer sk-local' "
f"-d {shlex.quote(payload)} 2>/dev/null | head -c 600"
)
rr = _probe_in_net(cfg, probe, timeout=90)
out = rr.stdout or ""
if '"choices"' not in out and '"content"' not in out:
dead.append(alias)
else:
log(f"[preflight] model {alias} responded")
if dead:
raise RuntimeError(
"These models are not responding through the proxy: " + ", ".join(dead) +
". Check the corresponding vLLM container(s) on the Spark — they may have "
"failed to load, OOM'd, or (in air-gapped mode) the model isn't in the HF cache.")
def check_searxng(cfg: dict, log) -> None:
"""In local_services mode, verify the reviewers' optional web_search backend.
StartOS exposes SearXNG only over HTTPS with a SELF-SIGNED cert (the lesson
from Nightshift's first deploy), so the probe uses `curl -sfk`, and it asserts
the JSON format is enabled (HTML back == json format off). This is NON-fatal:
web_search is an optional enhancement in Boardroom Map, so a broken SearXNG only
warns rather than failing the whole review (unlike Nightshift, where research
depended on it)."""
if cfg.get("networkMode") != "local_services":
return
sx = (cfg.get("searxngUrl") or "").strip().rstrip("/")
if not sx:
return
head = sc.head(cfg)
probe = f"curl -sfk -m 12 {shlex.quote(sx + '/search?q=boardroom&format=json')} 2>/dev/null | head -c 400"
r = sc.run(head, probe, timeout=25)
out = (r.stdout or "").strip()
if not out:
log(f"[preflight] WARNING: SearXNG not reachable from the head Spark at {sx}"
"reviewers' web_search will be unavailable (reviews still run).")
return
if not out.lstrip().startswith("{") and '"results"' not in out:
log(f"[preflight] WARNING: SearXNG at {sx} did not return JSON (enable the json "
"format in settings.yml) — web_search may not work.")
return
log("[preflight] SearXNG reachable with JSON enabled")
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
jinja2==3.1.5
python-multipart==0.0.20
pyyaml==6.0.2
pypdf==5.1.0
python-docx==1.1.2
+211
View File
@@ -0,0 +1,211 @@
"""Bring up model serving on the Sparks over SSH, in WAVES.
Boardroom Map serves a user-defined catalog of local models. Two Sparks can't hold
unlimited distinct models, so the job runner loads them in waves: each wave brings
up the vLLM container(s) that fit (<= maxConcurrentModels on the head Spark),
points a LiteLLM router at them, lets the reviewers assigned to those models run,
then tears the wave down and loads the next.
Network topology (the confidentiality boundary):
* A per-job user-defined Docker network on the head Spark (default
"boardroom-net"). In `airgapped` mode it is created with --internal, so the
reviewer containers attached to it can reach the model proxy but have ZERO
internet egress. In `local_services` mode it is a normal bridge (egress
possible) so reviewers can also reach LAN services / the second Spark.
* Head-Spark vLLMs join this network; the proxy reaches them by container name
(bm-vllm-<alias>). Reviewers reach the proxy by name (boardroom-proxy).
* Second-Spark vLLMs publish a host port; the proxy reaches them over the LAN.
This only works in local_services mode (an --internal network can't route to
the LAN), so airgapped jobs must keep all models on the head Spark — enforced
in preflight.
Models are served from a pre-populated HF cache mounted from the Spark work dir,
so airgapped serving needs no live download.
"""
from __future__ import annotations
import json
import shlex
import spark_client as sc
PROXY_NAME = "boardroom-proxy"
def net_name(cfg: dict) -> str:
return cfg.get("networkName") or "boardroom-net"
def _vllm_name(alias: str) -> str:
return f"bm-vllm-{alias}"
def _hf_cache(cfg: dict) -> str:
return f"{cfg['remoteWorkDir'].rstrip('/')}/hf-cache"
# ------------------------------------------------------------------ network
def ensure_network(cfg: dict, log) -> None:
head = sc.head(cfg)
name = net_name(cfg)
internal = "--internal " if cfg.get("networkMode") == "airgapped" else ""
cmd = (
f"docker network inspect {shlex.quote(name)} >/dev/null 2>&1 && echo EXISTS || "
f"docker network create {internal}{shlex.quote(name)}"
)
r = sc.run(head, cmd, timeout=60)
if r.returncode != 0:
raise RuntimeError(f"could not create docker network {name}: {r.stderr or r.stdout}")
mode = "airgapped (--internal)" if internal else "local_services"
log(f"[serving] network {name} ready ({mode})")
def remove_network(cfg: dict, log) -> None:
head = sc.head(cfg)
sc.run(head, f"docker network rm {shlex.quote(net_name(cfg))} 2>/dev/null; true", timeout=30)
# ------------------------------------------------------------------ vLLM
def _vllm_run(cfg: dict, model: dict, hf_token: str | None) -> tuple[sc.Spark, str]:
"""Build the docker run command for one model on its assigned Spark."""
alias, hf, port = model["alias"], model["hfModel"], int(model["port"])
image = cfg["servingImage"]
gpu_util = cfg["gpuMemoryUtilization"]
max_len = int(cfg["maxModelLen"])
parser = cfg.get("toolCallParser", "hermes")
tools = (f"--enable-auto-tool-choice --tool-call-parser {shlex.quote(parser)} " if parser else "")
env = f"-e HF_TOKEN={shlex.quote(hf_token)} " if hf_token else ""
cache = _hf_cache(cfg)
name = _vllm_name(alias)
if model.get("spark") == "secondary":
target = sc.by_role(cfg, "secondary")
# 2nd Spark: publish the port so the head's proxy can reach it over the LAN.
net = f"-p {port}:{port}"
else:
target = sc.head(cfg)
# Head Spark: attach to the per-job network; proxy reaches it by name.
net = f"--network {shlex.quote(net_name(cfg))}"
cmd = (
f"mkdir -p {shlex.quote(cache)}; "
f"docker rm -f {name} >/dev/null 2>&1; "
f"docker run -d --name {name} --gpus all --ipc=host --shm-size=16g "
f"--restart unless-stopped {net} {env}"
f"-v {shlex.quote(cache)}:/root/.cache/huggingface "
f"{shlex.quote(image)} "
f"vllm serve {shlex.quote(hf)} --host 0.0.0.0 --port {port} "
f"--gpu-memory-utilization {gpu_util} --max-model-len {max_len} {tools}"
f"--served-model-name {shlex.quote(alias)}"
)
return target, cmd
def _litellm_config(cfg: dict, wave: list[dict]) -> dict:
"""Router config exposing each model alias in the wave on one endpoint."""
model_list = []
for m in wave:
alias, port = m["alias"], int(m["port"])
if m.get("spark") == "secondary":
api_base = f"http://{cfg['secondarySparkHost']}:{port}/v1"
else:
api_base = f"http://{_vllm_name(alias)}:{port}/v1"
model_list.append({
"model_name": alias,
"litellm_params": {
"model": f"openai/{alias}",
"api_base": api_base,
"api_key": "sk-local",
},
})
return {"model_list": model_list, "general_settings": {"master_key": "sk-local"}}
def bring_up_wave(cfg: dict, wave: list[dict], hf_token: str | None, log) -> None:
"""Start the vLLMs for `wave` and a router that exposes them. Idempotent."""
head = sc.head(cfg)
for m in wave:
target, cmd = _vllm_run(cfg, m, hf_token)
log(f"[serving] launching {m['alias']} ({m['hfModel']}) on {target.host}:{m['port']}")
r = sc.run(target, cmd, timeout=240)
if r.returncode != 0:
raise RuntimeError(f"vLLM launch for {m['alias']} failed: {r.stderr or r.stdout}")
# LiteLLM router on the head Spark, attached to the per-job network so the
# reviewers reach it by name (boardroom-proxy). No published port — preflight
# probes it from inside the network.
cfg_json = json.dumps(_litellm_config(cfg, wave))
workdir = cfg["remoteWorkDir"]
remote_cfg = f"{workdir}/litellm.config.json"
proxy_port = int(cfg["proxyPort"])
log(f"[serving] launching router {PROXY_NAME} on network {net_name(cfg)}:{proxy_port}")
setup = (
f"mkdir -p {shlex.quote(workdir)} && "
f"printf '%s' {shlex.quote(cfg_json)} > {shlex.quote(remote_cfg)} && "
f"docker rm -f {PROXY_NAME} >/dev/null 2>&1; "
f"docker run -d --name {PROXY_NAME} --restart unless-stopped "
f"--network {shlex.quote(net_name(cfg))} "
f"-v {shlex.quote(remote_cfg)}:/app/config.json "
f"ghcr.io/berriai/litellm:main-stable "
f"--config /app/config.json --port {proxy_port} --host 0.0.0.0"
)
r = sc.run(head, setup, timeout=180)
if r.returncode != 0:
raise RuntimeError(f"LiteLLM router launch failed: {r.stderr or r.stdout}")
def tear_down_wave(cfg: dict, wave: list[dict], log) -> None:
names = " ".join(_vllm_name(m["alias"]) for m in wave)
# vLLMs may be split across both Sparks; clear the names on each.
for sp in sc.sparks(cfg):
sc.run(sp, f"docker rm -f {names} 2>/dev/null; true", timeout=120)
sc.run(sc.head(cfg), f"docker rm -f {PROXY_NAME} 2>/dev/null; true", timeout=60)
log(f"[serving] wave torn down ({names})")
def tear_down_all(cfg: dict, log) -> None:
"""Best-effort: remove every Boardroom Map serving container + the network."""
for sp in sc.sparks(cfg):
sc.run(sp, "docker ps -aq --filter name=bm-vllm- | xargs -r docker rm -f; "
f"docker rm -f {PROXY_NAME} 2>/dev/null; true", timeout=120)
remove_network(cfg, log)
log("[serving] all serving torn down")
def plan_waves(cfg: dict, needed_aliases: set[str]) -> list[list[dict]]:
"""Group the needed models into waves that respect per-Spark concurrency.
Head-Spark models are chunked into groups of `maxConcurrentModels`; secondary
models likewise. Wave i runs head-group-i and secondary-group-i together (they
sit on different GPUs)."""
catalog = {m["alias"]: m for m in (cfg.get("models") or [])}
cap = max(1, int(cfg.get("maxConcurrentModels", 1)))
primary, secondary = [], []
for alias in sorted(needed_aliases):
m = catalog.get(alias)
if not m:
continue
(secondary if m.get("spark") == "secondary" else primary).append(m)
def chunk(lst):
return [lst[i:i + cap] for i in range(0, len(lst), cap)]
pg, sg = chunk(primary), chunk(secondary)
waves = []
for i in range(max(len(pg), len(sg))):
wave = (pg[i] if i < len(pg) else []) + (sg[i] if i < len(sg) else [])
if wave:
waves.append(wave)
return waves
def reviewer_proxy_base(cfg: dict) -> str:
"""The OpenAI-compatible base URL reviewers use (by container name on the net)."""
return f"http://{PROXY_NAME}:{int(cfg['proxyPort'])}/v1"
def health(cfg: dict) -> dict:
head = sc.head(cfg)
r = sc.run(head, "docker ps --filter name=bm-vllm- --filter name=boardroom-proxy "
"--format '{{.Names}} {{.Status}}'", timeout=30)
return {"running": (r.stdout or "").strip().splitlines()}
+163
View File
@@ -0,0 +1,163 @@
"""SSH/rsync helpers for driving the Sparks from the Boardroom Map control plane.
Shells out to the system `ssh`/`rsync` (installed in the image) rather than a
Python SSH lib, so we get `docker logs -f` streaming for free and the exact same
behavior a human would get from a shell. Also usable as a CLI:
python spark_client.py test # probe GPU + images on the configured Spark(s)
This mirrors the LLaMA-Factory / Nightshift services' spark_client.py so the SSH
logic lives in one place and behaves identically across services.
"""
from __future__ import annotations
import json
import os
import shlex
import subprocess
import sys
from dataclasses import dataclass
DATA_DIR = os.environ.get("BM_DATA_DIR", "/data")
CONFIG_PATH = os.path.join(DATA_DIR, "config.json")
KEY_PATH = os.path.join(DATA_DIR, "ssh", "id_spark")
def load_config() -> dict:
with open(CONFIG_PATH) as f:
return json.load(f)
def _ensure_key_perms() -> str:
"""SSH refuses world-readable keys. Copy to a private 600 path at runtime."""
safe = "/tmp/id_spark"
if not os.path.exists(KEY_PATH):
raise FileNotFoundError(
f"SSH key not found at {KEY_PATH}. Run the 'Configure Sparks' action first."
)
with open(KEY_PATH, "rb") as src, open(safe, "wb") as dst:
dst.write(src.read())
os.chmod(safe, 0o600)
return safe
@dataclass
class Spark:
host: str
user: str
port: int
role: str = "primary" # "primary" (head) or "secondary"
def ssh_base(self) -> list[str]:
key = _ensure_key_perms()
return [
"ssh", "-i", key, "-p", str(self.port),
"-o", "StrictHostKeyChecking=accept-new",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=15",
f"{self.user}@{self.host}",
]
def sparks(cfg: dict | None = None) -> list[Spark]:
cfg = cfg or load_config()
user = cfg.get("primarySparkUser", "nvidia")
port = int(cfg.get("sshPort", 22))
out = [Spark(cfg["primarySparkHost"], user, port, role="primary")]
if cfg.get("useBothSparks") and cfg.get("secondarySparkHost"):
out.append(Spark(cfg["secondarySparkHost"], user, port, role="secondary"))
return out
def head(cfg: dict | None = None) -> Spark:
"""The head Spark: hosts the model proxy, the network, and the reviewer panel."""
return sparks(cfg)[0]
def by_role(cfg: dict, role: str) -> Spark:
"""Return the Spark serving a given role ('primary'|'secondary'); falls back
to the head if the secondary isn't configured."""
for sp in sparks(cfg):
if sp.role == role:
return sp
return head(cfg)
def run(spark: Spark, remote_cmd: str, timeout: int | None = None) -> subprocess.CompletedProcess:
"""Run a shell command on the Spark, capturing output."""
return subprocess.run(
spark.ssh_base() + [remote_cmd],
capture_output=True, text=True, timeout=timeout,
)
def stream(spark: Spark, remote_cmd: str):
"""Yield stdout lines from a long-running remote command (e.g. docker logs -f)."""
proc = subprocess.Popen(
spark.ssh_base() + [remote_cmd],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
)
try:
assert proc.stdout is not None
for line in proc.stdout:
yield line
finally:
proc.terminate()
def push_dir(spark: Spark, local_dir: str, remote_dir: str) -> subprocess.CompletedProcess:
"""rsync a local dir up to the Spark."""
key = _ensure_key_perms()
ssh = f"ssh -i {key} -p {spark.port} -o StrictHostKeyChecking=accept-new -o BatchMode=yes"
run(spark, f"mkdir -p {shlex.quote(remote_dir)}")
return subprocess.run(
["rsync", "-az", "-e", ssh,
local_dir.rstrip("/") + "/", f"{spark.user}@{spark.host}:{remote_dir.rstrip('/')}/"],
capture_output=True, text=True,
)
def pull_dir(spark: Spark, remote_dir: str, local_dir: str) -> subprocess.CompletedProcess:
"""rsync a remote dir back down to the StartOS volume."""
key = _ensure_key_perms()
ssh = f"ssh -i {key} -p {spark.port} -o StrictHostKeyChecking=accept-new -o BatchMode=yes"
os.makedirs(local_dir, exist_ok=True)
return subprocess.run(
["rsync", "-az", "-e", ssh,
f"{spark.user}@{spark.host}:{remote_dir.rstrip('/')}/", local_dir.rstrip("/") + "/"],
capture_output=True, text=True,
)
def test_cli() -> int:
cfg = load_config()
if not cfg.get("primarySparkHost"):
print("No Spark configured. Run the 'Configure Sparks' action first.")
return 1
serving = cfg.get("servingImage", "boardroom-vllm:latest")
reviewer = cfg.get("graderImage", "boardroom-grader:latest")
rc_all = 0
for sp in sparks(cfg):
print(f"== {sp.user}@{sp.host}:{sp.port} ({sp.role}) ==")
probe = (
"nvidia-smi -L && echo '---' && "
f"(docker image inspect {shlex.quote(serving)} >/dev/null 2>&1 "
f"&& echo 'vLLM image present: {serving}' || echo 'vLLM image MISSING') && "
f"(docker image inspect {shlex.quote(reviewer)} >/dev/null 2>&1 "
f"&& echo 'reviewer image present: {reviewer}' || echo 'reviewer image MISSING (build sandbox/ on the head Spark)')"
)
r = run(sp, probe, timeout=40)
print(r.stdout.strip() or "(no output)")
if r.returncode != 0:
rc_all = r.returncode
print(f"[error rc={r.returncode}] {r.stderr.strip()}")
print()
return rc_all
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "test"
if cmd == "test":
sys.exit(test_cli())
print(f"unknown command: {cmd}")
sys.exit(2)
+155
View File
@@ -0,0 +1,155 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Boardroom Map</title>
<style>
:root { --bg:#10131a; --panel:#181d27; --edge:#2a313f; --ink:#e9edf5; --dim:#97a1b5;
--accent:#c9a24b; --ok:#5fd08a; --warn:#ffcf66; --bad:#ff7a7a; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--ink); }
header { display:flex; align-items:center; gap:12px; padding:18px 22px; border-bottom:1px solid var(--edge); }
header .seal { width:26px;height:26px;border-radius:50%;border:2px solid var(--accent);
display:flex;align-items:center;justify-content:center;color:var(--accent);font-weight:700;font-size:13px; }
h1 { font-size:18px; margin:0; letter-spacing:2px; }
.wrap { max-width:1100px; margin:0 auto; padding:22px; display:grid; gap:18px; grid-template-columns:1fr 1fr; }
.card { background:var(--panel); border:1px solid var(--edge); border-radius:14px; padding:16px 18px; }
.card.full { grid-column:1 / -1; }
.card h2 { margin:0 0 10px; font-size:13px; text-transform:uppercase; letter-spacing:1px; color:var(--dim); }
.pill { display:inline-block; padding:2px 10px; border-radius:999px; font-size:12px; font-weight:600; }
.pill.idle,.pill.done{background:#26303f;color:var(--dim)}
.pill.reviewing,.pill.synthesizing,.pill.collecting,.pill.extracting{background:#3b3414;color:var(--warn)}
.pill.error{background:#3b1414;color:var(--bad)}
.kv { display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--edge); }
.kv:last-child{border:0} .kv .k{color:var(--dim)}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.dot.on{background:var(--ok)} .dot.off{background:#55608f}
pre { background:#0c0f16; border:1px solid var(--edge); border-radius:10px; padding:12px; margin:0;
max-height:340px; overflow:auto; font:12px/1.5 ui-monospace,Menlo,monospace; color:#cdd6ff; white-space:pre-wrap; }
.row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{background:#222a3a;color:var(--ink);border:1px solid var(--edge);border-radius:9px;
padding:8px 12px;font-size:13px;cursor:pointer} button:hover{background:#2c3650}
button.primary{background:#3a2f12;border-color:#6b551f;color:#ffdf9a}
.drop{border:1.5px dashed var(--edge);border-radius:12px;padding:18px;text-align:center;color:var(--dim);cursor:pointer}
.drop.hot{border-color:var(--accent);color:var(--ink)}
.badge{font-size:11px;color:var(--dim)}
.muted{color:var(--dim);font-size:12px}
a{color:#9ab8ff}
</style>
</head>
<body>
<header>
<div class="seal">§</div>
<h1>BOARDROOM MAP</h1>
<span id="phase" class="pill idle">idle</span>
<span class="muted" id="netmode"></span>
</header>
<div class="wrap">
<div class="card">
<h2>Documents</h2>
<div id="drop" class="drop">Drop confidential documents here, or click to choose<br>
<span class="badge">PDF · DOCX · TXT · MD</span></div>
<input id="file" type="file" multiple style="display:none"/>
<div id="inbox" style="margin-top:10px"></div>
<div class="row">
<button class="primary" onclick="runReview()">Run Review</button>
<button onclick="act('/api/inbox/clear','POST')">Clear Inbox</button>
<button onclick="refresh()">Refresh</button>
</div>
</div>
<div class="card">
<h2>Setup</h2>
<div id="ready"></div>
</div>
<div class="card">
<h2>Panel</h2>
<div id="panel"><div class="muted">No reviewers configured.</div></div>
</div>
<div class="card">
<h2>Serving / Job</h2>
<div id="job"><div class="muted"></div></div>
<div class="row">
<button onclick="act('/api/reviewer/build-image','POST')">Build reviewer image</button>
<button onclick="act('/api/stop','POST')">Stop serving</button>
</div>
</div>
<div class="card full">
<h2>Activity log</h2>
<pre id="log">loading…</pre>
</div>
<div class="card full">
<h2>Latest report</h2>
<pre id="report"></pre>
</div>
</div>
<script>
async function getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error(await r.text()); return r.json(); }
async function act(u,m){ try{ const r=await fetch(u,{method:m}); const t=await r.text();
alert(r.ok? 'OK: '+t.slice(0,400) : 'Error: '+t.slice(0,400)); refresh(); }catch(e){ alert(e); } }
function dot(on,label){ return `<div><span class="dot ${on?'on':'off'}"></span>${label}</div>`; }
async function runReview(){ act('/api/run','POST'); }
const drop=document.getElementById('drop'), file=document.getElementById('file');
drop.onclick=()=>file.click();
file.onchange=()=>upload(file.files);
['dragover','dragenter'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add('hot');}));
['dragleave','drop'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove('hot');}));
drop.addEventListener('drop',ev=>{ if(ev.dataTransfer.files.length) upload(ev.dataTransfer.files); });
async function upload(files){
const fd=new FormData(); for(const f of files) fd.append('files',f);
try{ const r=await fetch('/api/upload',{method:'POST',body:fd});
if(!r.ok) alert('Upload failed: '+(await r.text()).slice(0,300)); refresh(); }catch(e){ alert(e); }
}
async function refresh(){
try{
const s = await getJSON('/api/status');
const rt = s.runtime||{};
const ph = document.getElementById('phase');
ph.textContent = rt.phase||'idle'; ph.className = 'pill '+(rt.phase||'idle');
document.getElementById('netmode').textContent =
(s.networkMode==='airgapped'?'air-gapped':'local-services')
+ (s.synthesis?' · synthesis on':'') + (s.autoRunOnDrop?' · auto-run':'');
document.getElementById('inbox').innerHTML = (s.inbox||[]).length
? (s.inbox||[]).map(f=>`<div class="kv"><span>${f.name} ${f.supported?'':'<span class="badge">unsupported</span>'}</span><span class="muted">${(f.bytes/1024).toFixed(0)} KB</span></div>`).join('')
: '<div class="muted">Inbox empty.</div>';
document.getElementById('ready').innerHTML =
dot(s.configured.sparks,'Sparks configured') +
dot(s.configured.models>0,`${s.configured.models} model(s)`) +
dot(s.configured.reviewers>0,`${s.configured.reviewers} reviewer(s)`) +
`<div class="kv"><span class="k">network mode</span><span>${s.networkMode}</span></div>` +
`<div class="kv"><span class="k">wipe docs after</span><span>${s.wipeRemoteDocs?'yes':'no'}</span></div>` +
(s.models||[]).map(m=>`<div class="kv"><span>${m.alias}</span><span class="muted">${m.hfModel} · ${m.spark}</span></div>`).join('');
document.getElementById('panel').innerHTML = (s.panel||[]).length
? (s.panel||[]).map(r=>`<div class="kv"><span>${r.name} ${r.known?'':'<span class="badge">unknown model</span>'}</span><span class="muted">${r.model}${r.persona?' · persona ✓':''}</span></div>`).join('')
: '<div class="muted">No reviewers configured.</div>';
const panelRt=(rt.panel||[]);
document.getElementById('job').innerHTML =
`<div class="kv"><span class="k">job</span><span>${rt.job_id||'—'}</span></div>` +
(rt.waves_total?`<div class="kv"><span class="k">wave</span><span>${rt.wave_index}/${rt.waves_total}</span></div>`:'') +
(rt.message?`<div class="muted" style="margin-top:6px">${rt.message}</div>`:'') +
panelRt.map(p=>`<div class="kv"><span>${p.name}</span><span class="muted">${p.status||''}</span></div>`).join('');
const ev = await getJSON('/api/events');
document.getElementById('log').textContent = (ev.events||[]).slice(-200).join('\n') || '(no activity yet)';
try{ const rep = await (await fetch('/api/report')).text();
document.getElementById('report').textContent = rep; }catch(e){}
}catch(e){ document.getElementById('log').textContent = 'status error: '+e; }
}
refresh(); setInterval(refresh, 6000);
</script>
</body>
</html>