Scaffold: fork of Chambers architecture, renamed to Boardroom Map
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
javascript
|
||||
.git
|
||||
*.s9pk
|
||||
startos
|
||||
.github
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
javascript/
|
||||
*.s9pk
|
||||
.DS_Store
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -0,0 +1,2 @@
|
||||
# Static assets bundled into the s9pk.
|
||||
# Nightshift has none yet; this keeps the default `assets/` ingredient present.
|
||||
@@ -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).
|
||||
@@ -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 |
@@ -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).
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
Generated
+347
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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 (0–1). 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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])]
|
||||
})
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
@@ -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".',
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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: [],
|
||||
})
|
||||
@@ -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: {},
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user