From 1dde915540206e15bb86b4163fa0256f7b1473ef Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Mon, 6 Jul 2026 13:10:25 -0500 Subject: [PATCH] Scaffold: fork of Chambers architecture, renamed to Boardroom Map Co-Authored-By: Claude Fable 5 --- .dockerignore | 8 + .github/workflows/build.yml | 64 +++++ .github/workflows/release.yml | 19 ++ .gitignore | 6 + LICENSE | 13 + Makefile | 6 + README.md | 78 ++++++ assets/README.md | 2 + assets/instructions.md | 47 ++++ icon.svg | 18 ++ instructions.md | 47 ++++ orchestrator.Dockerfile | 26 ++ orchestrator/adjudicator.py | 88 +++++++ orchestrator/app.py | 194 ++++++++++++++ orchestrator/bm_config.py | 78 ++++++ orchestrator/extraction.py | 115 ++++++++ orchestrator/graders.py | 162 ++++++++++++ orchestrator/jobs.py | 379 +++++++++++++++++++++++++++ orchestrator/preflight.py | 92 +++++++ orchestrator/requirements.txt | 7 + orchestrator/serving.py | 211 +++++++++++++++ orchestrator/spark_client.py | 163 ++++++++++++ orchestrator/templates/index.html | 155 +++++++++++ package-lock.json | 347 ++++++++++++++++++++++++ package.json | 20 ++ s9pk.mk | 138 ++++++++++ sandbox/README.md | 33 +++ sandbox/build.sh | 17 ++ sandbox/grader.Dockerfile | 28 ++ sandbox/grader_agent.py | 339 ++++++++++++++++++++++++ startos/actions/configure-graders.ts | 113 ++++++++ startos/actions/configure-grading.ts | 132 ++++++++++ startos/actions/configure-models.ts | 159 +++++++++++ startos/actions/configure-sparks.ts | 161 ++++++++++++ startos/actions/grade-decks.ts | 59 +++++ startos/actions/index.ts | 17 ++ startos/actions/latest-scorecard.ts | 51 ++++ startos/actions/test-connection.ts | 55 ++++ startos/file-models/config.ts | 132 ++++++++++ startos/file-models/secrets.ts | 24 ++ startos/index.ts | 24 ++ startos/interfaces.ts | 26 ++ startos/main.ts | 49 ++++ startos/manifest/index.ts | 75 ++++++ startos/sdk.ts | 8 + startos/versions/index.ts | 8 + startos/versions/v_0_1_0.ts | 16 ++ tsconfig.json | 16 ++ 48 files changed, 4025 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 assets/README.md create mode 100644 assets/instructions.md create mode 100644 icon.svg create mode 100644 instructions.md create mode 100644 orchestrator.Dockerfile create mode 100644 orchestrator/adjudicator.py create mode 100644 orchestrator/app.py create mode 100644 orchestrator/bm_config.py create mode 100644 orchestrator/extraction.py create mode 100644 orchestrator/graders.py create mode 100644 orchestrator/jobs.py create mode 100644 orchestrator/preflight.py create mode 100644 orchestrator/requirements.txt create mode 100644 orchestrator/serving.py create mode 100644 orchestrator/spark_client.py create mode 100644 orchestrator/templates/index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 s9pk.mk create mode 100644 sandbox/README.md create mode 100644 sandbox/build.sh create mode 100644 sandbox/grader.Dockerfile create mode 100644 sandbox/grader_agent.py create mode 100644 startos/actions/configure-graders.ts create mode 100644 startos/actions/configure-grading.ts create mode 100644 startos/actions/configure-models.ts create mode 100644 startos/actions/configure-sparks.ts create mode 100644 startos/actions/grade-decks.ts create mode 100644 startos/actions/index.ts create mode 100644 startos/actions/latest-scorecard.ts create mode 100644 startos/actions/test-connection.ts create mode 100644 startos/file-models/config.ts create mode 100644 startos/file-models/secrets.ts create mode 100644 startos/index.ts create mode 100644 startos/interfaces.ts create mode 100644 startos/main.ts create mode 100644 startos/manifest/index.ts create mode 100644 startos/sdk.ts create mode 100644 startos/versions/index.ts create mode 100644 startos/versions/v_0_1_0.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..968b5ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +javascript +.git +*.s9pk +startos +.github +**/__pycache__ +*.pyc diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..fffaaa3 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..75c6036 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3428f15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +javascript/ +*.s9pk +.DS_Store +**/__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9e1f32 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f9bb659 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2772c1a --- /dev/null +++ b/README.md @@ -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). diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..7b4640e --- /dev/null +++ b/assets/README.md @@ -0,0 +1,2 @@ +# Static assets bundled into the s9pk. +# Nightshift has none yet; this keeps the default `assets/` ingredient present. diff --git a/assets/instructions.md b/assets/instructions.md new file mode 100644 index 0000000..2250354 --- /dev/null +++ b/assets/instructions.md @@ -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). diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..bb7eb35 --- /dev/null +++ b/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..2250354 --- /dev/null +++ b/instructions.md @@ -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). diff --git a/orchestrator.Dockerfile b/orchestrator.Dockerfile new file mode 100644 index 0000000..542ebd2 --- /dev/null +++ b/orchestrator.Dockerfile @@ -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"] diff --git a/orchestrator/adjudicator.py b/orchestrator/adjudicator.py new file mode 100644 index 0000000..2f03f1c --- /dev/null +++ b/orchestrator/adjudicator.py @@ -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} diff --git a/orchestrator/app.py b/orchestrator/app.py new file mode 100644 index 0000000..65f26a4 --- /dev/null +++ b/orchestrator/app.py @@ -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() diff --git a/orchestrator/bm_config.py b/orchestrator/bm_config.py new file mode 100644 index 0000000..2091b7c --- /dev/null +++ b/orchestrator/bm_config.py @@ -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 diff --git a/orchestrator/extraction.py b/orchestrator/extraction.py new file mode 100644 index 0000000..40b8931 --- /dev/null +++ b/orchestrator/extraction.py @@ -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 diff --git a/orchestrator/graders.py b/orchestrator/graders.py new file mode 100644 index 0000000..f717fa5 --- /dev/null +++ b/orchestrator/graders.py @@ -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/.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 /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 diff --git a/orchestrator/jobs.py b/orchestrator/jobs.py new file mode 100644 index 0000000..a60ddfa --- /dev/null +++ b/orchestrator/jobs.py @@ -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/, 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() diff --git a/orchestrator/preflight.py b/orchestrator/preflight.py new file mode 100644 index 0000000..53e91ac --- /dev/null +++ b/orchestrator/preflight.py @@ -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") diff --git a/orchestrator/requirements.txt b/orchestrator/requirements.txt new file mode 100644 index 0000000..84524a6 --- /dev/null +++ b/orchestrator/requirements.txt @@ -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 diff --git a/orchestrator/serving.py b/orchestrator/serving.py new file mode 100644 index 0000000..6e379c1 --- /dev/null +++ b/orchestrator/serving.py @@ -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-). 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()} diff --git a/orchestrator/spark_client.py b/orchestrator/spark_client.py new file mode 100644 index 0000000..57198de --- /dev/null +++ b/orchestrator/spark_client.py @@ -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) diff --git a/orchestrator/templates/index.html b/orchestrator/templates/index.html new file mode 100644 index 0000000..c2fc48f --- /dev/null +++ b/orchestrator/templates/index.html @@ -0,0 +1,155 @@ + + + + + + Boardroom Map + + + +
+
§
+

BOARDROOM MAP

+ idle + +
+ +
+
+

Documents

+
Drop confidential documents here, or click to choose
+ PDF · DOCX · TXT · MD
+ +
+
+ + + +
+
+ +
+

Setup

+
+
+ +
+

Panel

+
No reviewers configured.
+
+ +
+

Serving / Job

+
+
+ + +
+
+ +
+

Activity log

+
loading…
+
+ +
+

Latest report

+
+
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2b0fcd8 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7003a9f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..675ebc9 --- /dev/null +++ b/s9pk.mk @@ -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
/.git/worktrees/ 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 diff --git a/sandbox/README.md b/sandbox/README.md new file mode 100644 index 0000000..0664292 --- /dev/null +++ b/sandbox/README.md @@ -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- \ + --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= -e BM_LLM_BASE=http://boardroom-proxy:4000/v1 ... \ + -v /docs:/docs:ro -v /out:/out \ + -v /personas/.md:/persona/PERSONA.md:ro -v /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. diff --git a/sandbox/build.sh b/sandbox/build.sh new file mode 100644 index 0000000..a9dd77d --- /dev/null +++ b/sandbox/build.sh @@ -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" diff --git a/sandbox/grader.Dockerfile b/sandbox/grader.Dockerfile new file mode 100644 index 0000000..3522874 --- /dev/null +++ b/sandbox/grader.Dockerfile @@ -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"] diff --git a/sandbox/grader_agent.py b/sandbox/grader_agent.py new file mode 100644 index 0000000..5aab5bd --- /dev/null +++ b/sandbox/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/.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() diff --git a/startos/actions/configure-graders.ts b/startos/actions/configure-graders.ts new file mode 100644 index 0000000..a7d097c --- /dev/null +++ b/startos/actions/configure-graders.ts @@ -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, + }, + } + }, +) diff --git a/startos/actions/configure-grading.ts b/startos/actions/configure-grading.ts new file mode 100644 index 0000000..1e78b26 --- /dev/null +++ b/startos/actions/configure-grading.ts @@ -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 }, + } + }, +) diff --git a/startos/actions/configure-models.ts b/startos/actions/configure-models.ts new file mode 100644 index 0000000..f80cfb4 --- /dev/null +++ b/startos/actions/configure-models.ts @@ -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, + }, + } + }, +) diff --git a/startos/actions/configure-sparks.ts b/startos/actions/configure-sparks.ts new file mode 100644 index 0000000..4808f1d --- /dev/null +++ b/startos/actions/configure-sparks.ts @@ -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 }, + } + }, +) diff --git a/startos/actions/grade-decks.ts b/startos/actions/grade-decks.ts new file mode 100644 index 0000000..de89e55 --- /dev/null +++ b/startos/actions/grade-decks.ts @@ -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( + 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 }, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..ef0ac59 --- /dev/null +++ b/startos/actions/index.ts @@ -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) diff --git a/startos/actions/latest-scorecard.ts b/startos/actions/latest-scorecard.ts new file mode 100644 index 0000000..3958bd8 --- /dev/null +++ b/startos/actions/latest-scorecard.ts @@ -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( + 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 }, + } + }, +) diff --git a/startos/actions/test-connection.ts b/startos/actions/test-connection.ts new file mode 100644 index 0000000..213d962 --- /dev/null +++ b/startos/actions/test-connection.ts @@ -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( + 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 }, + } + }, +) diff --git a/startos/file-models/config.ts b/startos/file-models/config.ts new file mode 100644 index 0000000..263dd09 --- /dev/null +++ b/startos/file-models/config.ts @@ -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 + +export const configFile = FileHelper.json('./config.json', configShape) diff --git a/startos/file-models/secrets.ts b/startos/file-models/secrets.ts new file mode 100644 index 0000000..8d0d482 --- /dev/null +++ b/startos/file-models/secrets.ts @@ -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') diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..e060380 --- /dev/null +++ b/startos/index.ts @@ -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) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..674cba5 --- /dev/null +++ b/startos/interfaces.ts @@ -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])] +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..b78cc26 --- /dev/null +++ b/startos/main.ts @@ -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: [], + }) +}) diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..5d9f9c6 --- /dev/null +++ b/startos/manifest/index.ts @@ -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".', + }, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..bb6d6e7 --- /dev/null +++ b/startos/sdk.ts @@ -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) diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..ed8db91 --- /dev/null +++ b/startos/versions/index.ts @@ -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: [], +}) diff --git a/startos/versions/v_0_1_0.ts b/startos/versions/v_0_1_0.ts new file mode 100644 index 0000000..602767e --- /dev/null +++ b/startos/versions/v_0_1_0.ts @@ -0,0 +1,16 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +/** + * Initial release. ExVer form `:` — 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: {}, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..86ea522 --- /dev/null +++ b/tsconfig.json @@ -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"] +}