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
+8
View File
@@ -0,0 +1,8 @@
node_modules
javascript
.git
*.s9pk
startos
.github
**/__pycache__
*.pyc
+64
View File
@@ -0,0 +1,64 @@
name: Build
on:
workflow_dispatch:
push:
branches: ['main']
paths-ignore: ['*.md']
pull_request:
paths-ignore: ['*.md']
branches: ['main']
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Free up disk space
uses: start9labs/shared-workflows/.github/actions/free-disk-space@master
if: false # enable if image gets large
- name: Setup build environment
uses: start9labs/shared-workflows/.github/actions/setup-build-env@master
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Find or create developer signing key
run: |
if [ -n "${{ secrets.DEV_KEY }}" ]; then
mkdir -p ~/.startos
printf '%s' "${{ secrets.DEV_KEY }}" > ~/.startos/developer.key.pem
else
start-cli init-key
fi
shell: bash
- name: Build JS bundle
run: |
npm ci
npm run check
npm run build
mkdir -p assets
shell: bash
- name: Build the service packages
run: |
RUST_LOG=debug RUST_BACKTRACE=1 make
echo ""
echo "SHA256SUMs:"
sha256sum *.s9pk
shell: bash
- name: Upload each s9pk as its own artifact
uses: start9labs/shared-workflows/.github/actions/upload-each@master
with:
pattern: "*.s9pk"
retention-days: 14
compression-level: 0
+19
View File
@@ -0,0 +1,19 @@
name: Release
on:
push:
tags:
- 'v*.*'
jobs:
release:
uses: start9labs/shared-workflows/.github/workflows/release.yml@master
with:
RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }}
S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }}
secrets:
DEV_KEY: ${{ secrets.DEV_KEY }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
permissions:
contents: write
+6
View File
@@ -0,0 +1,6 @@
node_modules/
javascript/
*.s9pk
.DS_Store
**/__pycache__/
*.pyc
+13
View File
@@ -0,0 +1,13 @@
Copyright 2026 Jonathan Kirkwood / ten31
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+6
View File
@@ -0,0 +1,6 @@
# Build x86_64 and aarch64 s9pks (no riscv). The orchestrator/overseer is an
# arch-agnostic control plane that only SSHes to the Sparks; nothing in this
# package runs ON the Sparks (the worker/serving images are built there).
ARCHES := x86 arm
# overrides to s9pk.mk must precede the include statement
include s9pk.mk
+78
View File
@@ -0,0 +1,78 @@
# Boardroom Map — a private document-review panel for your DGX Sparks
Boardroom Map is a StartOS service (`.s9pk`) that lets you **drop confidential
documents in and have a panel of local LLMs review them** on your NVIDIA DGX
Sparks. You pick the models and the personas (lenses), and how many reviews to
run; each reviewer writes a report, and an optional **local lead reviewer**
synthesizes them into one consolidated report. There is **no frontier model and
no cloud API key** — in the default air-gapped mode the documents and reviews
never leave your hardware.
It is a sibling of [Nightshift](../nightshift) and reuses the same control-plane
pattern (a GPU-free orchestrator on StartOS driving the Sparks over SSH), but
with the swarm, the git blackboard, and the Claude overseer removed and replaced
by an on-demand **document-review pipeline**.
## Architecture
```
StartOS box (control plane, no GPU) DGX Spark(s)
┌────────────────────────────────────┐ ┌───────────────────────────────┐
│ FastAPI web UI + job runner │ SSH │ per-job Docker network │
│ • inbox (drop documents) │ ───────▶│ (──internal in airgapped) │
│ • extract text (PDF/DOCX/TXT/MD) │ rsync │ ┌─────────┐ ┌────────────┐ │
│ • plan model "waves" │ ───────▶│ │ vLLM(s) │◀─│ LiteLLM │ │
│ • launch reviewer containers │ │ └─────────┘ │ router │ │
│ • pull reports, synthesize, wipe │◀─────── │ ┌──────────────┐ ▲ │ │
│ • reports saved here only │ rsync │ │ reviewer ×N │──┘ │ │
└────────────────────────────────────┘ │ │ (read-only, │ │ │
│ │ sandboxed) │ │ │
│ └──────────────┘ │ │
└───────────────────────────────┘
```
- **Reviewers** are one-shot, read-only, hardened containers (non-root,
`--cap-drop ALL`, read-only rootfs, no docker socket). In air-gapped mode they
sit on an `--internal` network and can reach only the model proxy.
- **Waves:** the job runner serves models in waves bounded by
`maxConcurrentModels`, so a panel can span more models than fit in GPU memory
at once.
- **Confidentiality:** documents are extracted to text on the StartOS box; only
text crosses to the Sparks, and it is wiped from the Sparks after the job.
## Repo layout
```
startos/ StartOS package definition (TypeScript / start-sdk)
manifest/ main.ts interfaces.ts versions/ file-models/ actions/
orchestrator/ The control-plane app (Python)
app.py FastAPI UI + JSON API
jobs.py the job runner (extract → serve waves → review → synthesize)
serving.py vLLM + LiteLLM router on the Sparks, in waves
reviewers.py launch the reviewer panel
synthesis.py the local lead reviewer
extraction.py PDF/DOCX/TXT/MD → text (on the StartOS box)
preflight.py probe models before launching reviewers
spark_client.py SSH/rsync helpers
bm_config.py config defaults (mirrors startos/file-models/config.ts)
sandbox/ reviewer image (built ON the Spark, not packed in the s9pk)
grader_agent.py reviewer.Dockerfile build.sh
openclaw/ what each Spark needs provisioned (OpenClaw's job)
```
## Build
Same path as Nightshift — GitHub CI (`.github/workflows/build.yml`) or a local
build with `start-cli` (see the s9pk-build-on-mac recipe). The vLLM and reviewer
images are built **on the Sparks**, not packed into the `.s9pk`.
```
npm ci && npm run check && npm run build # type-check + bundle
make # pack the .s9pk (needs start-cli)
```
## Status
v0.1 — source complete, `tsc`-clean and Python-syntax-clean. Not yet validated
against live Sparks. See `openclaw/OPENCLAW_SPEC.md` for the Spark-side
provisioning (HF model pre-pull is required for air-gapped runs).
+2
View File
@@ -0,0 +1,2 @@
# Static assets bundled into the s9pk.
# Nightshift has none yet; this keeps the default `assets/` ingredient present.
+47
View File
@@ -0,0 +1,47 @@
# Boardroom Map
Drop confidential documents in and convene a **panel of local LLMs** running on
your DGX Sparks to review them. You choose the models, the personas (lenses), and
how many reviews. An optional **local lead reviewer** synthesizes the panel into
one consolidated report. There is **no frontier model and no cloud key** — in the
default air-gapped mode the documents and their reviews never leave your hardware.
Boardroom Map is a *control plane*: nothing serves or runs on your StartOS box (it only
SSHes to the Sparks and extracts document text on CPU).
## Setup (run the Actions in order)
1. **Configure Sparks** — SSH host/user/key for your head Spark (and optionally a
second), plus the work directory and image tags. Then **Test Spark Connection**.
2. **Configure Models** — the catalog of local models to serve (alias → HF id →
which Spark → port), and serving knobs. For air-gapped runs every model must be
on the **head Spark** and present in its HF cache.
3. **Configure Reviewers** — the panel: one entry per review, each a model + a
persona (the lens it reads through) + an optional temperature.
4. **Configure Review** — the rubric, the **Network Mode** (air-gapped vs
local-services), synthesis on/off + lead model, and whether to wipe documents
from the Sparks afterward.
## Running a review
1. Open the **Web UI** and drag your documents (PDF / DOCX / TXT / MD) onto the
inbox (or drop them in the service's `inbox` folder).
2. Click **Run Review** (or enable *auto-run on drop*).
3. Watch the activity log. The service extracts text locally, serves the needed
models on the Sparks **in waves** (so a panel can span more models than fit in
GPU memory at once), runs each reviewer, then the lead reviewer, and saves the
reports. Read them in the Web UI or via **View Latest Report**.
## Network modes
- **Air-gapped (default):** reviewer containers join an `--internal` Docker
network — they can reach only the on-Spark model proxy, with zero internet
egress. Models are served from a pre-pulled HF cache. All models must be on the
head Spark. Strongest confidentiality.
- **Local services:** reviewers may also reach LAN services (e.g. SearXNG) and the
second Spark. This network has egress unless you firewall it — use only when you
accept that reviewers can reach the network.
The original documents are extracted to plain text on the StartOS box; only that
text is shipped to the Sparks, and it is wiped from the Sparks after the job (the
reports are kept on your StartOS box).
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#171b24"/>
<stop offset="1" stop-color="#0e1118"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="48" fill="url(#bg)"/>
<!-- document -->
<rect x="74" y="56" width="92" height="120" rx="8" fill="#e9edf5"/>
<rect x="88" y="78" width="64" height="7" rx="3.5" fill="#9aa3b5"/>
<rect x="88" y="96" width="64" height="7" rx="3.5" fill="#9aa3b5"/>
<rect x="88" y="114" width="44" height="7" rx="3.5" fill="#9aa3b5"/>
<!-- magnifying glass (the review) -->
<circle cx="158" cy="150" r="34" fill="none" stroke="#c9a24b" stroke-width="10"/>
<line x1="182" y1="174" x2="206" y2="198" stroke="#c9a24b" stroke-width="12" stroke-linecap="round"/>
<circle cx="158" cy="150" r="20" fill="#c9a24b" opacity="0.18"/>
</svg>

After

Width:  |  Height:  |  Size: 941 B

+47
View File
@@ -0,0 +1,47 @@
# Boardroom Map
Drop confidential documents in and convene a **panel of local LLMs** running on
your DGX Sparks to review them. You choose the models, the personas (lenses), and
how many reviews. An optional **local lead reviewer** synthesizes the panel into
one consolidated report. There is **no frontier model and no cloud key** — in the
default air-gapped mode the documents and their reviews never leave your hardware.
Boardroom Map is a *control plane*: nothing serves or runs on your StartOS box (it only
SSHes to the Sparks and extracts document text on CPU).
## Setup (run the Actions in order)
1. **Configure Sparks** — SSH host/user/key for your head Spark (and optionally a
second), plus the work directory and image tags. Then **Test Spark Connection**.
2. **Configure Models** — the catalog of local models to serve (alias → HF id →
which Spark → port), and serving knobs. For air-gapped runs every model must be
on the **head Spark** and present in its HF cache.
3. **Configure Reviewers** — the panel: one entry per review, each a model + a
persona (the lens it reads through) + an optional temperature.
4. **Configure Review** — the rubric, the **Network Mode** (air-gapped vs
local-services), synthesis on/off + lead model, and whether to wipe documents
from the Sparks afterward.
## Running a review
1. Open the **Web UI** and drag your documents (PDF / DOCX / TXT / MD) onto the
inbox (or drop them in the service's `inbox` folder).
2. Click **Run Review** (or enable *auto-run on drop*).
3. Watch the activity log. The service extracts text locally, serves the needed
models on the Sparks **in waves** (so a panel can span more models than fit in
GPU memory at once), runs each reviewer, then the lead reviewer, and saves the
reports. Read them in the Web UI or via **View Latest Report**.
## Network modes
- **Air-gapped (default):** reviewer containers join an `--internal` Docker
network — they can reach only the on-Spark model proxy, with zero internet
egress. Models are served from a pre-pulled HF cache. All models must be on the
head Spark. Strongest confidentiality.
- **Local services:** reviewers may also reach LAN services (e.g. SearXNG) and the
second Spark. This network has egress unless you firewall it — use only when you
accept that reviewers can reach the network.
The original documents are extracted to plain text on the StartOS box; only that
text is shipped to the Sparks, and it is wiped from the Sparks after the job (the
reports are kept on your StartOS box).
+26
View File
@@ -0,0 +1,26 @@
# StartOS service image: the Boardroom Map orchestrator (control plane).
# Build context is the PROJECT ROOT (manifest images.main.workdir = '.'), so
# COPY paths are repo-relative. Arch-agnostic and GPU-free — it SSHes to the
# Sparks and extracts document text on CPU. start-cli builds this for x86_64 +
# aarch64.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-client rsync tini ca-certificates && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY orchestrator/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY orchestrator/ ./
# Ship the reviewer build context so the orchestrator can build the reviewer
# image on the head Spark over SSH (see reviewers.ensure_reviewer_image). Lives
# at /app/sandbox.
COPY sandbox/ ./sandbox/
EXPOSE 8080
ENV BM_DATA_DIR=/data
# main.ts overrides this command, but keep a sane default for local runs.
ENTRYPOINT ["tini", "--"]
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
+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>
+347
View File
@@ -0,0 +1,347 @@
{
"name": "boardroom-map-startos",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boardroom-map-startos",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@start9labs/start-sdk": "1.5.3"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
}
},
"node_modules/@iarna/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q=="
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodable/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
]
},
"node_modules/@start9labs/start-sdk": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.3.tgz",
"integrity": "sha512-OyHe9J6hMvyA5ZavcLkxdVQvZcuTH9J9kagV6NDI83eAG/YpJFIq62gP/n/2PPNdHWwNSXVQmSwnsvsV8Gyg+A==",
"dependencies": {
"@iarna/toml": "^3.0.0",
"@noble/curves": "^1.9.7",
"@noble/hashes": "^1.8.0",
"@types/ini": "^4.1.1",
"deep-equality-data-structures": "^2.0.0",
"fast-xml-parser": "~5.7.0",
"ini": "^5.0.0",
"isomorphic-fetch": "^3.0.0",
"mime": "^4.1.0",
"yaml": "^2.8.3",
"zod": "4.3.6",
"zod-deep-partial": "^1.2.0"
}
},
"node_modules/@types/ini": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz",
"integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg=="
},
"node_modules/@types/node": {
"version": "22.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vercel/ncc": {
"version": "0.38.4",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
"dev": true,
"bin": {
"ncc": "dist/ncc/cli.js"
}
},
"node_modules/anynum": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz",
"integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
]
},
"node_modules/deep-equality-data-structures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
"integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==",
"dependencies": {
"object-hash": "^3.0.0"
}
},
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/ini": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
"integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/isomorphic-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"dependencies": {
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"engines": {
"node": ">= 6"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/prettier": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/strnum": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz",
"integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"anynum": "^1.0.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-deep-partial": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz",
"integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==",
"peerDependencies": {
"zod": "^4.1.13"
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "boardroom-map-startos",
"version": "0.1.0",
"description": "StartOS service: drop confidential documents in and have a panel of local LLMs on your DGX Sparks review them — fully air-gappable, no frontier oversight",
"scripts": {
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
"check": "tsc --noEmit",
"prettier": "prettier --write startos"
},
"license": "Apache-2.0",
"dependencies": {
"@start9labs/start-sdk": "1.5.3"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
}
}
+138
View File
@@ -0,0 +1,138 @@
# ** Plumbing. DO NOT EDIT **.
# This file is imported by ./Makefile. Make edits there
PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts)
INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null)
# Resolve the actual git dir so this works inside git worktrees, where .git
# is a file pointing at <main>/.git/worktrees/<name> rather than a directory.
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index)
ARCHES ?= x86 arm riscv
# TARGETS is the list of leaf make-targets the build matrix fans out over.
# Defaults to the arches; variant packages override (e.g. immich, ollama, vllm
# set this to a list of variant or variant-arch leaf targets).
TARGETS ?= $(ARCHES)
ifdef VARIANT
BASE_NAME := $(PACKAGE_ID)_$(VARIANT)
else
BASE_NAME := $(PACKAGE_ID)
endif
.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients
.DELETE_ON_ERROR:
.SECONDARY:
define SUMMARY
@manifest=$$(start-cli s9pk inspect $(1) manifest); \
size=$$(du -h $(1) | awk '{print $$1}'); \
title=$$(printf '%s' "$$manifest" | jq -r .title); \
version=$$(printf '%s' "$$manifest" | jq -r .version); \
arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \
sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \
gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \
printf "\n"; \
printf "\033[1;32m✅ Build Complete!\033[0m\n"; \
printf "\n"; \
printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \
printf "───────────────────────────────\n"; \
printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \
printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \
printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \
printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \
printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \
echo ""
endef
all: $(TARGETS)
arches: $(ARCHES)
# Generic make-variable introspection. Used by the release workflow to
# read $(TARGETS) and fan out one matrix runner per target. `make -s
# print-TARGETS` echoes the list with no other output.
print-%:
@echo '$($*)'
universal: $(BASE_NAME).s9pk
$(call SUMMARY,$<)
arch/%: $(BASE_NAME)_%.s9pk
$(call SUMMARY,$<)
x86 x86_64: arch/x86_64
arm arm64 aarch64: arch/aarch64
riscv riscv64: arch/riscv64
$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS)
@$(MAKE) --no-print-directory ingredients
@echo " Packing '$@'..."
start-cli s9pk pack -o $@
$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS)
@$(MAKE) --no-print-directory ingredients
@echo " Packing '$@'..."
start-cli s9pk pack --arch=$* -o $@
ingredients: $(INGREDIENTS)
@echo " Re-evaluating ingredients..."
install: | check-deps check-init
@HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$HOST" ]; then \
echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
if [ -z "$$(ls *.s9pk 2>/dev/null)" ]; then \
echo "Error: No .s9pk file found. Run 'make' first."; \
exit 1; \
fi; \
S9PK=$$(start-cli s9pk select) || exit 1; \
printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \
start-cli package install -s "$$S9PK"
publish: | all
@REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$REGISTRY" ]; then \
echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$S3BASE" ]; then \
echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
command -v s3cmd >/dev/null || \
(echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \
printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \
for s9pk in *.s9pk; do \
age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \
if [ "$$age" -gt 3600 ]; then \
printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \
read -r ans; \
case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \
fi; \
start-cli s9pk publish "$$s9pk"; \
done
check-deps:
@command -v start-cli >/dev/null || \
(echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1)
@command -v npm >/dev/null || \
(echo "Error: npm not found. Please install Node.js and npm." && exit 1)
check-init:
@if [ ! -f ~/.startos/developer.key.pem ]; then \
echo "Initializing StartOS developer environment..."; \
start-cli init-key; \
fi
javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules
npm run check
npm run build
node_modules: package-lock.json package.json
npm ci
clean:
@echo "Cleaning up build artifacts..."
@rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules
+33
View File
@@ -0,0 +1,33 @@
# Boardroom Map reviewer sandbox
This directory is the build context for the **reviewer image**, which runs on the
DGX Spark — it is *not* packed into the `.s9pk`. The orchestrator rsyncs this
folder to the head Spark and runs `build.sh` there (the Sparks are aarch64), or
you can build it by hand.
- `grader_agent.py` — a one-shot, read-only agent. It reads the documents
mounted at `/docs`, runs one local model (through the on-Spark proxy) under its
persona (`/persona/PERSONA.md`) and the shared rubric (`/RUBRIC.md`), and writes
a single report to `/out`. With `BM_ROLE=synthesizer` it instead reads the
panel's reports from `/reports` and writes `CONSOLIDATED_REPORT.md`.
- `reviewer.Dockerfile` — lean pure-Python image (stdlib only).
- `build.sh``IMAGE=boardroom-grader:latest bash build.sh`.
## How it is launched (by the orchestrator)
Hardened and, in air-gapped mode, network-isolated:
```
docker run -d --name bm-grader-<id> \
--network boardroom-net \ # --internal in air-gapped mode
--user 1000:1000 --security-opt no-new-privileges --cap-drop ALL \
--read-only --tmpfs /tmp --tmpfs /home/rev --pids-limit 256 --memory 6g --cpus 4 \
-e BM_MODEL=<alias> -e BM_LLM_BASE=http://boardroom-proxy:4000/v1 ... \
-v <job>/docs:/docs:ro -v <job>/out:/out \
-v <job>/personas/<id>.md:/persona/PERSONA.md:ro -v <job>/RUBRIC.md:/RUBRIC.md:ro \
boardroom-grader:latest
```
The container can reach **only** the model proxy in air-gapped mode; it holds no
credentials beyond a dummy proxy key and cannot touch the host or the documents'
originals.
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Build the Boardroom Map reviewer image ON THE HEAD SPARK (aarch64/GB10).
# The orchestrator does this automatically over SSH, but you can also run it by
# hand: copy this sandbox/ directory to the Spark and run:
#
# IMAGE=boardroom-grader:latest bash build.sh
#
# The tag must match the service's "Reviewer Image Tag" (Configure Sparks).
set -euo pipefail
IMAGE="${IMAGE:-boardroom-grader:latest}"
DIR="$(cd "$(dirname "$0")" && pwd)"
echo ">> Building $IMAGE from $DIR"
docker build -t "$IMAGE" -f "$DIR/reviewer.Dockerfile" "$DIR"
echo ">> Done. Image: $IMAGE"
docker image inspect "$IMAGE" >/dev/null && echo ">> OK"
+28
View File
@@ -0,0 +1,28 @@
# Boardroom Map reviewer image — BUILT ON THE HEAD SPARK (aarch64), not packed into the
# s9pk (the orchestrator ships this build context and builds it on the Spark; see
# reviewers.ensure_reviewer_image).
#
# One-shot, read-only document reviewer (grader_agent.py) speaking the
# OpenAI-compatible API directly — a lean pure-Python image that builds fast.
#
# At RUN time the orchestrator launches this HARDENED (non-root, --cap-drop ALL,
# --security-opt no-new-privileges, read-only rootfs, no docker socket, only
# /docs (ro), /persona (ro), /RUBRIC.md (ro) and /out (rw) mounted, cpu/mem/pid
# caps) and attached to the per-job network. In air-gapped mode that network is
# --internal, so the container can reach ONLY the on-Spark model proxy.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
# Non-root user matching the orchestrator's --user 1000:1000 (HOME=/home/rev,
# mounted as a writable tmpfs at run time).
RUN useradd -u 1000 -m -d /home/rev -s /bin/bash reviewer || true
COPY grader_agent.py /opt/boardroom/grader_agent.py
WORKDIR /out
ENV PYTHONUNBUFFERED=1
# grader_agent.py uses only the Python stdlib (urllib) — no pip deps to install.
ENTRYPOINT ["python3", "/opt/boardroom/grader_agent.py"]
+339
View File
@@ -0,0 +1,339 @@
"""Boardroom Map reviewer — a sandboxed, ONE-SHOT agent that reads confidential
documents through a LOCAL model and writes a single report, then exits.
Unlike a swarm worker, this never loops forever and never writes to a shared
workspace. It mounts the documents read-only at /docs, runs one model (through the
on-Spark proxy) under its PERSONA + the shared RUBRIC, and writes exactly one
file to /out:
role=reviewer -> /out/<BM_REVIEWER_ID>.md
role=synthesizer -> /out/CONSOLIDATED_REPORT.md (also reads /reports)
It speaks the OpenAI-compatible /v1/chat/completions API directly (no Claude CLI,
no Anthropic translation — small local models handle this far better). The whole
document set is pre-loaded into the prompt up to a budget; for anything larger the
model can pull more with read_file. Native tool_calls are used when available,
with a JSON-action text fallback for models without a vLLM tool parser.
In air-gapped mode the container is on an --internal Docker network: the only
thing reachable is the model proxy. web_search is offered ONLY when BM_SEARXNG_URL
is set (local-services mode).
"""
from __future__ import annotations
import json
import os
import re
import ssl
import time
import traceback
import urllib.parse
import urllib.request
_SSL_CTX = ssl._create_unverified_context() # LAN self-signed (SearXNG)
RID = os.environ.get("BM_REVIEWER_ID", "reviewer")
NAME = os.environ.get("BM_REVIEWER_NAME", RID)
ROLE = os.environ.get("BM_ROLE", "reviewer") # reviewer | synthesizer
MODEL = os.environ.get("BM_MODEL", "reviewer-a")
LLM_BASE = os.environ.get("BM_LLM_BASE", "http://boardroom-proxy:4000/v1").rstrip("/")
LLM_KEY = os.environ.get("BM_LLM_KEY", "sk-local")
SEARXNG_URL = os.environ.get("BM_SEARXNG_URL", "").rstrip("/")
try:
TEMPERATURE = float(os.environ.get("BM_TEMPERATURE", "") or "0.3")
except ValueError:
TEMPERATURE = 0.3
try:
MAX_MODEL_LEN = int(os.environ.get("BM_MAX_MODEL_LEN", "32768"))
except ValueError:
MAX_MODEL_LEN = 32768
DOCS = "/docs"
REPORTS = "/reports"
OUT_DIR = "/out"
PERSONA_PATH = "/persona/PERSONA.md"
RUBRIC_PATH = "/RUBRIC.md"
# Leave headroom for the system/rubric/persona + the model's output; spend the
# rest on document text (~3 chars/token is a safe rough estimate).
DOC_BUDGET = max(8000, (MAX_MODEL_LEN - 3500) * 3)
MAX_STEPS = 8
MAX_OUTPUT_TOKENS = 2048
# ---------------------------------------------------------------- io helpers
def read_text(path: str, limit: int = 1_000_000) -> str:
try:
with open(path, errors="replace") as f:
return f.read()[:limit]
except FileNotFoundError:
return ""
def _roots() -> list[str]:
return [DOCS, REPORTS] if ROLE == "synthesizer" else [DOCS]
def _safe(path: str) -> str:
"""Resolve a path inside an allowed read root; refuse escapes."""
cand = path or "."
for root in _roots():
p = os.path.realpath(os.path.join(root, cand) if not os.path.isabs(cand) else cand)
if p == root or p.startswith(root + os.sep):
return p
raise ValueError(f"path outside allowed roots: {path}")
def list_dir(root: str) -> list[str]:
out = []
if not os.path.isdir(root):
return out
for r, _dirs, files in os.walk(root):
for fn in files:
out.append(os.path.relpath(os.path.join(r, fn), root))
return sorted(out)
# ---------------------------------------------------------------- tools
def tool_list_files(args: dict) -> str:
lines = []
for root in _roots():
names = list_dir(root)
if names:
lines.append(f"{root}:")
lines += [f" {n}" for n in names]
return "\n".join(lines) or "(no files)"
def tool_read_file(args: dict) -> str:
p = _safe(args["path"])
try:
with open(p, errors="replace") as f:
return f.read()[:20000]
except FileNotFoundError:
return f"(no such file: {args['path']})"
except IsADirectoryError:
return f"(is a directory: {args['path']})"
def tool_web_search(args: dict) -> str:
if not SEARXNG_URL:
return "web search unavailable"
q = urllib.parse.urlencode({"q": args.get("query", ""), "format": "json"})
try:
req = urllib.request.Request(f"{SEARXNG_URL}/search?{q}", headers={"User-Agent": "boardroom-grader"})
with urllib.request.urlopen(req, timeout=20, context=_SSL_CTX) as resp:
data = json.loads(resp.read().decode())
lines = [f"- {r.get('title','')}\n {r.get('url','')}\n {r.get('content','')[:300]}"
for r in (data.get("results") or [])[:8]]
return "\n".join(lines) or "(no results)"
except Exception as e:
return f"search error: {e}"
DISPATCH = {"list_files": tool_list_files, "read_file": tool_read_file, "web_search": tool_web_search}
TOOLS = [
{"type": "function", "function": {
"name": "list_files", "description": "List the available document (and report) files.",
"parameters": {"type": "object", "properties": {}}}},
{"type": "function", "function": {
"name": "read_file", "description": "Read a document or report file by its path (from list_files).",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
]
if SEARXNG_URL:
TOOLS.append({"type": "function", "function": {
"name": "web_search", "description": "Search the web via SearXNG; returns top results.",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}})
def run_tool(name: str, args: dict) -> str:
fn = DISPATCH.get(name)
if not fn:
return f"(unknown tool: {name})"
try:
return fn(args)
except Exception as e:
return f"(tool error: {e})"
# ---------------------------------------------------------------- LLM
def chat(messages: list) -> dict:
body = {"model": MODEL, "messages": messages, "tools": TOOLS,
"tool_choice": "auto", "temperature": TEMPERATURE, "max_tokens": MAX_OUTPUT_TOKENS}
req = urllib.request.Request(
f"{LLM_BASE}/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM_KEY}"},
method="POST")
with urllib.request.urlopen(req, timeout=300, context=_SSL_CTX) as resp:
data = json.loads(resp.read().decode())
return data["choices"][0]["message"]
_JSON_ACTION = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
def _text_fallback_calls(content: str) -> list:
if not content:
return []
m = _JSON_ACTION.search(content)
if not m:
return []
try:
obj = json.loads(m.group(1))
except json.JSONDecodeError:
return []
name = obj.get("tool") or obj.get("name")
if name in DISPATCH:
return [{"id": "fallback", "function": {"name": name, "arguments": json.dumps(obj.get("args", {}))}}]
return []
# ---------------------------------------------------------------- prompts
def _preload_docs() -> tuple[str, bool]:
"""Concatenate the document text up to DOC_BUDGET. Returns (text, truncated)."""
names = list_dir(DOCS)
chunks, used, truncated = [], 0, False
for n in names:
body = read_text(os.path.join(DOCS, n))
header = f"\n\n========== DOCUMENT: {n} ==========\n"
room = DOC_BUDGET - used
if room <= 0:
truncated = True
break
seg = (header + body)[:room]
if len(header + body) > room:
truncated = True
chunks.append(seg)
used += len(seg)
return "".join(chunks), truncated
def _preload_reports() -> str:
names = list_dir(REPORTS)
parts = []
budget = DOC_BUDGET
used = 0
for n in names:
body = read_text(os.path.join(REPORTS, n))
header = f"\n\n========== REVIEWER REPORT: {n} ==========\n"
seg = (header + body)[: max(0, budget - used)]
parts.append(seg)
used += len(seg)
return "".join(parts)
def system_prompt() -> str:
persona = read_text(PERSONA_PATH).strip()
if ROLE == "synthesizer":
base = (f"You are '{NAME}', the lead reviewer chairing a document-review panel. "
"You are given the panel members' individual reports (and the source "
"documents for reference). Produce ONE consolidated report in Markdown.")
else:
base = (f"You are '{NAME}', an expert confidential-document reviewer. Read the "
"document(s) provided and produce ONE written report in Markdown. Base every "
"statement on the documents; never invent facts. Be specific and cite the "
"document/section for each point.")
if persona:
base += "\n\n# YOUR LENS — how YOU specifically read this\n" + persona
tools_note = (
"\n\nYou can call list_files and read_file to pull more content if what was "
"pre-loaded is truncated"
+ (", and web_search for external context" if SEARXNG_URL else "")
+ ". When done, reply with the FINAL report only — no tool call. If your client "
'cannot emit tool calls, reply with a single fenced block: '
'```json\\n{"tool":"read_file","args":{"path":"..."}}\\n``` and nothing else.'
)
return base + tools_note
def first_user_message() -> str:
rubric = read_text(RUBRIC_PATH).strip() or "Produce a thorough review report."
if ROLE == "synthesizer":
reports = _preload_reports()
docs, truncated = _preload_docs()
return (f"# REVIEW RUBRIC\n{rubric}\n\n# PANEL REPORTS\n{reports}\n\n"
f"# SOURCE DOCUMENTS (for reference){' (truncated)' if truncated else ''}\n{docs}\n\n"
"# YOUR TASK\nConsolidate the panel's reports into one authoritative report per the "
"rubric: shared findings, conflicts (and your adjudication), anything only one "
"reviewer caught, and a prioritized overall recommendation. Attribute points to "
"reviewers. Output the final consolidated report now.")
docs, truncated = _preload_docs()
note = ("\n\n(Note: the documents were truncated to fit context — use read_file to pull any "
"section you need in full.)" if truncated else "")
return (f"# REVIEW RUBRIC\n{rubric}\n\n# DOCUMENT(S)\n{docs}{note}\n\n"
"# YOUR TASK\nReview the document(s) above per the rubric and your lens. Output your "
"final report now.")
# ---------------------------------------------------------------- run
def out_path() -> str:
name = "CONSOLIDATED_REPORT.md" if ROLE == "synthesizer" else f"{RID}.md"
return os.path.join(OUT_DIR, name)
def write_report(text: str) -> None:
os.makedirs(OUT_DIR, exist_ok=True)
with open(out_path(), "w") as f:
f.write(text.strip() + "\n")
def run() -> str:
messages = [{"role": "system", "content": system_prompt()},
{"role": "user", "content": first_user_message()}]
last_text = ""
for _ in range(MAX_STEPS):
msg = chat(messages)
content = msg.get("content") or ""
calls = msg.get("tool_calls") or []
if content.strip():
last_text = content.strip()
if not calls:
calls = _text_fallback_calls(content)
if not calls:
break # final report
messages.append({"role": "assistant", "content": content})
for c in calls:
args = json.loads(c["function"]["arguments"] or "{}")
res = run_tool(c["function"]["name"], args)
messages.append({"role": "user", "content": f"[tool {c['function']['name']} result]\n{res[:20000]}"})
continue
messages.append({"role": "assistant", "content": content or None, "tool_calls": calls})
for c in calls:
try:
args = json.loads(c["function"]["arguments"] or "{}")
except json.JSONDecodeError:
args = {}
res = run_tool(c["function"]["name"], args)
messages.append({"role": "tool", "tool_call_id": c.get("id", ""), "content": res[:20000]})
# If the model ended on a tool turn with no report text, ask once more plainly.
if not last_text.strip():
messages.append({"role": "user", "content": "Now output your final report in Markdown."})
try:
last_text = (chat(messages).get("content") or "").strip()
except Exception:
pass
return last_text
def main() -> None:
print(f"[{RID}] reviewer starting (role={ROLE} model={MODEL} base={LLM_BASE})", flush=True)
try:
report = run()
if not report.strip():
report = f"# {NAME}\n\n(The model returned no report text.)"
write_report(report)
print(f"[{RID}] report written to {out_path()} ({len(report)} chars)", flush=True)
except Exception as e:
print(traceback.format_exc(), flush=True)
# Always leave a file so the orchestrator can see this reviewer ran.
try:
write_report(f"# {NAME} — ERROR\n\nThis reviewer failed: {e}\n")
except Exception:
pass
raise
if __name__ == "__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config'
const { InputSpec, Value, List } = sdk
const inputSpec = InputSpec.of({
reviewers: Value.list(
List.obj(
{
name: 'Review Panel',
description:
'Who sits on the panel — one entry per review. Add as many as you like. ' +
'Each reviewer is a model from your catalog plus a PERSONA: the lens it ' +
'reads through, so the same document gets examined from different angles.',
default: [],
minLength: 1,
maxLength: 32,
},
{
uniqueBy: 'name',
displayAs: '{{name}} ({{model}})',
spec: InputSpec.of({
name: Value.text({
name: 'Name',
description: 'Unique reviewer name. Becomes its container and report filename.',
required: true,
default: null,
placeholder: 'risk-counsel',
patterns: [
{ regex: '^[A-Za-z0-9][A-Za-z0-9 _-]{0,40}$',
description: 'Letters, numbers, spaces, dashes, underscores (max 41 chars).' },
],
}),
model: Value.text({
name: 'Model Alias',
description: 'Which catalog model this reviewer uses (must match an alias from "Configure Models").',
required: true,
default: null,
placeholder: 'reviewer-a',
}),
persona: Value.textarea({
name: 'Persona / Lens',
description:
'How THIS reviewer should read the documents — its priorities and ' +
'weighting. Injected into its system prompt. e.g. "You are skeptical ' +
'legal counsel: weight liability, ambiguous obligations, and missing ' +
'clauses above everything." Leave empty for a neutral reviewer.',
required: false,
default: null,
minRows: 3,
maxRows: 16,
placeholder:
'You are a financial-controls reviewer. Focus on numbers that do not ' +
'reconcile, unstated assumptions behind projections, and anything that ' +
'would concern an auditor. Organize findings by severity.',
}),
temperature: Value.number({
name: 'Sampling Temperature (optional)',
description:
'Best-effort per-reviewer sampling temperature for extra diversity. ' +
'Persona is the primary lever. Leave empty to use the model default.',
required: false,
default: null,
integer: false,
min: 0,
max: 2,
}),
}),
},
),
),
})
export const configureReviewers = sdk.Action.withInput(
'configure-reviewers',
async ({ effects }) => ({
name: 'Configure Reviewers',
description: 'Define the review panel: which models and which personas, and how many reviews.',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const cfg = await configFile.read().const(effects)
if (!cfg) return {}
return { reviewers: cfg.reviewers }
},
async ({ effects, input }) => {
await configFile.merge(effects, { reviewers: input.reviewers })
return {
version: '1',
title: 'Panel Configured',
message:
'Saved a panel of ' + input.reviewers.length + ' reviewer(s). Each model ' +
'alias must exist in "Configure Models". Set the rubric in "Configure ' +
'Review", then drop documents and run a review.',
result: {
type: 'single',
value: input.reviewers.map((r) => r.name).join(', '),
copyable: false,
qr: false,
masked: false,
},
}
},
)
+132
View File
@@ -0,0 +1,132 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config'
const { InputSpec, Value } = sdk
const inputSpec = InputSpec.of({
reviewInstructions: Value.textarea({
name: 'Review Rubric',
description:
'What every reviewer should look for and produce. Layered above each ' +
'reviewer\'s persona. Be concrete about the structure you want back.',
required: true,
default: null,
minRows: 5,
maxRows: 20,
placeholder:
'Review the attached document(s). Produce: a short summary, key findings, ' +
'risks/red flags, open questions, and recommendations. Cite the document and ' +
'section for each point. Never invent facts not present in the documents.',
}),
networkMode: Value.select({
name: 'Network Mode',
description:
'Air-gapped: reviewers reach ONLY the on-Spark model proxy — zero internet, ' +
'documents never leave your hardware (models must be pre-pulled into the ' +
'Spark HF cache, all on the head Spark). Local services: reviewers may also ' +
'reach LAN services like SearXNG and the second Spark (this network has ' +
'egress unless you firewall it).',
default: 'airgapped',
values: {
airgapped: 'Air-gapped (no network, recommended)',
local_services: 'Local services (SearXNG / 2nd Spark)',
},
}),
searxngUrl: Value.text({
name: 'SearXNG URL (local-services only)',
description: 'JSON-search endpoint to give reviewers a web_search tool. Ignored in air-gapped mode. Empty = no web search.',
required: false,
default: null,
placeholder: 'https://searxng.local',
}),
synthesisEnabled: Value.toggle({
name: 'Synthesize a Consolidated Report',
description:
'After the panel finishes, run a local "lead reviewer" that reads all the ' +
'individual reports and writes one consolidated report (themes, conflicts, ' +
'consensus, recommendation). No frontier model — stays on the Sparks.',
default: true,
}),
synthesisModel: Value.text({
name: 'Lead Reviewer Model (optional)',
description: 'Catalog alias of the model that writes the consolidated report. Empty = use the first model.',
required: false,
default: null,
placeholder: 'reviewer-a',
}),
synthesisPersona: Value.textarea({
name: 'Lead Reviewer Instructions (optional)',
description: 'Override how the consolidated report is written. Empty = a sensible built-in default.',
required: false,
default: null,
minRows: 3,
maxRows: 14,
}),
wipeRemoteDocs: Value.toggle({
name: 'Wipe Documents From Sparks After Review',
description:
'Delete the extracted document text from the Sparks when a job finishes. ' +
'Reports are always kept on this StartOS box. Recommended for confidential material.',
default: true,
}),
autoRunOnDrop: Value.toggle({
name: 'Auto-run When Documents Are Dropped',
description:
'Start a review automatically (after a short debounce) whenever new files ' +
'land in the inbox. Off by default so you trigger reviews explicitly.',
default: false,
}),
})
export const configureReview = sdk.Action.withInput(
'configure-review',
async ({ effects }) => ({
name: 'Configure Review',
description: 'Set the rubric, air-gap mode, synthesis, and document retention.',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const cfg = await configFile.read().const(effects)
if (!cfg) return {}
return {
reviewInstructions: cfg.reviewInstructions,
networkMode: cfg.networkMode,
searxngUrl: cfg.searxngUrl || undefined,
synthesisEnabled: cfg.synthesisEnabled,
synthesisModel: cfg.synthesisModel || undefined,
synthesisPersona: cfg.synthesisPersona || undefined,
wipeRemoteDocs: cfg.wipeRemoteDocs,
autoRunOnDrop: cfg.autoRunOnDrop,
}
},
async ({ effects, input }) => {
await configFile.merge(effects, {
reviewInstructions: input.reviewInstructions,
networkMode: input.networkMode,
searxngUrl: input.searxngUrl ?? '',
synthesisEnabled: input.synthesisEnabled,
synthesisModel: input.synthesisModel ?? '',
synthesisPersona: input.synthesisPersona ?? '',
wipeRemoteDocs: input.wipeRemoteDocs,
autoRunOnDrop: input.autoRunOnDrop,
})
return {
version: '1',
title: 'Review Settings Saved',
message:
input.networkMode === 'airgapped'
? 'Saved. Reviewers will run air-gapped (no internet). Ensure all models are on the head Spark and pre-pulled into its HF cache.'
: 'Saved. Reviewers run in local-services mode and may reach the network — make sure that is acceptable for these documents.',
result: { type: 'single', value: input.networkMode, copyable: false, qr: false, masked: false },
}
},
)
+159
View File
@@ -0,0 +1,159 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config'
const { InputSpec, Value, List } = sdk
const inputSpec = InputSpec.of({
models: Value.list(
List.obj(
{
name: 'Model Catalog',
description:
'The local models this service can serve on your Sparks. Each reviewer ' +
'references one of these by its alias. The job runner loads models in ' +
'waves so you can run a panel across more models than fit in GPU memory ' +
'at once.',
default: [],
minLength: 1,
maxLength: 16,
},
{
uniqueBy: 'alias',
displayAs: '{{alias}} → {{hfModel}}',
spec: InputSpec.of({
alias: Value.text({
name: 'Alias',
description: 'Short name reviewers use to pick this model (e.g. "qwen-32b").',
required: true,
default: null,
placeholder: 'reviewer-a',
patterns: [
{ regex: '^[a-z0-9][a-z0-9-]{0,30}$',
description: 'Lowercase letters, numbers, dashes (max 31 chars).' },
],
}),
hfModel: Value.text({
name: 'Hugging Face Model ID',
description: 'The model vLLM serves. Must be present in the Spark HF cache for air-gapped mode.',
required: true,
default: null,
placeholder: 'Qwen/Qwen3-32B-FP8',
}),
spark: Value.select({
name: 'Served On',
description:
'Which Spark serves this model. Air-gapped review mode requires the ' +
'head (primary) Spark; the secondary is used only in local-services mode.',
default: 'primary',
values: { primary: 'Primary (head) Spark', secondary: 'Secondary Spark' },
}),
port: Value.number({
name: 'vLLM Port',
description: 'Host port the vLLM container for this model listens on. Unique per Spark.',
required: true,
default: 8001,
integer: true,
min: 1,
max: 65535,
}),
}),
},
),
),
gpuMemoryUtilization: Value.text({
name: 'GPU Memory Utilization',
description: 'vLLM --gpu-memory-utilization (01). Lower it if you co-resident multiple models per Spark.',
required: true,
default: '0.85',
}),
maxModelLen: Value.number({
name: 'Max Model Length',
description: 'vLLM --max-model-len (context window). Documents are chunked to fit.',
required: true,
default: 32768,
integer: true,
min: 2048,
}),
toolCallParser: Value.text({
name: 'Tool-Call Parser',
description:
'vLLM tool-call parser for the reviewer\'s read-file tool loop. Match the ' +
'served model family (Qwen3 → "hermes"). Empty disables native tool-calling.',
required: false,
default: 'hermes',
}),
maxConcurrentModels: Value.number({
name: 'Max Co-resident Models (head Spark)',
description:
'How many distinct models may load on the head Spark at once. The job runner ' +
'loads models in waves so it never exceeds this. 1 is safest.',
required: true,
default: 1,
integer: true,
min: 1,
max: 8,
}),
proxyPort: Value.number({
name: 'Model Proxy Port',
description: 'Port for the on-Spark LiteLLM router that exposes every model alias on one endpoint.',
required: true,
default: 4000,
integer: true,
min: 1,
max: 65535,
}),
})
export const configureModels = sdk.Action.withInput(
'configure-models',
async ({ effects }) => ({
name: 'Configure Models',
description: 'Define the local model catalog served on your Sparks and the serving knobs.',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const cfg = await configFile.read().const(effects)
if (!cfg) return {}
return {
models: cfg.models,
gpuMemoryUtilization: cfg.gpuMemoryUtilization,
maxModelLen: cfg.maxModelLen,
toolCallParser: cfg.toolCallParser,
maxConcurrentModels: cfg.maxConcurrentModels,
proxyPort: cfg.proxyPort,
}
},
async ({ effects, input }) => {
await configFile.merge(effects, {
models: input.models,
gpuMemoryUtilization: input.gpuMemoryUtilization,
maxModelLen: input.maxModelLen,
toolCallParser: input.toolCallParser ?? '',
maxConcurrentModels: input.maxConcurrentModels,
proxyPort: input.proxyPort,
})
return {
version: '1',
title: 'Models Configured',
message:
'Saved ' + input.models.length + ' model(s). Make sure each is present in ' +
'the Spark HF cache for air-gapped runs, then set "Configure Reviewers".',
result: {
type: 'single',
value: input.models.map((m) => m.alias).join(', '),
copyable: false,
qr: false,
masked: false,
},
}
},
)
+161
View File
@@ -0,0 +1,161 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config'
import { sshKeyFile, hfTokenFile } from '../file-models/secrets'
const { InputSpec, Value } = sdk
const inputSpec = InputSpec.of({
primarySparkHost: Value.text({
name: 'Primary Spark Host',
description: 'Hostname or IP of the head DGX Spark (reachable over SSH). Serves models, hosts the model proxy, and runs the reviewer panel.',
required: true,
default: null,
placeholder: 'spark-01.local',
}),
primarySparkUser: Value.text({
name: 'SSH User',
description: 'The login user on the Spark (DGX OS default is "nvidia").',
required: true,
default: 'nvidia',
}),
sshPort: Value.number({
name: 'SSH Port',
description: 'SSH port on the Spark.',
required: true,
default: 22,
integer: true,
min: 1,
max: 65535,
}),
sshPrivateKey: Value.textarea({
name: 'SSH Private Key',
description:
'A private key (PEM/OpenSSH) whose public half is in the Spark user\'s ' +
'~/.ssh/authorized_keys. Stored in this service\'s private volume and ' +
'used only to reach your Sparks. Paste the FULL key including header/footer.',
warning:
'This is a credential. It is written to the service volume and never ' +
'shown again. Use a dedicated key for this service.',
required: true,
default: null,
minRows: 6,
maxRows: 14,
placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----',
}),
useBothSparks: Value.toggle({
name: 'Use Both Sparks',
description:
'Allow models to be served on a second Spark (over ConnectX/200GbE) for ' +
'extra capacity. NOTE: in air-gapped review mode all models must run on the ' +
'head Spark; the second Spark is used only in local-services mode.',
default: false,
}),
secondarySparkHost: Value.text({
name: 'Secondary Spark Host',
description: 'Hostname/IP of the second Spark. Required only if "Use Both Sparks" is on.',
required: false,
default: null,
placeholder: 'spark-02.local',
}),
headInternalHost: Value.text({
name: 'Head Internal Host',
description:
'Address the head Spark uses for its own preflight checks against the local ' +
'model proxy. Single Spark: 127.0.0.1 is fine.',
required: true,
default: '127.0.0.1',
}),
remoteWorkDir: Value.text({
name: 'Remote Work Directory',
description: 'Absolute path on the head Spark for staged document text, the HF cache, and logs.',
required: true,
default: '/home/nvidia/boardroom-map',
}),
servingImage: Value.text({
name: 'vLLM Image Tag',
description: 'The vLLM serving image built on the Sparks (e.g. via spark-vllm-docker).',
required: true,
default: 'boardroom-vllm:latest',
}),
graderImage: Value.text({
name: 'Reviewer Image Tag',
description: 'The sandboxed reviewer image built on the head Spark from sandbox/build.sh.',
required: true,
default: 'boardroom-grader:latest',
}),
hfToken: Value.text({
name: 'Hugging Face Token (optional)',
description:
'Only needed for gated/private models or to warm a model the first time. ' +
'Leave empty to keep the existing token unchanged. Passed to the vLLM ' +
'container at serve time.',
required: false,
default: null,
masked: true,
}),
})
export const configureSparks = sdk.Action.withInput(
'configure-sparks',
async ({ effects }) => ({
name: 'Configure Sparks',
description: 'Set the DGX Spark connection details, SSH credentials, and image tags.',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
inputSpec,
// Prefill non-secret fields from existing config. Never prefill the key/token.
async ({ effects }) => {
const cfg = await configFile.read().const(effects)
if (!cfg) return {}
return {
primarySparkHost: cfg.primarySparkHost || undefined,
primarySparkUser: cfg.primarySparkUser,
sshPort: cfg.sshPort,
useBothSparks: cfg.useBothSparks,
secondarySparkHost: cfg.secondarySparkHost ?? undefined,
headInternalHost: cfg.headInternalHost,
remoteWorkDir: cfg.remoteWorkDir,
servingImage: cfg.servingImage,
graderImage: cfg.graderImage,
}
},
async ({ effects, input }) => {
// Persist the private key to its own file (600 enforced in-container).
await sshKeyFile.write(effects, input.sshPrivateKey.trim() + '\n')
let hfTokenSet = (await configFile.read().const(effects))?.hfTokenSet ?? false
if (input.hfToken && input.hfToken.trim()) {
await hfTokenFile.write(effects, input.hfToken.trim())
hfTokenSet = true
}
await configFile.merge(effects, {
primarySparkHost: input.primarySparkHost,
primarySparkUser: input.primarySparkUser,
sshPort: input.sshPort,
useBothSparks: input.useBothSparks,
secondarySparkHost: input.secondarySparkHost,
headInternalHost: input.headInternalHost,
remoteWorkDir: input.remoteWorkDir,
servingImage: input.servingImage,
graderImage: input.graderImage,
hfTokenSet,
})
return {
version: '1',
title: 'Sparks Configured',
message:
'Saved. Use "Test Spark Connection" to verify SSH + GPU access, then set ' +
'"Configure Models" and "Configure Reviewers".',
result: { type: 'single', value: input.primarySparkHost, copyable: false, qr: false, masked: false },
}
},
)
+59
View File
@@ -0,0 +1,59 @@
import { startSdk } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
/**
* Trigger a review of whatever is currently in the inbox. The running job-runner
* thread polls for /data/state/run_request and starts a job when it appears, so
* this action just drops that request file (decoupled from the daemon — no need
* to reach its HTTP port from the action's one-shot container).
*/
export const runReview = sdk.Action.withoutInput(
'run-review',
async ({ effects }) => ({
name: 'Run Review',
description: 'Convene the panel now over the documents currently in the inbox.',
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
}),
async ({ effects }) => {
const mounts = sdk.Mounts.of().mountVolume({
volumeId: 'main',
mountpoint: '/data',
subpath: null,
readonly: false,
})
let output: string
try {
const { stdout } = await startSdk.runCommand<typeof sdk.manifest>(
effects,
{ imageId: 'main' },
[
'sh',
'-c',
'mkdir -p /data/state && date +%s > /data/state/run_request && ' +
'n=$(ls -1 /data/inbox 2>/dev/null | wc -l | tr -d " "); ' +
'echo "Review requested. $n file(s) in the inbox."',
],
{ mounts, env: { BM_DATA_DIR: '/data' } },
'run-review',
)
output = (stdout?.toString() || '').trim() || 'Review requested.'
} catch (e: any) {
output = 'Could not request a review: ' + (e?.message || String(e))
}
return {
version: '1',
title: 'Review Requested',
message:
output +
' Watch the Web UI for progress; reports appear there and via "View Latest Report".',
result: { type: 'single', value: output, copyable: false, qr: false, masked: false },
}
},
)
+17
View File
@@ -0,0 +1,17 @@
import { sdk } from '../sdk'
import { configureSparks } from './configure-sparks'
import { configureModels } from './configure-models'
import { configureReviewers } from './configure-reviewers'
import { configureReview } from './configure-review'
import { runReview } from './run-review'
import { testConnection } from './test-connection'
import { latestReport } from './latest-report'
export const actions = sdk.Actions.of()
.addAction(configureSparks)
.addAction(configureModels)
.addAction(configureReviewers)
.addAction(configureReview)
.addAction(runReview)
.addAction(testConnection)
.addAction(latestReport)
+51
View File
@@ -0,0 +1,51 @@
import { startSdk } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
/**
* Returns the latest report as a copyable result, so you can read it straight
* from the StartOS service page without opening the Web UI. The job runner saves
* the most recent report (consolidated if synthesis is on, else the panel
* digest) to /data/reports/latest.md on the StartOS host — no Spark round-trip.
*/
export const latestReport = sdk.Action.withoutInput(
'latest-report',
async ({ effects }) => ({
name: 'View Latest Report',
description: 'Show the most recent review report produced by the panel.',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
async ({ effects }) => {
const mounts = sdk.Mounts.of().mountVolume({
volumeId: 'main',
mountpoint: '/data',
subpath: null,
readonly: true,
})
let report: string
try {
const { stdout } = await startSdk.runCommand<typeof sdk.manifest>(
effects,
{ imageId: 'main' },
['sh', '-c', 'cat /data/reports/latest.md 2>/dev/null || echo "(no report yet — drop documents in the inbox and run a review)"'],
{ mounts, env: { BM_DATA_DIR: '/data' } },
'latest-report',
)
report = (stdout?.toString() || '').trim() || '(no report yet)'
} catch (e: any) {
report = 'Could not read report: ' + (e?.message || String(e))
}
return {
version: '1',
title: 'Latest Boardroom Map Report',
message: 'The panel\'s most recent review.',
result: { type: 'single', value: report, copyable: true, qr: false, masked: false },
}
},
)
+55
View File
@@ -0,0 +1,55 @@
import { startSdk } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
/**
* Runs a one-shot in the orchestrator image that SSHes into the configured
* Spark(s) and reports `nvidia-smi` plus whether the vLLM + reviewer images are
* built. Reuses orchestrator/spark_client.py so SSH logic lives in one place.
*/
export const testConnection = sdk.Action.withoutInput(
'test-connection',
async ({ effects }) => ({
name: 'Test Spark Connection',
description: 'SSH into the configured Spark(s) and verify GPU + serving/reviewer image access.',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
async ({ effects }) => {
const mounts = sdk.Mounts.of().mountVolume({
volumeId: 'main',
mountpoint: '/data',
subpath: null,
readonly: true,
})
let output: string
try {
const { stdout, stderr } = await startSdk.runCommand<typeof sdk.manifest>(
effects,
{ imageId: 'main' },
['python3', '/app/spark_client.py', 'test'],
{ mounts, env: { BM_DATA_DIR: '/data' } },
'spark-test',
)
output =
(stdout?.toString() || '').trim() +
(stderr?.toString().trim() ? '\n\n[stderr]\n' + stderr.toString().trim() : '')
} catch (e: any) {
output =
'Connection test failed.\n\n' +
(e?.stdout?.toString() || '') +
(e?.stderr?.toString() || e?.message || String(e))
}
return {
version: '1',
title: 'Spark Connection Test',
message: 'Result of probing your Spark(s) over SSH.',
result: { type: 'single', value: output || '(no output)', copyable: true, qr: false, masked: false },
}
},
)
+132
View File
@@ -0,0 +1,132 @@
import { FileHelper, z } from '@start9labs/start-sdk'
/**
* Boardroom Map configuration, persisted to the `main` volume as config.json.
*
* Written by the StartOS actions (Configure Sparks / Models / Reviewers /
* Review) and read by the Python orchestrator inside the container, which mounts
* the same volume at /data and reads /data/config.json. Keep field names in sync
* with orchestrator/bm_config.py (CONFIG_DEFAULTS).
*
* Boardroom Map is a CONTROL PLANE: a GPU-free orchestrator on StartOS that SSHes into
* one or two DGX Sparks to serve local models and run a panel of sandboxed
* "reviewer" containers over confidential documents you drop in. There is NO
* frontier model and NO cloud key — everything stays on your hardware. The only
* secrets are the Spark SSH key and an optional Hugging Face token (secrets.ts).
*/
export const configShape = z.object({
// --- Spark connection (mirrors LLaMA-Factory / Nightshift) ---
primarySparkHost: z.string().default(''),
primarySparkUser: z.string().default('nvidia'),
sshPort: z.number().int().positive().default(22),
// Second Spark for extra model capacity. null = single node.
secondarySparkHost: z.string().nullable().default(null),
useBothSparks: z.boolean().default(false),
// Address the head Spark's own preflight checks use to reach the local proxy.
headInternalHost: z.string().default('127.0.0.1'),
// --- Remote execution ---
remoteWorkDir: z.string().default('/home/nvidia/boardroom-map'),
// --- Images (built ON the Sparks; not packed into the s9pk) ---
servingImage: z.string().default('boardroom-vllm:latest'),
graderImage: z.string().default('boardroom-grader:latest'),
// --- Serving (vLLM on the Sparks) ---
gpuMemoryUtilization: z.string().default('0.85'),
maxModelLen: z.number().int().positive().default(32768),
// vLLM tool-call parser for native function-calling (the reviewer's read-file
// tool loop relies on it). Must match the served model family — Qwen3 →
// 'hermes'. Empty disables native tool-calling (reviewers fall back to a
// JSON-action text protocol).
toolCallParser: z.string().default('hermes'),
// LiteLLM router exposing every model alias on one OpenAI-compatible endpoint.
proxyPort: z.number().int().positive().default(4000),
// How many distinct models may be co-resident on the HEAD Spark at once. The
// job runner loads models in WAVES so it never exceeds this — letting you run a
// panel across more models than fit in GPU memory simultaneously. 1 is safest.
maxConcurrentModels: z.number().int().positive().default(1),
// The MODEL CATALOG: the set of local models the service can serve. Each
// reviewer (below) references one of these by `alias`. Mirrors
// bm_config.py CONFIG_DEFAULTS["models"].
models: z
.array(
z.object({
alias: z.string(),
hfModel: z.string(),
// Which Spark serves this model. In `airgapped` network mode all models
// must be on the head Spark (see networkMode).
spark: z.enum(['primary', 'secondary']).default('primary'),
port: z.number().int().positive().default(8001),
}),
)
.default([
{ alias: 'reviewer-a', hfModel: 'Qwen/Qwen3-32B-FP8', spark: 'primary', port: 8001 },
]),
// --- The review panel: one entry per reviewer ("number of reviews") ---
// Each reviewer is a model + a persona (the lens it reads through) + an
// optional temperature. Mirrors bm_config.py CONFIG_DEFAULTS["reviewers"].
reviewers: z
.array(
z.object({
name: z.string(),
// Must match one of the model catalog aliases above.
model: z.string(),
persona: z.string().nullable().default(''),
temperature: z.number().nullable().default(null),
}),
)
.default([
{ name: 'reviewer-1', model: 'reviewer-a', persona: '', temperature: null },
]),
// --- Review job settings ---
// The rubric: what every reviewer should look for / produce. Layered above
// each reviewer's persona.
reviewInstructions: z.string().default(
'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.',
),
// Confidentiality posture for the reviewer containers:
// 'airgapped' — reviewers join an --internal Docker network: they can
// reach ONLY the on-Spark model proxy, with zero internet
// egress. Models must be pre-pulled into the Spark's HF
// cache (no live download). All models must be on the head
// Spark. Strongest confidentiality.
// 'local_services' — reviewers may also reach configured LAN services
// (e.g. SearXNG) and the second Spark. NOTE: this network
// has egress unless you firewall it — use only when you
// accept that reviewers can reach the network.
networkMode: z.enum(['airgapped', 'local_services']).default('airgapped'),
// SearXNG JSON endpoint, used ONLY in local_services mode to give reviewers a
// web_search tool. Empty = no web search.
searxngUrl: z.string().default(''),
// --- Synthesis (a local lead reviewer; no frontier model) ---
synthesisEnabled: z.boolean().default(true),
// Alias of the model that writes the consolidated report. Empty = first model.
synthesisModel: z.string().default(''),
// Optional persona/instructions for the lead reviewer. Empty = built-in default.
synthesisPersona: z.string().default(''),
// --- Document handling ---
// After a job, wipe the extracted document text from the Sparks. Reports are
// kept on the StartOS box regardless. Default true for confidentiality.
wipeRemoteDocs: z.boolean().default(true),
// Watch /data/inbox and auto-start a review when files land (debounced).
// Default false: you trigger reviews explicitly with "Run Review".
autoRunOnDrop: z.boolean().default(false),
// Name of the per-job Docker network created on the head Spark.
networkName: z.string().default('boardroom-net'),
// --- Auth flags (the secret itself lives in secrets.ts) ---
hfTokenSet: z.boolean().default(false),
})
export type Config = z.infer<typeof configShape>
export const configFile = FileHelper.json('./config.json', configShape)
+24
View File
@@ -0,0 +1,24 @@
import { FileHelper } from '@start9labs/start-sdk'
/**
* SSH private key used to reach the Sparks, stored as a standalone file in the
* `main` volume. Kept out of config.json so it is never returned in plaintext
* config reads. The container copies it to a 600 path at runtime.
*
* Volume path './ssh/id_spark' -> /data/ssh/id_spark inside the container.
*/
export const sshKeyFile = FileHelper.string('./ssh/id_spark')
/**
* Optional Hugging Face token (for gated/private model pulls on the Sparks).
* Passed to the vLLM serving container as HF_TOKEN at launch. In `airgapped`
* network mode models are served from a pre-pulled cache, so this is only used
* the first time you warm a model (or in local_services mode).
*
* Volume path './secrets/hf_token' -> /data/secrets/hf_token.
*
* NOTE: Boardroom Map has NO frontier/cloud key. There is deliberately no Anthropic
* key and no Gitea token — the whole point is that confidential documents and
* their reviews never leave your hardware.
*/
export const hfTokenFile = FileHelper.string('./secrets/hf_token')
+24
View File
@@ -0,0 +1,24 @@
import { buildManifest } from '@start9labs/start-sdk'
import { sdk } from './sdk'
import { versions } from './versions'
import { actions } from './actions'
import { setInterfaces } from './interfaces'
import { manifest as sdkManifest } from './manifest'
// Required ABI exports for a StartOS service package. The PUBLISHABLE manifest is
// the static manifest combined with version-graph metadata (version, release
// notes, migration ranges) — start-cli reads this `manifest` export, and it must
// include `version`, which buildManifest() supplies from the VersionGraph.
export const manifest = buildManifest(versions, sdkManifest)
export { main } from './main'
export { actions } from './actions'
// Back up the whole volume (config, ssh key, optional HF token, inbox, reports).
export const { createBackup, restoreInit } = sdk.setupBackups(async () =>
sdk.Backups.ofVolumes('main'),
)
// init composes: version migrations, action registration, interface export,
// and backup restore.
export const init = sdk.setupInit(versions, actions, setInterfaces, restoreInit)
export const uninit = sdk.setupUninit(versions)
+26
View File
@@ -0,0 +1,26 @@
import { sdk } from './sdk'
export const WEB_UI_PORT = 8080
/**
* Expose the orchestrator web UI as a StartOS interface (Tor + LAN), so the
* user can open the Boardroom Map control panel from the StartOS dashboard.
*/
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const multi = sdk.MultiHost.of(effects, 'web')
const origin = await multi.bindPort(WEB_UI_PORT, { protocol: 'http' })
const ui = sdk.createInterface(effects, {
name: 'Web UI',
id: 'webui',
description:
'The Boardroom Map control panel: drop documents in, convene the reviewer ' +
'panel, watch the job run on your Sparks, and read the reports.',
type: 'ui',
username: null,
path: '',
query: {},
schemeOverride: null,
masked: false,
})
return [await origin.export([ui])]
})
+49
View File
@@ -0,0 +1,49 @@
import { sdk } from './sdk'
import { WEB_UI_PORT } from './interfaces'
export const main = sdk.setupMain(async ({ effects }) => {
// Mount the persistent volume at /data: config.json, ssh key, optional HF
// token, the dropped-document inbox, job run state, and saved reports all live
// here.
const mounts = sdk.Mounts.of().mountVolume({
volumeId: 'main',
mountpoint: '/data',
subpath: null,
readonly: false,
})
const sub = await sdk.SubContainer.of(
effects,
{ imageId: 'main' },
mounts,
'boardroom-webui',
)
// The web UI runs the FastAPI app AND, in a background thread, the Boardroom Map job
// runner (which extracts dropped documents, serves the chosen models on the
// Sparks in waves, runs the reviewer panel, and synthesizes a report).
return sdk.Daemons.of(effects).addDaemon('webui', {
subcontainer: sub,
exec: {
command: [
'uvicorn',
'app:app',
'--host',
'0.0.0.0',
'--port',
String(WEB_UI_PORT),
],
cwd: '/app',
env: { BM_DATA_DIR: '/data' },
},
ready: {
display: 'Web Interface',
fn: () =>
sdk.healthCheck.checkPortListening(effects, WEB_UI_PORT, {
successMessage: 'The control panel is ready',
errorMessage: 'The control panel is not yet listening',
}),
},
requires: [],
})
})
+75
View File
@@ -0,0 +1,75 @@
import { setupManifest } from '@start9labs/start-sdk'
/**
* Boardroom Map manifest.
*
* Like the LLaMA-Factory and Nightshift services, this is a CONTROL PLANE — it
* does not run any GPU workload itself. It is a small web UI + job runner that
* SSHes into one or two NVIDIA DGX Sparks to:
* 1. serve a panel of local LLMs with vLLM (loaded in waves to fit GPU memory),
* 2. extract text from documents you drop in (PDF/DOCX/TXT/MD — done on the
* StartOS box), ship it to the Sparks, and launch a panel of sandboxed
* "reviewer" containers (each a model + a persona) that read the documents
* and write a report,
* 3. optionally run a local "lead reviewer" that synthesizes the panel's
* reports into one consolidated report.
*
* There is NO frontier model and NO cloud API key. In the default `airgapped`
* network mode the reviewer containers can reach ONLY the on-Spark model proxy —
* the documents and their reviews never touch the internet.
*
* NOTE: s9pk.mk extracts the package identifier from the single-quoted value on
* the line below, so keep that field on one line and avoid stray quotes above it.
*/
export const manifest = setupManifest({
id: 'boardroom-map',
title: 'Boardroom Map',
license: 'Apache-2.0',
packageRepo: 'https://github.com/ten31/boardroom-map',
upstreamRepo: 'https://github.com/ten31/boardroom-map',
marketingUrl: 'https://github.com/ten31/boardroom-map',
donationUrl: null,
description: {
short: 'A private panel of local LLMs that reviews your confidential documents on your DGX Sparks',
long:
'Boardroom Map lets you drop confidential documents in and convene a panel of ' +
'local LLMs running on your NVIDIA DGX Sparks to review them. You choose ' +
'which models and which personas (lenses) sit on the panel and how many ' +
'reviews to run. Each reviewer reads the documents and writes a report; an ' +
'optional local lead reviewer synthesizes them into one consolidated ' +
'report. There is no frontier model and no cloud key: in the default ' +
'air-gapped mode the reviewers reach only the on-Spark model endpoint, so ' +
'your documents and their reviews never leave your hardware. No GPU is ' +
'needed on the StartOS host.',
},
// Arch-agnostic orchestrator. Docker build paths are relative to the PROJECT
// ROOT (where the Makefile runs), matching the Start9 convention.
images: {
main: {
source: {
dockerBuild: {
dockerfile: './orchestrator.Dockerfile',
workdir: '.',
},
},
arch: ['x86_64', 'aarch64'],
// The orchestrator only SSHes out + extracts document text on CPU; it never
// touches a local GPU.
nvidiaContainer: false,
},
},
volumes: ['main'],
dependencies: {},
hardwareRequirements: {
ram: 2048,
},
alerts: {
install:
'Boardroom Map drives work on REMOTE machines (your DGX Sparks) over SSH; ' +
'nothing serves or runs on your StartOS server. After install: ' +
'(1) "Configure Sparks" for SSH access, (2) "Configure Models" for the ' +
'local models to serve, (3) "Configure Reviewers" for the panel + personas, ' +
'(4) "Configure Review" for the rubric and air-gap mode. Then drop ' +
'documents in the inbox and run "Run Review".',
},
})
+8
View File
@@ -0,0 +1,8 @@
import { StartSdk } from '@start9labs/start-sdk'
import { manifest } from './manifest'
/**
* The bound SDK facade. Import `sdk` everywhere else to reach actions, daemons,
* interfaces, health checks, file helpers, and the input-form builders.
*/
export const sdk = StartSdk.of().withManifest(manifest).build(true)
+8
View File
@@ -0,0 +1,8 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { v_0_1_0 } from './v_0_1_0'
/** The current version MUST be the first argument (`current`). */
export const versions = VersionGraph.of({
current: v_0_1_0,
other: [],
})
+16
View File
@@ -0,0 +1,16 @@
import { VersionInfo } from '@start9labs/start-sdk'
/**
* Initial release. ExVer form `<upstream>:<downstream>` — we track our own
* packaging revision since Boardroom Map has no separate upstream semver.
*/
export const v_0_1_0 = VersionInfo.of({
version: '0.1.0:0',
releaseNotes:
'Initial release: drop confidential documents in and convene a panel of ' +
'local LLMs on your DGX Sparks to review them. Choose the models, the ' +
'personas, and how many reviews; an optional local lead reviewer ' +
'synthesizes a consolidated report. Default air-gapped mode keeps documents ' +
'and reviews entirely on your hardware.',
migrations: {},
})
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["startos/**/*.ts"]
}