From 83aefdabec7ec237b2b7ef324fcfaab234e4bed2 Mon Sep 17 00:00:00 2001 From: Guillaume Date: Wed, 26 Aug 2026 12:20:40 +0200 Subject: [PATCH 1/6] Feat/vendored opencode binary (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): vendored OpenCode binary + auto-install/init and container-tunnel support --------------- - opencode_binary.py: Node-free on-demand provisioning of the OpenCode standalone binary (npm-registry tarball via stdlib), managed per-user cache - resolver prefers managed binary; background auto-install on import/start - `weightslab agent init` CLI; agent-config gating with an info hint (no implicit sign-in) - configurable OpenCode bind host + UI trusted-hosts allowlist so the agent works through a container's published port / SSH tunnel - unit tests + CI agent-smoke job Co-Authored-By: Claude Opus 4.8 (1M context) * docs(agent): Getting Started via `weightslab agent init` + OpenCode env var reference - agent_quickstart: lead with `weightslab agent init` (provisions the Node-free OpenCode binary, then signs in); document --provision-only and the "agent not initialized" info behavior so a new user knows exactly what to do. - configuration: document the OpenCode provisioning/bind env vars — WEIGHTSLAB_OPENCODE_HOST, WEIGHTSLAB_UI_TRUSTED_HOSTS, WEIGHTSLAB_OPENCODE_AUTOINSTALL/AUTODOWNLOAD/VERSION/HOME — incl. the container-behind-a-tunnel setup. --- .github/workflows/ci.yml | 60 +++ docs/agent_quickstart.rst | 32 +- docs/configuration.rst | 63 +++ scripts/ci/agent_smoke.py | 297 ++++++++++++++ tests/test_agent_cli.py | 77 ++++ tests/test_agent_networking.py | 70 ++++ tests/test_opencode_binary.py | 259 ++++++++++++ weightslab/__init__.py | 24 ++ weightslab/cli.py | 148 ++++++- .../PyTorch/wl-classification/main.py | 6 +- weightslab/opencode_binary.py | 372 ++++++++++++++++++ weightslab/opencode_process.py | 55 ++- weightslab/ui/server.py | 93 +++-- 13 files changed, 1510 insertions(+), 46 deletions(-) create mode 100644 scripts/ci/agent_smoke.py create mode 100644 tests/test_agent_cli.py create mode 100644 tests/test_agent_networking.py create mode 100644 tests/test_opencode_binary.py create mode 100644 weightslab/opencode_binary.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 454657cb..8526450b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,6 +224,66 @@ jobs: # A per-test timeout guards against any regression that hangs a test. python -m pytest ./tests -v --timeout=600 + # ── Agent smoke test on a pip-installed package ─────────────────────────── + # Proves the Option-2 promise end-to-end: install weightslab into a CLEAN + # virtualenv (from the built wheel, not editable) and confirm the OpenCode + # agent works with NO manual `npm i -g opencode` / `npx` step -- + # 1. the managed OpenCode binary provisions and runs (`--version`), + # 2. `weightslab start` (UI) brings the agent server up even with no + # credential configured (the user can configure it afterwards), and + # 3. `weightslab start example` boots without ever hitting the + # "no opencode/npx" path. + agent-smoke: + needs: [ gate, install ] + if: ${{ needs.gate.outputs.run_ci == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + name: agent smoke (pip install) + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Create clean virtual environment and install from wheel + run: | + python -m venv .venv-smoke + . .venv-smoke/bin/activate + python -m pip install --upgrade pip build + # Build a wheel and install THAT (a real "pip install the package", + # not an editable checkout) so package-data / entry points are exercised + # exactly as an end user would get them. + python -m build --wheel + python -m pip install dist/*.whl --extra-index-url https://download.pytorch.org/whl/cpu + + - name: Agent smoke — provision opencode + weightslab start + env: + WEIGHTSLAB_LOG_LEVEL: INFO + # Force the managed provisioning path (do not depend on the runner's + # preinstalled Node): the standalone binary must run on its own. + WEIGHTSLAB_OPENCODE_AUTODOWNLOAD: '1' + run: | + . .venv-smoke/bin/activate + python scripts/ci/agent_smoke.py start + + - name: Agent smoke — weightslab agent init (CLI, headless) + env: + WEIGHTSLAB_LOG_LEVEL: INFO + WEIGHTSLAB_OPENCODE_AUTODOWNLOAD: '1' + run: | + . .venv-smoke/bin/activate + python scripts/ci/agent_smoke.py cli-init + + - name: Agent smoke — weightslab start example (agent optional, no error) + env: + WEIGHTSLAB_LOG_LEVEL: INFO + run: | + . .venv-smoke/bin/activate + python scripts/ci/agent_smoke.py example + build-and-publish-dev: # Only publish to TestPyPI when pushing to main (not on PRs or dev branch pushes). if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} diff --git a/docs/agent_quickstart.rst b/docs/agent_quickstart.rst index 50e6da5d..1a243e2c 100644 --- a/docs/agent_quickstart.rst +++ b/docs/agent_quickstart.rst @@ -19,25 +19,39 @@ server. This page is the fastest path from "just installed WeightsLab" to What you need -------------- -- WeightsLab installed (``pip install weightslab``) — this brings the - ``opencode-ai`` bundled binary with it, so there is nothing extra to - install for the agent itself. +- WeightsLab installed (``pip install weightslab``). That's the only install + step: WeightsLab provisions the OpenCode binary itself, on first use, into a + per-user cache — **no Node.js and no manual ``npm``/``opencode`` install + required**. - One set of credentials for a model provider: an OpenRouter API key, an Anthropic key, or a local Ollama install. Pick whichever you already have. -Step 1 — authenticate OpenCode once +Step 1 — initialize the agent once ------------------------------------ The agent's provider and credentials live entirely inside OpenCode, never in -WeightsLab itself. Do this once per machine: +WeightsLab itself. The one-liner below provisions the OpenCode binary (if it +isn't already) and then signs you in — do this once per machine: .. code-block:: bash - opencode auth login + weightslab agent init -Follow the prompts to sign in to OpenRouter, Anthropic, or point it at a -local Ollama endpoint. You can also do this later from the browser, using the -login modal on the Weights Studio landing page — no terminal required. +Follow the prompts to sign in to OpenRouter, Anthropic, or point it at a local +Ollama endpoint. Equivalent alternatives: + +- ``opencode auth login`` — if you prefer to drive OpenCode directly (WeightsLab + installs the binary either way). +- The login modal on the Weights Studio landing page — no terminal required. +- ``weightslab agent init --provision-only`` — headless/CI: just install the + binary, skip the interactive sign-in. + +.. note:: + + You can skip this step and start straight away — if no credential is found, + WeightsLab logs an *info* line ("OpenCode is installed, but the agent is not + initialized yet — run ``weightslab agent init``") and keeps running. The + assistant is optional; nothing else is blocked. Step 2 — start an experiment ------------------------------ diff --git a/docs/configuration.rst b/docs/configuration.rst index 32500c80..15f5d3f2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -815,6 +815,22 @@ server and the backend SDK agent share. These control where it lives. one. Takes precedence over everything else, and configures **both** the UI server and the SDK agent — set it once and the two converge on a single process. + * - ``WEIGHTSLAB_OPENCODE_HOST`` + - ``127.0.0.1`` + - Host the spawned agent server **binds** to. Loopback by default (the + server has filesystem access and must not be reachable off-machine on a + normal local run). Set to ``0.0.0.0`` when running in a container reached + over an SSH tunnel / published port, so the published port can reach it — + the URL handed to the browser stays ``127.0.0.1`` either way. See + :ref:`studio-bridging`. + * - ``WEIGHTSLAB_UI_TRUSTED_HOSTS`` + - *(unset)* + - Comma-separated extra source IPs/CIDRs allowed to call the UI server's + local-only control routes (start agent, notebook, loops). Loopback is + always trusted; behind a tunnel + published port the browser's request + arrives from the container gateway, so set e.g. + ``172.16.0.0/12,192.168.0.0/16`` there (the real trust boundary being the + tunnel + host publishing to ``127.0.0.1``). .. note:: @@ -823,6 +839,53 @@ server and the backend SDK agent share. These control where it lives. browser, this port has to be reachable from the browser's side — see :ref:`studio-bridging`. +Agent installation (OpenCode binary) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +WeightsLab provisions the OpenCode standalone binary itself — no Node.js +required — the first time it is needed (on ``import weightslab``, +``weightslab start``, ``weightslab start example``, or the first agent use). +It is fetched once into a per-user cache and reused. These control that. + +.. list-table:: + :header-rows: 1 + :widths: 35 15 50 + + * - Variable + - Default + - Description + * - ``WEIGHTSLAB_OPENCODE_AUTOINSTALL`` + - ``1`` + - Auto-install OpenCode in the background (logged) on ``import weightslab`` + / ``weightslab start`` when it isn't already present. Set to ``0`` to + disable the on-import/start install (e.g. air-gapped or CI hosts). + * - ``WEIGHTSLAB_OPENCODE_AUTODOWNLOAD`` + - ``1`` + - Master switch for the network fetch. ``0`` forbids all on-demand + downloads — an already-provisioned binary is still used, but nothing new + is fetched (stricter than ``AUTOINSTALL``, which only gates the + import/start pre-warm). + * - ``WEIGHTSLAB_OPENCODE_VERSION`` + - *(pinned)* + - Override the OpenCode version WeightsLab provisions. Each release pins a + known-good version; set this only to track a different one. + * - ``WEIGHTSLAB_OPENCODE_HOME`` + - *(per-user cache)* + - Directory the managed binary is installed under. Defaults to the + platform cache (``~/.cache/weightslab/opencode`` on Linux, + ``%LOCALAPPDATA%\\weightslab\\opencode`` on Windows, + ``~/Library/Caches/weightslab/opencode`` on macOS). + +.. tip:: + + Sign in once with ``weightslab agent init`` (provisions the binary, then runs + ``opencode auth login``); ``weightslab agent init --provision-only`` just + installs the binary without the interactive sign-in, for headless/CI use. + To uninstall, delete ``$WEIGHTSLAB_OPENCODE_HOME`` (default per-user cache + above) and set ``WEIGHTSLAB_OPENCODE_AUTOINSTALL=0`` (and, to also block the + on-demand fetch, ``WEIGHTSLAB_OPENCODE_AUTODOWNLOAD=0``) so it isn't + re-installed. + Agent Provider Setup ~~~~~~~~~~~~~~~~~~~~ diff --git a/scripts/ci/agent_smoke.py b/scripts/ci/agent_smoke.py new file mode 100644 index 00000000..1dc4afcb --- /dev/null +++ b/scripts/ci/agent_smoke.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""End-to-end agent smoke test for a *pip-installed* weightslab. + +Run against a clean environment where weightslab was installed from a wheel and +Node.js is deliberately absent. It proves the Option-2 promise: after +``pip install weightslab`` the OpenCode agent works with no manual install. + +Modes (argv[1]): + provision Provision the managed OpenCode binary and run `--version`. + This is "initializing opencode" with no Node/npx on the box. + start `weightslab start` (the UI): boot it headless, then drive + POST /agent-server/start + GET /agent-server/status. Asserts the + agent server comes up WITHOUT any credential configured -- i.e. an + unconfigured user still gets a running agent they can then configure + (opencode auth login / the landing login modal), rather than a hard + failure. ("allow user to configure agent if not already done") + example `weightslab start example`: boot the bundled training example and + assert it starts cleanly and never hits the "no opencode/npx" path. + all provision, then start, then example. + +Exit code is non-zero on the first failure, with a clear reason. +""" + +import json +import os +import signal +import socket +import subprocess +import sys +import threading +import time +import urllib.request +from pathlib import Path + +OPENCODE_MISSING_MARKER = "Could not provision OpenCode" +NOT_CONFIGURED_MARKER = "not initialized" +INSTALL_MARKERS = ("installing now", "OpenCode installed", "OpenCode ready") +STARTUP_BUDGET = 90.0 # UI / agent readiness +EXAMPLE_MIN_UPTIME = 60.0 # example must survive this long past import +EXAMPLE_BUDGET = 300.0 + + +def log(msg: str) -> None: + print(f"[agent-smoke] {msg}", flush=True) + + +def fail(msg: str) -> "NoReturn": # type: ignore[valid-type] + log(f"FAIL: {msg}") + sys.exit(1) + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def http_get(url: str, timeout: float = 3.0): + req = urllib.request.Request(url, headers={"Origin": "http://127.0.0.1"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read() + + +def http_post(url: str, timeout: float = 60.0): + req = urllib.request.Request( + url, data=b"{}", method="POST", + headers={"Content-Type": "application/json", "Origin": "http://127.0.0.1"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read() + + +def poll_until(fn, budget: float, what: str): + deadline = time.monotonic() + budget + last = None + while time.monotonic() < deadline: + try: + if fn(): + return True + except Exception as exc: # not up yet + last = exc + time.sleep(1.0) + log(f"timed out waiting for {what} ({last})") + return False + + +class Proc: + """A weightslab subprocess with combined-output capture and tree kill.""" + + def __init__(self, args, env=None): + self.args = args + self.lines = [] + self._proc = subprocess.Popen( + args, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + env=env or os.environ.copy(), + start_new_session=True, # own process group so we can kill the tree + ) + threading.Thread(target=self._drain, daemon=True).start() + + def _drain(self): + for line in self._proc.stdout: + self.lines.append(line.rstrip("\n")) + print(f" | {line.rstrip()}", flush=True) + + @property + def output(self) -> str: + return "\n".join(self.lines) + + def alive(self) -> bool: + return self._proc.poll() is None + + def returncode(self): + return self._proc.poll() + + def stop(self): + if self._proc.poll() is not None: + return + try: + os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM) + except Exception: + self._proc.terminate() + try: + self._proc.wait(timeout=15) + except Exception: + try: + os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL) + except Exception: + self._proc.kill() + + +def mode_provision() -> None: + log("provisioning managed OpenCode binary (no Node.js expected on PATH)...") + from weightslab import opencode_binary + + if _which("npx") or _which("node"): + log("note: Node is present; the managed path is still exercised explicitly") + + path = opencode_binary.ensure_managed_binary() + if not path: + fail("ensure_managed_binary() returned None -- provisioning failed") + log(f"managed binary: {path}") + + out = subprocess.run([str(path), "--version"], capture_output=True, text=True, timeout=60) + if out.returncode != 0: + fail(f"`opencode --version` failed (rc={out.returncode}): {out.stderr.strip()}") + log(f"opencode --version -> {out.stdout.strip() or out.stderr.strip()}") + + # And confirm the resolver actually selects it. + from weightslab import opencode_process + argv = opencode_process.resolve_opencode_argv() + if not argv or Path(argv[0]) != Path(path): + fail(f"resolver did not select the managed binary: {argv}") + log("resolver selects the managed binary. provision OK") + + +def _which(name: str): + from shutil import which + return which(name) + + +def mode_start() -> None: + port = free_port() + workspace = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / f"wl-smoke-start-{port}" + workspace.mkdir(parents=True, exist_ok=True) + log(f"launching `weightslab start` on port {port} (workspace {workspace})...") + + proc = Proc([ + "weightslab", "start", str(workspace), + "--no-browser", "--host", "127.0.0.1", "--port", str(port), + ]) + try: + base = f"http://127.0.0.1:{port}" + # /agent-server/status always answers 200 JSON once the HTTP server is + # up, independent of whether the bundled SPA assets are present -- a more + # robust readiness probe than "/" (which 404s on an assets-less build). + if not poll_until(lambda: http_get(base + "/agent-server/status")[0] == 200, + STARTUP_BUDGET, "UI server"): + fail(f"UI did not serve on {base}\n---\n{proc.output}") + log("UI is serving") + + # No credential is configured in CI. The agent server must still come up + # -- the user configures the model/login afterwards. That is the whole + # "configure agent if not already done" guarantee. + status, body = http_post(base + "/agent-server/start", timeout=STARTUP_BUDGET) + payload = json.loads(body or b"{}") + if OPENCODE_MISSING_MARKER in proc.output: + fail("agent start hit the no-opencode path despite a pip install") + if not payload.get("ok"): + fail(f"/agent-server/start not ok: {payload}") + if not payload.get("url"): + fail(f"/agent-server/start returned no url: {payload}") + log(f"agent server up (unconfigured) at {payload['url']}") + + # Status endpoint should now report the running agent. + s_status, s_body = http_get(base + "/agent-server/status") + log(f"/agent-server/status -> {s_status} {s_body[:200]!r}") + log("start mode OK") + finally: + proc.stop() + + +def mode_example() -> None: + """`weightslab start example` is pure training: the agent is lazy and + optional. With NO agent configured it must (c) boot cleanly, (c) never hit + the no-opencode path, and just log an info hint -- no init, no error. We + deliberately do NOT provision opencode here (that would be an init the user + never asked for).""" + workspace = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "wl-smoke-example" + workspace.mkdir(parents=True, exist_ok=True) + log("launching `weightslab start example` with NO agent configured...") + + # Force the unconfigured state so the info-hint path is what we test. + env = {**os.environ, "WEIGHTSLAB_SUPPRESS_BANNER": "1"} + env.pop("OPENCODE_URL", None) + proc = Proc(["weightslab", "start", "example"], env=env) + try: + start = time.monotonic() + while time.monotonic() - start < EXAMPLE_BUDGET: + if OPENCODE_MISSING_MARKER in proc.output: + fail("example surfaced an opencode error despite the agent being optional") + rc = proc.returncode() + if rc is not None: + if rc == 0: + log("example exited 0 during boot window") + break + fail(f"example exited early with rc={rc}\n---\n{proc.output}") + if time.monotonic() - start >= EXAMPLE_MIN_UPTIME: + log(f"example stayed up {int(EXAMPLE_MIN_UPTIME)}s with no opencode error") + break + time.sleep(2.0) + # Soft checks (may land slightly after boot): the "no init, just info" + # sign-in hint, and the background install being logged. + if NOT_CONFIGURED_MARKER in proc.output: + log("info hint present: user told how to `weightslab agent init`") + else: + log("note: agent-config info hint not observed in captured output") + if any(m in proc.output for m in INSTALL_MARKERS): + log("opencode install was logged during the example run") + else: + log("note: opencode install log not observed (may finish after window)") + log("example mode OK") + finally: + proc.stop() + + +def mode_cli_init() -> None: + """(b) The user can initialize the agent from the CLI. Exercise the + headless path: `weightslab agent init --provision-only` must provision a + working opencode with no Node and no interactive prompt.""" + log("running `weightslab agent init --provision-only`...") + out = subprocess.run( + ["weightslab", "agent", "init", "--provision-only"], + capture_output=True, text=True, timeout=300, + ) + combined = (out.stdout or "") + (out.stderr or "") + print(combined, flush=True) + if out.returncode != 0: + fail(f"`weightslab agent init --provision-only` exited {out.returncode}") + if "OpenCode ready" not in combined: + fail("agent init did not report a provisioned OpenCode binary") + + from weightslab import opencode_binary + path = opencode_binary.find_managed_binary() + if not path: + fail("agent init reported success but no managed binary is present") + ver = subprocess.run([str(path), "--version"], capture_output=True, text=True, timeout=60) + if ver.returncode != 0: + fail(f"provisioned opencode failed `--version` (rc={ver.returncode})") + log(f"cli init OK — opencode {ver.stdout.strip() or ver.stderr.strip()} at {path}") + + +def main() -> None: + mode = sys.argv[1] if len(sys.argv) > 1 else "all" + if mode == "provision": + mode_provision() + elif mode == "start": + mode_provision() + mode_start() + elif mode == "example": + # No provisioning: the example must be clean and agent-free on its own. + mode_example() + elif mode == "cli-init": + mode_cli_init() + elif mode == "all": + mode_provision() + mode_start() + mode_cli_init() + mode_example() + else: + fail(f"unknown mode {mode!r}") + log(f"mode {mode!r}: PASS") + + +if __name__ == "__main__": + main() diff --git a/tests/test_agent_cli.py b/tests/test_agent_cli.py new file mode 100644 index 00000000..7d1f8e91 --- /dev/null +++ b/tests/test_agent_cli.py @@ -0,0 +1,77 @@ +"""Tests for the `weightslab agent` CLI surface and the agent-config gating that +keeps an unconfigured run agent-free and error-free (info hint only).""" + +import os +import unittest +from pathlib import Path +from unittest.mock import patch + +from weightslab import cli + + +class AgentConfiguredTests(unittest.TestCase): + def test_not_configured_when_no_url_no_env_no_auth(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENCODE_URL", None) + with patch.object(cli, "_opencode_auth_paths", + return_value=[Path("/nonexistent/auth.json")]), \ + patch.object(cli, "_agent_env_files", + return_value=[Path("/nonexistent/.env")]): + self.assertFalse(cli.agent_is_configured()) + + def test_configured_when_opencode_url_set(self): + with patch.dict(os.environ, {"OPENCODE_URL": "http://127.0.0.1:4096"}, clear=False): + self.assertTrue(cli.agent_is_configured()) + + def test_configured_when_env_file_present(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENCODE_URL", None) + with patch.object(cli, "_agent_env_files") as envs, \ + patch.object(cli, "_opencode_auth_paths", return_value=[]), \ + patch.object(Path, "is_file", return_value=True): + envs.return_value = [Path("/proj/.env")] + self.assertTrue(cli.agent_is_configured()) + + def test_configured_when_auth_file_present(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENCODE_URL", None) + with patch.object(cli, "_agent_env_files", return_value=[Path("/nonexistent/.env")]), \ + patch.object(cli, "_opencode_auth_paths") as paths, \ + patch.object(Path, "is_file", return_value=True): + paths.return_value = [Path("/whatever/auth.json")] + self.assertTrue(cli.agent_is_configured()) + + +class PrewarmGateTests(unittest.TestCase): + def test_install_always_no_hint_when_configured(self): + # Installing the binary is unconditional; only the sign-in hint is gated. + with patch.object(cli, "agent_is_configured", return_value=True), \ + patch.object(cli, "_prewarm_opencode") as prewarm, \ + patch.object(cli, "_log_agent_config_hint") as hint: + cli._prewarm_opencode_or_hint() + prewarm.assert_called_once() + hint.assert_not_called() + + def test_install_and_hint_when_not_configured(self): + with patch.object(cli, "agent_is_configured", return_value=False), \ + patch.object(cli, "_prewarm_opencode") as prewarm, \ + patch.object(cli, "_log_agent_config_hint") as hint: + cli._prewarm_opencode_or_hint() + prewarm.assert_called_once() + hint.assert_called_once() + + +class AgentParserTests(unittest.TestCase): + def test_agent_init_parsed(self): + args = cli._build_parser().parse_args(["agent", "init", "--provision-only"]) + self.assertEqual(args.command, "agent") + self.assertEqual(args.agent_action, "init") + self.assertTrue(args.provision_only) + + def test_agent_init_defaults_no_provision_only(self): + args = cli._build_parser().parse_args(["agent", "init"]) + self.assertFalse(args.provision_only) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_networking.py b/tests/test_agent_networking.py new file mode 100644 index 00000000..b69de103 --- /dev/null +++ b/tests/test_agent_networking.py @@ -0,0 +1,70 @@ +"""Tests for the container/tunnel-enabling knobs: + * opencode_process.opencode_bind_host() -- what OpenCode binds to + * ui.server._client_is_trusted -- who may hit the local-only control routes + +Both default to the safe, loopback-only behavior; the env overrides only widen +things for the container-behind-a-tunnel deployment. +""" + +import ipaddress +import os +import unittest +from unittest.mock import patch + +from weightslab import opencode_process +from weightslab.ui import server as ui_server + + +class BindHostTests(unittest.TestCase): + def test_default_is_loopback(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop(opencode_process.HOST_ENV_VAR, None) + self.assertEqual(opencode_process.opencode_bind_host(), "127.0.0.1") + + def test_env_override(self): + with patch.dict(os.environ, {opencode_process.HOST_ENV_VAR: "0.0.0.0"}, clear=False): + self.assertEqual(opencode_process.opencode_bind_host(), "0.0.0.0") + + def test_blank_env_falls_back_to_default(self): + with patch.dict(os.environ, {opencode_process.HOST_ENV_VAR: " "}, clear=False): + self.assertEqual(opencode_process.opencode_bind_host(), "127.0.0.1") + + +class TrustedClientTests(unittest.TestCase): + def test_loopback_always_trusted(self): + with patch.object(ui_server, "_TRUSTED_CLIENT_NETS", []): + self.assertTrue(ui_server._client_is_trusted("127.0.0.1")) + self.assertTrue(ui_server._client_is_trusted("::1")) + + def test_non_loopback_rejected_by_default(self): + with patch.object(ui_server, "_TRUSTED_CLIENT_NETS", []): + self.assertFalse(ui_server._client_is_trusted("172.17.0.1")) + + def test_trusted_net_allows_gateway(self): + nets = [ipaddress.ip_network("172.16.0.0/12")] + with patch.object(ui_server, "_TRUSTED_CLIENT_NETS", nets): + self.assertTrue(ui_server._client_is_trusted("172.17.0.1")) + self.assertFalse(ui_server._client_is_trusted("8.8.8.8")) + + def test_garbage_addr_is_not_trusted(self): + nets = [ipaddress.ip_network("172.16.0.0/12")] + with patch.object(ui_server, "_TRUSTED_CLIENT_NETS", nets): + self.assertFalse(ui_server._client_is_trusted("not-an-ip")) + + +class TrustedNetsParsingTests(unittest.TestCase): + def test_parse_multiple_and_ignore_invalid(self): + with patch.dict(os.environ, + {"WEIGHTSLAB_UI_TRUSTED_HOSTS": "172.16.0.0/12, bad, 10.1.2.3"}, + clear=False): + nets = ui_server._parse_trusted_client_nets() + self.assertEqual(len(nets), 2) + + def test_empty_env_is_empty_list(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("WEIGHTSLAB_UI_TRUSTED_HOSTS", None) + self.assertEqual(ui_server._parse_trusted_client_nets(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_opencode_binary.py b/tests/test_opencode_binary.py new file mode 100644 index 00000000..18f48f3d --- /dev/null +++ b/tests/test_opencode_binary.py @@ -0,0 +1,259 @@ +"""Tests for weightslab/opencode_binary.py -- the on-demand provisioner that +makes ``pip install weightslab`` ship a working OpenCode with no Node.js. + +Everything here is offline: the one network call (download_managed_binary) is +exercised by patching ``urllib.request.urlopen`` to hand back an in-memory npm +tarball, so the extract/chmod/atomic-rename path is covered without touching the +real registry. Platform selection is exercised by patching the tiny set of +host probes (``sys.platform``, ``platform.machine``, AVX2/musl detection). +""" + +import io +import os +import stat +import tarfile +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + +from weightslab import opencode_binary, opencode_process + + +def _fake_npm_tarball(binary_name: str = "opencode", body: bytes = b"#!/bin/sh\necho ok\n") -> bytes: + """Build an in-memory .tgz laid out like an opencode- npm package + (``package/bin/``), the exact shape _extract_binary looks for.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + info = tarfile.TarInfo(name=f"package/bin/{binary_name}") + info.size = len(body) + info.mode = 0o644 + tar.addfile(info, io.BytesIO(body)) + return buf.getvalue() + + +class ManagedPathTests(unittest.TestCase): + def test_home_env_override_and_version_scoping(self): + with tempfile.TemporaryDirectory() as tmp: + env = {opencode_binary.HOME_ENV_VAR: tmp, opencode_binary.VERSION_ENV_VAR: "9.9.9"} + with patch.dict(os.environ, env, clear=False): + path = opencode_binary.managed_binary_path() + self.assertEqual(Path(path).parent.parent, Path(tmp) / "9.9.9") + self.assertEqual(Path(path).parent.name, "bin") + + def test_pinned_version_env_override(self): + with patch.dict(os.environ, {opencode_binary.VERSION_ENV_VAR: "1.2.3"}, clear=False): + self.assertEqual(opencode_binary.pinned_version(), "1.2.3") + with patch.dict(os.environ, {opencode_binary.VERSION_ENV_VAR: ""}, clear=False): + self.assertEqual(opencode_binary.pinned_version(), + opencode_binary.DEFAULT_OPENCODE_VERSION) + + def test_autodownload_toggle(self): + for val, expected in [("0", False), ("false", False), ("no", False), + ("off", False), ("1", True), ("", True)]: + with patch.dict(os.environ, {opencode_binary.AUTODOWNLOAD_ENV_VAR: val}, clear=False): + self.assertEqual(opencode_binary.autodownload_enabled(), expected) + + +class CandidatePackageTests(unittest.TestCase): + def _candidates(self, plat, machine, avx2, musl): + with patch.object(opencode_binary.sys, "platform", plat), \ + patch.object(opencode_binary.platform, "machine", return_value=machine), \ + patch.object(opencode_binary, "_supports_avx2", return_value=avx2), \ + patch.object(opencode_binary, "_is_musl", return_value=musl): + return opencode_binary.candidate_packages() + + def test_linux_x64_avx2_glibc(self): + got = self._candidates("linux", "x86_64", avx2=True, musl=False) + self.assertEqual(got[0], "opencode-linux-x64") + self.assertIn("opencode-linux-x64-baseline", got) + + def test_linux_x64_no_avx2_prefers_baseline(self): + got = self._candidates("linux", "x86_64", avx2=False, musl=False) + self.assertEqual(got[0], "opencode-linux-x64-baseline") + + def test_linux_musl_prefers_musl(self): + got = self._candidates("linux", "x86_64", avx2=True, musl=True) + self.assertEqual(got[0], "opencode-linux-x64-musl") + + def test_linux_arm64(self): + got = self._candidates("linux", "aarch64", avx2=False, musl=False) + self.assertEqual(got[0], "opencode-linux-arm64") + + def test_darwin_arm64(self): + got = self._candidates("darwin", "arm64", avx2=False, musl=False) + self.assertEqual(got, ["opencode-darwin-arm64"]) + + def test_windows_x64_avx2(self): + got = self._candidates("win32", "AMD64", avx2=True, musl=False) + self.assertEqual(got[0], "opencode-windows-x64") + + +class FindAndEnsureTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self._env = patch.dict( + os.environ, + {opencode_binary.HOME_ENV_VAR: self._tmp.name, + opencode_binary.VERSION_ENV_VAR: "1.2.3"}, + clear=False, + ) + self._env.start() + self.addCleanup(self._env.stop) + + def _install_fake(self): + path = opencode_binary.managed_binary_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n") + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR) + return path + + def test_find_missing_returns_none(self): + self.assertIsNone(opencode_binary.find_managed_binary()) + + def test_find_present_returns_path(self): + path = self._install_fake() + self.assertEqual(opencode_binary.find_managed_binary(), path) + + def test_ensure_returns_existing_without_download(self): + path = self._install_fake() + with patch.object(opencode_binary, "download_managed_binary") as dl: + self.assertEqual(opencode_binary.ensure_managed_binary(), path) + dl.assert_not_called() + + def test_ensure_respects_autodownload_disabled(self): + with patch.dict(os.environ, {opencode_binary.AUTODOWNLOAD_ENV_VAR: "0"}, clear=False): + with patch.object(opencode_binary, "download_managed_binary") as dl: + self.assertIsNone(opencode_binary.ensure_managed_binary()) + dl.assert_not_called() + + def test_ensure_downloads_when_missing(self): + sentinel = self._tmp.name + "/sentinel" + with patch.object(opencode_binary, "download_managed_binary", return_value=sentinel) as dl: + self.assertEqual(opencode_binary.ensure_managed_binary(), sentinel) + dl.assert_called_once() + + +class DownloadTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self._env = patch.dict( + os.environ, + {opencode_binary.HOME_ENV_VAR: self._tmp.name, + opencode_binary.VERSION_ENV_VAR: "1.2.3"}, + clear=False, + ) + self._env.start() + self.addCleanup(self._env.stop) + + def test_download_extracts_and_marks_executable(self): + tgz = _fake_npm_tarball(binary_name=opencode_binary._binary_filename()) + + def fake_urlopen(req, timeout=None): + return io.BytesIO(tgz) + + with patch.object(opencode_binary.urllib.request, "urlopen", side_effect=fake_urlopen): + path = opencode_binary.download_managed_binary() + + self.assertIsNotNone(path) + self.assertTrue(Path(path).is_file()) + self.assertTrue(os.access(str(path), os.X_OK)) + self.assertEqual(Path(path).read_bytes()[:2], b"#!") + + def test_download_all_candidates_fail_returns_none(self): + def boom(req, timeout=None): + raise OSError("network down") + + with patch.object(opencode_binary.urllib.request, "urlopen", side_effect=boom): + self.assertIsNone(opencode_binary.download_managed_binary()) + + +class BackgroundInstallTests(unittest.TestCase): + def setUp(self): + # Reset the once-per-process guard so each test starts clean. + opencode_binary._bg_started = False + self.addCleanup(setattr, opencode_binary, "_bg_started", False) + + def test_noop_when_already_installed(self): + with patch.object(opencode_binary, "find_managed_binary", return_value=Path("/x/opencode")), \ + patch.object(opencode_binary, "download_managed_binary") as dl: + opencode_binary.ensure_managed_binary_in_background(reason="test") + dl.assert_not_called() + + def test_noop_when_autodownload_disabled(self): + with patch.dict(os.environ, {opencode_binary.AUTODOWNLOAD_ENV_VAR: "0"}, clear=False), \ + patch.object(opencode_binary, "find_managed_binary", return_value=None), \ + patch.object(opencode_binary, "download_managed_binary") as dl: + opencode_binary.ensure_managed_binary_in_background(reason="test") + dl.assert_not_called() + + def test_downloads_in_background_when_missing(self): + done = threading.Event() + + def fake_download(version=None): + done.set() + return Path("/x/opencode") + + with patch.object(opencode_binary, "find_managed_binary", return_value=None), \ + patch.object(opencode_binary, "download_managed_binary", side_effect=fake_download): + opencode_binary.ensure_managed_binary_in_background(reason="test") + self.assertTrue(done.wait(timeout=5), "background download did not run") + + def test_only_first_call_starts(self): + with patch.object(opencode_binary, "find_managed_binary", return_value=None), \ + patch.object(opencode_binary, "download_managed_binary", + return_value=Path("/x/opencode")) as dl: + opencode_binary.ensure_managed_binary_in_background(reason="a") + opencode_binary.ensure_managed_binary_in_background(reason="b") + # second call must be a no-op regardless of thread timing + import time as _t + _t.sleep(0.5) + self.assertLessEqual(dl.call_count, 1) + + +class ResolverPrecedenceTests(unittest.TestCase): + """opencode_process.resolve_opencode_argv order: + managed-present -> PATH -> managed-download -> npx -> None.""" + + def test_managed_present_wins(self): + with patch.object(opencode_process.opencode_binary, "find_managed_binary", + return_value=Path("/mgd/opencode")), \ + patch.object(opencode_process.shutil, "which", return_value="/usr/bin/opencode"): + self.assertEqual(opencode_process.resolve_opencode_argv(), ["/mgd/opencode"]) + + def test_path_used_before_download(self): + with patch.object(opencode_process.opencode_binary, "find_managed_binary", return_value=None), \ + patch.object(opencode_process.opencode_binary, "ensure_managed_binary") as ensure, \ + patch.object(opencode_process.shutil, "which", + side_effect=lambda n: "/usr/bin/opencode" if n == "opencode" else None): + self.assertEqual(opencode_process.resolve_opencode_argv(), ["/usr/bin/opencode"]) + ensure.assert_not_called() + + def test_download_when_no_path(self): + with patch.object(opencode_process.opencode_binary, "find_managed_binary", return_value=None), \ + patch.object(opencode_process.opencode_binary, "ensure_managed_binary", + return_value=Path("/mgd/opencode")), \ + patch.object(opencode_process.shutil, "which", return_value=None): + self.assertEqual(opencode_process.resolve_opencode_argv(), ["/mgd/opencode"]) + + def test_npx_last_resort(self): + def which(name): + return "/usr/bin/npx" if name == "npx" else None + with patch.object(opencode_process.opencode_binary, "find_managed_binary", return_value=None), \ + patch.object(opencode_process.opencode_binary, "ensure_managed_binary", return_value=None), \ + patch.object(opencode_process.shutil, "which", side_effect=which): + self.assertEqual(opencode_process.resolve_opencode_argv(), + ["/usr/bin/npx", "--yes", "opencode-ai@latest"]) + + def test_none_when_nothing_available(self): + with patch.object(opencode_process.opencode_binary, "find_managed_binary", return_value=None), \ + patch.object(opencode_process.opencode_binary, "ensure_managed_binary", return_value=None), \ + patch.object(opencode_process.shutil, "which", return_value=None): + self.assertIsNone(opencode_process.resolve_opencode_argv()) + + +if __name__ == "__main__": + unittest.main() diff --git a/weightslab/__init__.py b/weightslab/__init__.py index 44323142..759de5ea 100644 --- a/weightslab/__init__.py +++ b/weightslab/__init__.py @@ -96,6 +96,30 @@ def __dir__(): if os.getenv('WEIGHTSLAB_SUPPRESS_BANNER', '0') != '1': logger.info(_BANNER) +# Auto-install the OpenCode agent binary on first import, in the background and +# logged, so `import weightslab` / `weightslab start` / `weightslab start example` +# all leave the agent ready with no manual step (installation only — signing in +# stays opt-in via `weightslab agent init`). Guards: main process only (never +# DataLoader workers); skipped under pytest and when +# WEIGHTSLAB_OPENCODE_AUTOINSTALL is falsey or the download is disabled. The +# call itself no-ops instantly when the binary is already present. +def _autoinstall_opencode_on_import(): + import sys as _sys + if os.environ.get('WEIGHTSLAB_OPENCODE_AUTOINSTALL', '1').strip().lower() in {'0', 'false', 'no', 'off'}: + return + if 'pytest' in _sys.modules or os.environ.get('PYTEST_CURRENT_TEST'): + return + try: + from weightslab import opencode_binary + opencode_binary.ensure_managed_binary_in_background( + reason="import weightslab", logger=logger) + except Exception as _exc: # pragma: no cover - best-effort + logger.debug("OpenCode auto-install skipped: %s", _exc) + + +if _IS_MAIN_PROCESS: + _autoinstall_opencode_on_import() + grpc_tls_enabled = os.environ.get('GRPC_TLS_ENABLED', 'true').lower() == 'true' if _IS_MAIN_PROCESS and grpc_tls_enabled and os.environ.get('WEIGHTSLAB_SKIP_SECURE_INIT', 'false').lower() != 'true': try: diff --git a/weightslab/cli.py b/weightslab/cli.py index a70d6b76..d4e5adb4 100644 --- a/weightslab/cli.py +++ b/weightslab/cli.py @@ -602,6 +602,13 @@ def example_start(args): logger.info(f"Starting the WeightsLab {label} ({kind}) example...") logger.info(f" {main_py}") + # Install OpenCode in the background (logged) so it's ready if the user opens + # the chat; the example itself is pure training and never needs it to run. + # Configuration (sign-in) stays optional -- surface it as info, never an + # error, so a run with no agent configured doesn't look like it failed. + _prewarm_opencode() + if not agent_is_configured(): + _log_agent_config_hint() logger.info("In another terminal, launch the UI with: weightslab start") logger.info("Then open the URL printed by `weightslab start` — stop the example with Ctrl+C.") if not _CERTS_DIR_IN_ORIGINAL_ENV: @@ -777,6 +784,83 @@ def _print_experiment_guidance(experiment_dir: Path) -> None: logger.info("=" * 60) +def _opencode_auth_paths() -> "list[Path]": + """Candidate locations of OpenCode's own credential store (written by + `opencode auth login`). Existence of any means the agent has been + initialized on this machine.""" + candidates = [] + xdg = os.environ.get("XDG_DATA_HOME", "").strip() + if xdg: + candidates.append(Path(xdg) / "opencode" / "auth.json") + if _is_windows(): + for var in ("APPDATA", "LOCALAPPDATA"): + base = os.environ.get(var) + if base: + candidates.append(Path(base) / "opencode" / "auth.json") + candidates.append(Path.home() / ".local" / "share" / "opencode" / "auth.json") + return candidates + + +def _agent_env_files() -> "list[Path]": + """Candidate user-provided agent env files (.env). Mirrors the paths + agent.py's _load_config actually loads, so "found here" == "used there".""" + pkg_dir = Path(__file__).resolve().parent # .../weightslab + return [Path.cwd() / ".env", pkg_dir / ".env", pkg_dir.parent / ".env"] + + +def agent_is_configured() -> bool: + """True if the agent has been initialized -- i.e. an env file / credential is + present so it can be used without any further step: + * OPENCODE_URL set (explicit server), or + * a user `.env` present (agent.py loads it), or + * a prior `opencode auth login` (its credential store exists). + + Deliberately does NOT count agent_config.yaml: it ships in the repo/wheel and + only carries URL/model *defaults*, not a credential, so counting it would + make the "not initialized" hint never fire. + """ + if os.environ.get("OPENCODE_URL", "").strip(): + return True + if any(p.is_file() for p in _agent_env_files()): + return True + return any(p.is_file() for p in _opencode_auth_paths()) + + +def _log_agent_config_hint() -> None: + """Tell the user OpenCode is installed but the agent still needs a one-time + sign-in -- an INFO note, not an error. The assistant is optional; a run that + never uses it must not look broken.""" + logger.info( + "OpenCode is installed, but the agent is not initialized yet — run " + "`weightslab agent init` to sign in (or set OPENCODE_URL / add a .env at " + "your project root). The assistant is optional; continuing without it." + ) + + +def _prewarm_opencode() -> None: + """Install OpenCode in the background if not already present (logged). + + Delegates to the shared, once-per-process installer so the ~180 MB first-run + download never blocks the UI, and repeated launches don't re-download. + """ + from weightslab import opencode_binary + opencode_binary.ensure_managed_binary_in_background(reason="weightslab start", logger=logger) + + +def _prewarm_opencode_or_hint() -> None: + """Install OpenCode up front (background, logged) so the agent is ready, and + -- separately -- hint how to sign in when nothing is configured yet. + + Installing the binary is unconditional: it is a free, one-time fetch and is + what makes `weightslab start` leave the agent usable. Signing in stays + opt-in (`weightslab agent init`); we only *hint* at it, never do it + implicitly -- that is the "no agent env found -> no init, just info" rule. + """ + _prewarm_opencode() + if not agent_is_configured(): + _log_agent_config_hint() + + def ui_start_native(args): """`weightslab start`: launch the Weights Studio UI natively (no Docker). @@ -810,6 +894,12 @@ def ui_start_native(args): os.environ["WL_LAST_EXPERIMENT_DIR"] = str(experiment_dir) _print_experiment_guidance(experiment_dir) + # If the agent has been initialized, provision OpenCode up front (in the + # background) so it is ready the moment the user opens the chat. If it has + # NOT been initialized, do nothing but log how to enable it -- an + # unconfigured run stays agent-free and error-free. + _prewarm_opencode_or_hint() + ui_host = getattr(args, "host", None) or os.getenv("WEIGHTSLAB_UI_HOST", "0.0.0.0") preferred_ui_port, ui_port_source = _resolve_ui_port(args) if ui_port_source == "default": @@ -927,6 +1017,46 @@ def _add_example_kind_flags(p: argparse.ArgumentParser) -> None: p.set_defaults(example_kind=_DEFAULT_EXAMPLE) +def agent_init(args): + """`weightslab agent init`: initialize the AI assistant. + + Provisions the OpenCode binary (Node-free, via weightslab.opencode_binary) + and then runs `opencode auth login` so the user signs in once. This is the + explicit opt-in that `weightslab start` / `start example` only *hint* at when + no agent is configured -- nothing here happens implicitly. + """ + from weightslab import opencode_binary + + logger.info("Provisioning the OpenCode agent binary (no Node.js required)...") + path = opencode_binary.ensure_managed_binary() + if not path: + logger.error( + "Could not provision OpenCode (offline?). Retry with network access, " + "or install Node.js 20+ and `npm i -g opencode-ai`." + ) + sys.exit(1) + logger.info(f"OpenCode ready: {path}") + + if getattr(args, "provision_only", False): + logger.info("Provision-only: skipping interactive sign-in.") + logger.info(f"Sign in later with: weightslab agent init (or: {path} auth login)") + return + + logger.info("Launching `opencode auth login` — follow the prompts to sign in.") + try: + rc = subprocess.run([str(path), "auth", "login"]).returncode + except KeyboardInterrupt: + logger.info("Sign-in cancelled. Re-run `weightslab agent init` anytime.") + return + if rc != 0: + logger.warning( + f"`opencode auth login` exited with code {rc}. " + "Re-run `weightslab agent init` to try again." + ) + sys.exit(rc) + logger.info("Agent initialized. The assistant is now available in `weightslab start`.") + + def _build_parser() -> argparse.ArgumentParser: """Build the top-level argument parser (banner + detailed command reference). @@ -945,7 +1075,7 @@ def _build_parser() -> argparse.ArgumentParser: epilog=_EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter, ) - sub = parser.add_subparsers(dest="command", metavar="{se,start,cli,tunnel,export,help}") + sub = parser.add_subparsers(dest="command", metavar="{se,start,cli,tunnel,export,agent,help}") # weightslab se [--force-certs] [certs_dir] se_parser = sub.add_parser("se", help="Set up the secure environment (TLS certs + gRPC auth token)") @@ -1035,6 +1165,17 @@ def _build_parser() -> argparse.ArgumentParser: "start", help="Start a bundled PyTorch example (default: classification)") _add_example_kind_flags(example_alias_start) + # weightslab agent init [--provision-only] + agent_parser = sub.add_parser( + "agent", help="Manage the AI assistant (OpenCode), e.g. `weightslab agent init`") + agent_sub = agent_parser.add_subparsers(dest="agent_action", metavar="{init}") + agent_init_parser = agent_sub.add_parser( + "init", help="Provision OpenCode and sign in so the assistant is ready to use") + agent_init_parser.add_argument( + "--provision-only", action="store_true", + help="Only download/verify the OpenCode binary; skip the interactive " + "sign-in (for headless/CI environments).") + sub.add_parser("help", help="Show this help message") return parser @@ -1070,6 +1211,11 @@ def main(): elif args.command == "example": # Alias for `start example` — tolerate the swapped subcommand order. example_start(args) + elif args.command == "agent": + if getattr(args, "agent_action", None) == "init": + agent_init(args) + else: + _build_parser().parse_args(["agent", "--help"]) else: parser.print_help() diff --git a/weightslab/examples/PyTorch/wl-classification/main.py b/weightslab/examples/PyTorch/wl-classification/main.py index 7f095c25..dc64f510 100644 --- a/weightslab/examples/PyTorch/wl-classification/main.py +++ b/weightslab/examples/PyTorch/wl-classification/main.py @@ -385,9 +385,9 @@ def test(loader, model, criterion_mlt, metric_mlt, device, test_loader_len): else: train_range = itertools.count() - # # ============= - # # Training Loop - # wl.start_training(timeout=3) # Blocks and keeps the main thread alive while background services run. Optionally set a timeout (seconds) to auto-stop. + # ============= + # Training Loop + wl.start_training(timeout=3) # Blocks and keeps the main thread alive while background services run. Optionally set a timeout (seconds) to auto-stop. train_loss = None test_loss, test_metric = None, None diff --git a/weightslab/opencode_binary.py b/weightslab/opencode_binary.py new file mode 100644 index 00000000..35c2cc9c --- /dev/null +++ b/weightslab/opencode_binary.py @@ -0,0 +1,372 @@ +"""Self-contained provisioning of a standalone OpenCode binary. + +Why this exists +--------------- +``weightslab`` drives a local ``opencode serve`` process (see +``opencode_process.py``). Historically the only ways to get that binary were a +global ``npm i -g opencode-ai`` or the ``npx --yes`` fallback -- both of which +need Node.js on the machine. That makes a plain ``pip install weightslab`` in a +clean environment *not* enough: the agent silently fails with "Could not find +`opencode` or `npx`" until the user installs Node and OpenCode by hand. + +This module removes that manual step without bloating the wheel. OpenCode ships +its ~180 MB standalone binaries inside platform-specific npm packages +(``opencode-linux-x64``, ``opencode-darwin-arm64``, ...), each downloadable as a +plain gzip tarball from the npm registry -- no Node needed to fetch or unpack, +only ``urllib`` + ``tarfile`` from the stdlib. So instead of vendoring a +180 MB-per-platform binary into every wheel (which would make wheels huge and +platform-locked), we fetch the *correct* binary on demand into a per-user cache +and reuse it forever after. ``resolve_opencode_argv`` prefers this managed +binary, so after a clean ``pip install`` the agent "just works". + +The platform/arch/musl/AVX2 selection logic mirrors ``opencode-ai``'s own +``postinstall.mjs`` so we pick exactly the package its installer would have. +""" + +from __future__ import annotations + +import logging +import os +import platform +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import threading +import urllib.request +from pathlib import Path +from typing import List, Optional + +_LOGGER = logging.getLogger(__name__) + +# The OpenCode version this weightslab release pins. Kept explicit (not +# "@latest") so a given weightslab build always provisions a known-good, +# tested OpenCode -- reproducible installs, no surprise upgrade mid-release. +# Override with WEIGHTSLAB_OPENCODE_VERSION to track a different one. +DEFAULT_OPENCODE_VERSION = "1.18.23" + +VERSION_ENV_VAR = "WEIGHTSLAB_OPENCODE_VERSION" +HOME_ENV_VAR = "WEIGHTSLAB_OPENCODE_HOME" +# Set to "0"/"false"/"no" to forbid the on-demand download (air-gapped hosts, +# CI that must stay offline). find_managed_binary() still returns an already +# provisioned binary; only the network fetch is suppressed. +AUTODOWNLOAD_ENV_VAR = "WEIGHTSLAB_OPENCODE_AUTODOWNLOAD" + +_REGISTRY = "https://registry.npmjs.org" +# Generous: a cold fetch pulls a ~180 MB tarball over the public registry. +_DOWNLOAD_TIMEOUT = 180.0 + + +def pinned_version() -> str: + """The OpenCode version to provision (env override wins).""" + return os.environ.get(VERSION_ENV_VAR, "").strip() or DEFAULT_OPENCODE_VERSION + + +def autodownload_enabled() -> bool: + raw = os.environ.get(AUTODOWNLOAD_ENV_VAR, "").strip().lower() + if raw in {"0", "false", "no", "off"}: + return False + return True + + +def _cache_root() -> Path: + """Per-user cache directory the managed binary lives under. + + Honours WEIGHTSLAB_OPENCODE_HOME, then the platform-conventional cache + location, so provisioning survives across virtualenvs (the binary is a + property of the machine, not of one env) and never needs write access to + the -- possibly read-only -- site-packages tree. + """ + override = os.environ.get(HOME_ENV_VAR, "").strip() + if override: + return Path(override).expanduser() + + if sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") + root = Path(base) if base else Path.home() / "AppData" / "Local" + return root / "weightslab" / "opencode" + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "weightslab" / "opencode" + xdg = os.environ.get("XDG_CACHE_HOME", "").strip() + root = Path(xdg) if xdg else Path.home() / ".cache" + return root / "weightslab" / "opencode" + + +def _binary_filename() -> str: + # OpenCode names the extracted binary opencode.exe on Windows, opencode + # elsewhere (postinstall.mjs's sourceBinary). + return "opencode.exe" if sys.platform == "win32" else "opencode" + + +def managed_binary_path(version: Optional[str] = None) -> Path: + """Where the managed binary for ``version`` is (or would be) installed. + + Version-scoped so bumping DEFAULT_OPENCODE_VERSION provisions cleanly + alongside the old one instead of clobbering a binary another env still uses. + """ + version = version or pinned_version() + return _cache_root() / version / "bin" / _binary_filename() + + +def _norm_platform() -> str: + return {"darwin": "darwin", "linux": "linux", "win32": "windows"}.get( + sys.platform, sys.platform + ) + + +def _norm_arch() -> str: + machine = platform.machine().lower() + if machine in {"x86_64", "amd64", "x64"}: + return "x64" + if machine in {"arm64", "aarch64"}: + return "arm64" + if machine.startswith("arm"): + return "arm" + return machine + + +def _supports_avx2() -> bool: + """AVX2 probe, x64 only -- mirrors postinstall.mjs. Non-AVX2 x64 CPUs need + the ``-baseline`` build; getting this wrong yields an illegal-instruction + crash at first run, so we default to the safe (baseline-preferred) answer + whenever detection is uncertain.""" + if _norm_arch() != "x64": + return False + system = _norm_platform() + try: + if system == "linux": + with open("/proc/cpuinfo", "r", encoding="utf-8", errors="ignore") as fh: + return " avx2 " in (" " + fh.read().lower() + " ") + if system == "darwin": + out = subprocess.run( + ["sysctl", "-n", "hw.optional.avx2_0"], + capture_output=True, text=True, timeout=1.5, + ) + return out.returncode == 0 and out.stdout.strip() == "1" + if system == "windows": + # IsProcessorFeaturePresent(40) == PF_AVX2_INSTRUCTIONS_AVAILABLE. + ps = ( + '(Add-Type -MemberDefinition "[DllImport(\\"kernel32.dll\\")] ' + 'public static extern bool IsProcessorFeaturePresent(int f);" ' + "-Name K -Namespace W -PassThru)::IsProcessorFeaturePresent(40)" + ) + for exe in ("powershell.exe", "pwsh.exe", "pwsh", "powershell"): + if not shutil.which(exe): + continue + out = subprocess.run( + [exe, "-NoProfile", "-NonInteractive", "-Command", ps], + capture_output=True, text=True, timeout=3.0, + ) + if out.returncode == 0: + return out.stdout.strip().lower() in {"true", "1"} + except Exception: # pragma: no cover - detection is best-effort + return False + return False + + +def _is_musl() -> bool: + if _norm_platform() != "linux": + return False + try: + if Path("/etc/alpine-release").exists(): + return True + except Exception: # pragma: no cover - filesystem probe blocked + pass + try: + out = subprocess.run(["ldd", "--version"], capture_output=True, text=True) + return "musl" in (out.stdout + out.stderr).lower() + except Exception: # pragma: no cover - ldd absent + return False + + +def candidate_packages() -> List[str]: + """Ordered npm package names to try for this host, most-preferred first. + + Mirrors opencode-ai/postinstall.mjs's ``packageNames()`` so we resolve the + same artifact its own installer would, including the -musl and -baseline + fallbacks. The list is ordered, not singular, precisely so a wrong AVX2/musl + guess degrades to a working build rather than a hard failure. + """ + system = _norm_platform() + arch = _norm_arch() + base = f"opencode-{system}-{arch}" + baseline = arch == "x64" and not _supports_avx2() + + if system == "linux": + if _is_musl(): + if arch == "x64": + return ( + [f"{base}-baseline-musl", f"{base}-musl", f"{base}-baseline", base] + if baseline + else [f"{base}-musl", f"{base}-baseline-musl", base, f"{base}-baseline"] + ) + return [f"{base}-musl", base] + if arch == "x64": + return ( + [f"{base}-baseline", base, f"{base}-baseline-musl", f"{base}-musl"] + if baseline + else [base, f"{base}-baseline", f"{base}-musl", f"{base}-baseline-musl"] + ) + return [base, f"{base}-musl"] + + if arch == "x64": + return [f"{base}-baseline", base] if baseline else [base, f"{base}-baseline"] + return [base] + + +def _tarball_url(pkg: str, version: str) -> str: + # Standard unscoped-package layout on the npm registry. + return f"{_REGISTRY}/{pkg}/-/{pkg}-{version}.tgz" + + +def _extract_binary(tgz_path: Path, dest: Path) -> bool: + """Extract ``package/bin/`` from an npm tarball to ``dest``. + + Writes to a sibling temp file and atomically renames, so a concurrent + reader never sees a half-written binary and two racing provisioners can't + corrupt each other's output. + """ + wanted = f"bin/{_binary_filename()}" + dest.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(tgz_path, "r:gz") as tar: + member = next( + (m for m in tar.getmembers() if m.isfile() and m.name.replace("\\", "/").endswith(wanted)), + None, + ) + if member is None: + _LOGGER.warning("OpenCode tarball %s has no %s", tgz_path.name, wanted) + return False + src = tar.extractfile(member) + if src is None: # pragma: no cover - defensive + return False + fd, tmp_name = tempfile.mkstemp(dir=str(dest.parent), prefix=".opencode-", suffix=".part") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as out: + shutil.copyfileobj(src, out, length=1024 * 1024) + mode = os.stat(tmp).st_mode + os.chmod(tmp, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + os.replace(tmp, dest) + finally: + if tmp.exists(): + tmp.unlink() + return True + + +def download_managed_binary(version: Optional[str] = None) -> Optional[Path]: + """Fetch and install the managed OpenCode binary. Returns its path or None. + + Tries each candidate package in turn; the first that both downloads and + yields the wanted binary wins. Never raises: a failed provision degrades to + ``None`` so the caller can fall back to PATH/npx rather than crash. + """ + version = version or pinned_version() + dest = managed_binary_path(version) + packages = candidate_packages() + _LOGGER.info( + "OpenCode: provisioning managed binary %s (%s) -> %s", + version, packages[0] if packages else "?", dest, + ) + for pkg in packages: + url = _tarball_url(pkg, version) + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=".tgz") as tmp: + tgz = Path(tmp.name) + req = urllib.request.Request(url, headers={"User-Agent": "weightslab-opencode-provisioner"}) + with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT) as resp: + if getattr(resp, "status", 200) != 200: + continue + with open(tgz, "wb") as fh: + shutil.copyfileobj(resp, fh, length=1024 * 1024) + ok = _extract_binary(tgz, dest) + if ok: + _LOGGER.info("OpenCode: installed %s from %s", dest, pkg) + return dest + except Exception as exc: # try the next candidate package + _LOGGER.debug("OpenCode: candidate %s failed (%s)", pkg, exc) + continue + finally: + try: + tgz.unlink() + except Exception: + pass + _LOGGER.warning( + "OpenCode: could not provision a managed binary for %s/%s (version %s).", + _norm_platform(), _norm_arch(), version, + ) + return None + + +def _looks_runnable(path: Path) -> bool: + try: + return path.is_file() and os.access(str(path), os.X_OK) + except Exception: # pragma: no cover - defensive + return False + + +def find_managed_binary(version: Optional[str] = None) -> Optional[Path]: + """Return an already-provisioned managed binary, or None. No network.""" + path = managed_binary_path(version) + return path if _looks_runnable(path) else None + + +def ensure_managed_binary(version: Optional[str] = None, + auto_download: Optional[bool] = None) -> Optional[Path]: + """Return a usable managed binary, downloading it once if needed. + + ``auto_download`` defaults to the WEIGHTSLAB_OPENCODE_AUTODOWNLOAD env + setting. Returns None (never raises) when no managed binary can be made + available, so callers can fall back to PATH/npx. + """ + existing = find_managed_binary(version) + if existing: + return existing + if auto_download is None: + auto_download = autodownload_enabled() + if not auto_download: + return None + return download_managed_binary(version) + + +# Guard so many callers (import hook + `weightslab start` + `start example`) at +# most spawn ONE background install per process, rather than racing downloads. +_bg_lock = threading.Lock() +_bg_started = False + + +def ensure_managed_binary_in_background(reason: str = "", logger: Optional[logging.Logger] = None) -> None: + """Install OpenCode in a daemon thread if it isn't already present, logging + the install. Idempotent, best-effort, and non-blocking -- the caller (a CLI + launch or ``import weightslab``) never waits on the ~180 MB download. + + Respects WEIGHTSLAB_OPENCODE_AUTODOWNLOAD. Only the FIRST call per process + does anything; the rest return immediately. + """ + global _bg_started + log = logger or _LOGGER + if not autodownload_enabled(): + return + if find_managed_binary() is not None: + return # already installed -- nothing to do, stay quiet + with _bg_lock: + if _bg_started: + return + _bg_started = True + + def _run(): + try: + log.info("OpenCode not installed — installing now (%s)...", reason or "first use") + path = download_managed_binary() + if path: + log.info("OpenCode installed: %s", path) + else: + log.info( + "OpenCode install could not complete (offline?); it will be " + "retried automatically the next time the agent is used." + ) + except Exception as exc: # pragma: no cover - best-effort + log.debug("OpenCode background install failed: %s", exc) + + threading.Thread(target=_run, name="opencode-install", daemon=True).start() diff --git a/weightslab/opencode_process.py b/weightslab/opencode_process.py index d36c89b5..92db1eee 100644 --- a/weightslab/opencode_process.py +++ b/weightslab/opencode_process.py @@ -43,6 +43,8 @@ from pathlib import Path from typing import Optional +from weightslab import opencode_binary + _LOGGER = logging.getLogger(__name__) # Generous: a cold `npx` run downloads the package before the server binds. @@ -68,6 +70,17 @@ # or where a specific port is the one that happens to be forwarded/published. PORT_ENV_VAR = "WEIGHTSLAB_OPENCODE_PORT" +# Host the spawned OpenCode server BINDS to. Loopback by default -- the server +# has filesystem access and must never be reachable off the machine on a normal +# local run. But in a container reached over an SSH tunnel / published port, the +# browser's request arrives on the container's network interface, not its +# loopback, so a 127.0.0.1-only bind is refused. Setting this to 0.0.0.0 (done +# in the weightslab dev container) lets the published port reach it. Only the +# BIND host changes; the URL handed to the browser stays 127.0.0.1 (which the +# tunnel maps), so this never widens what address the page is told to use. +HOST_ENV_VAR = "WEIGHTSLAB_OPENCODE_HOST" +DEFAULT_OPENCODE_HOST = "127.0.0.1" + # Dropped directly in the workspace directory, next to (and alongside) the # AGENTS.md the landing-page agent already seeds there -- same "lives with # the experiment" reasoning, and it means deleting/moving the experiment @@ -234,6 +247,17 @@ def write_lock(workspace_dir: str, url: str, pid: Optional[int] = None) -> None: _LOGGER.warning("Could not write OpenCode lock file under %s", workspace_dir) +def opencode_bind_host() -> str: + """Host the spawned OpenCode server binds to (``--hostname``). + + ``WEIGHTSLAB_OPENCODE_HOST`` overrides the loopback default -- set it to + ``0.0.0.0`` so a container's published port / an SSH tunnel can reach the + server. Distinct from the URL reported to the browser, which stays + ``127.0.0.1`` on purpose (see HOST_ENV_VAR). + """ + return os.environ.get(HOST_ENV_VAR, "").strip() or DEFAULT_OPENCODE_HOST + + def default_opencode_port() -> int: """The port a fresh spawn asks for first -- DEFAULT_OPENCODE_PORT unless WEIGHTSLAB_OPENCODE_PORT overrides it. A malformed or out-of-range value is @@ -302,15 +326,28 @@ def pick_opencode_port() -> int: def resolve_opencode_argv() -> Optional[list]: - """Locate a way to run OpenCode, preferring an already-installed binary. - - Falls back to ``npx --yes``, which fetches the package into the npx - cache on first use -- deliberately not a global ``npm i -g``, which can - need elevated permissions and mutates the user's toolchain silently. + """Locate a way to run OpenCode, in preference order. + + 1. A weightslab-managed binary already provisioned on this machine + (``opencode_binary``) -- what makes a clean ``pip install weightslab`` + work with no Node and no manual OpenCode install. + 2. An ``opencode`` the user already has on PATH (a global/dev install): + respected before we spend bandwidth provisioning our own. + 3. Provisioning the managed binary now (a one-time ~180 MB fetch from the + npm registry, no Node required). + 4. ``npx --yes`` as a last resort -- fetches into the npx cache on first + use; deliberately not a global ``npm i -g`` (needs elevated perms and + mutates the user's toolchain silently). Requires Node. """ + managed = opencode_binary.find_managed_binary() + if managed: + return [str(managed)] exe = shutil.which("opencode") if exe: return [exe] + managed = opencode_binary.ensure_managed_binary() + if managed: + return [str(managed)] npx = shutil.which("npx") if npx: return [npx, "--yes", "opencode-ai@latest"] @@ -399,8 +436,10 @@ def resolve_or_spawn_opencode(workspace_dir: str, origin: Optional[str] = None, if argv is None: return { "ok": False, - "error": "Could not find `opencode` or `npx`. Install Node.js 20+ " - "(which provides npx), or `npm i -g opencode-ai`.", + "error": "Could not provision OpenCode: the managed binary download " + "failed (offline?) and no `opencode`/`npx` was found. Restore " + "network access, or install Node.js 20+ (provides npx), or " + "`npm i -g opencode-ai`.", } port = pick_opencode_port() @@ -408,7 +447,7 @@ def resolve_or_spawn_opencode(workspace_dir: str, origin: Optional[str] = None, for value in DEFAULT_CORS_ORIGINS: if value not in cors: cors.append(value) - cmd = argv + ["serve", "--hostname", "127.0.0.1", "--port", str(port)] + cmd = argv + ["serve", "--hostname", opencode_bind_host(), "--port", str(port)] for value in cors: cmd += ["--cors", value] diff --git a/weightslab/ui/server.py b/weightslab/ui/server.py index c6ff40af..53c7eec7 100644 --- a/weightslab/ui/server.py +++ b/weightslab/ui/server.py @@ -80,6 +80,50 @@ _LOOPBACK_ADDRESSES = {"127.0.0.1", "::1"} +def _parse_trusted_client_nets() -> list: + """Extra source networks allowed to hit the local-only control routes, + from WEIGHTSLAB_UI_TRUSTED_HOSTS (comma-separated IPs or CIDRs). + + Default is empty: loopback stays the only trusted source. This exists for + the container-behind-a-tunnel case -- when the browser reaches the UI via a + published port, the request arrives from the container's gateway, not + 127.0.0.1, so those routes would 403. There the real trust boundary is the + SSH tunnel + the host publishing only to 127.0.0.1, so trusting the internal + docker network (e.g. "172.16.0.0/12") is safe and must be opted into + explicitly. The weightslab dev container sets this. + """ + import ipaddress + raw = os.environ.get("WEIGHTSLAB_UI_TRUSTED_HOSTS", "") + nets = [] + for token in raw.split(","): + token = token.strip() + if not token: + continue + try: + nets.append(ipaddress.ip_network(token, strict=False)) + except ValueError: + logger.warning("Ignoring invalid WEIGHTSLAB_UI_TRUSTED_HOSTS entry: %r", token) + return nets + + +_TRUSTED_CLIENT_NETS = _parse_trusted_client_nets() + + +def _client_is_trusted(addr: str) -> bool: + """True if a request from ``addr`` may hit the local-only control routes: + always for loopback, and for any network in WEIGHTSLAB_UI_TRUSTED_HOSTS.""" + if addr in _LOOPBACK_ADDRESSES: + return True + if not _TRUSTED_CLIENT_NETS: + return False + import ipaddress + try: + ip = ipaddress.ip_address(addr) + except ValueError: + return False + return any(ip in net for net in _TRUSTED_CLIENT_NETS) + + def static_dir() -> str: """Absolute path to the bundled SPA directory (``weightslab/ui/static``).""" return os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") @@ -470,20 +514,16 @@ def get(self) -> dict: def _resolve_opencode_argv() -> Optional[list]: - """Locate a way to run OpenCode, preferring an already-installed binary. - - Falls back to ``npx --yes``, which fetches the package into the npx cache on - first use. That is deliberately *not* ``npm install -g``: a global install may - need elevated permissions and mutates the user's toolchain behind their back, - while the npx path needs neither and is equally automatic. + """Locate a way to run OpenCode. + + Delegates to ``opencode_process.resolve_opencode_argv`` so this UI server and + the backend SDK agent share ONE resolution order: a weightslab-managed binary + (provisioned on demand for a Node-free ``pip install``) first, then an + ``opencode`` already on PATH, then a managed provision, then the ``npx --yes`` + fallback. Keeping the two paths identical is what stops them disagreeing about + which OpenCode to run for the same workspace. """ - exe = shutil.which("opencode") - if exe: - return [exe] - npx = shutil.which("npx") - if npx: - return [npx, "--yes", "opencode-ai@latest"] - return None + return opencode_process.resolve_opencode_argv() def _opencode_healthy(base_url: str, timeout: float = 1.5) -> bool: @@ -611,13 +651,16 @@ def ensure(self, workspace_dir: str, origin: Optional[str]) -> dict: argv = _resolve_opencode_argv() if argv is None: self._error = ( - "Could not find `opencode` or `npx`. Install Node.js 20+ " - "(which provides npx), or `npm i -g opencode-ai`." + "Could not provision OpenCode: the managed binary download " + "failed (offline?) and no `opencode`/`npx` was found. Restore " + "network access, or install Node.js 20+ (provides npx), or " + "`npm i -g opencode-ai`." ) return {"ok": False, "error": self._error} port = _pick_opencode_port() - cmd = argv + ["serve", "--hostname", "127.0.0.1", "--port", str(port)] + cmd = argv + ["serve", "--hostname", opencode_process.opencode_bind_host(), + "--port", str(port)] for value in _cors_origin_variants(origin): cmd += ["--cors", value] @@ -1740,7 +1783,7 @@ def _start_agent_server(self): like every other local-machine action in this server -- this one starts a process with filesystem access, so it must never be reachable off-host. """ - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1770,7 +1813,7 @@ def _start_loop(self): command in the connected-experiment agent bar). Loopback-only, same reasoning as _start_agent_server -- this also starts/reuses that same process.""" - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1803,7 +1846,7 @@ def _start_loop(self): self._send_json(status, result) def _stop_loop(self, loop_id: str): - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1814,7 +1857,7 @@ def _stop_loop(self, loop_id: str): def _update_loop(self, loop_id: str): """Change a running loop's prompt and/or interval (the panel's Edit action) without stopping and restarting the job.""" - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1837,7 +1880,7 @@ def _get_loop_messages(self, loop_id: str): """Backs a loop tab's read-only transcript: its scrollback is just this job's own OpenCode session history, written to solely by the scheduled check-in (_fire) -- nothing to merge here, only fetching.""" - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1867,7 +1910,7 @@ def _data_query(self): protobuf body to forward, and this one starts from a plain JSON {query, accumulate} instead. """ - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1921,7 +1964,7 @@ def _get_latest_data_query(self): _data_query/_latest_data_query). {"seq": 0} if nothing has run yet this process; the frontend only reacts when seq is NEWER than the last one it already handled.""" - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1933,7 +1976,7 @@ def _track_process(self): _TrackedProcesses' own docstring for why a detached process needs this instead of being reachable through the normal process-tree kill every OTHER child of this server already gets.""" - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return @@ -1978,7 +2021,7 @@ def _start_local_notebook(self): # browser can't do itself; this is the one piece of local-only # control surface that requires it. Loopback-gated like the other # "local machine" actions in this server. - if self.client_address[0] not in _LOOPBACK_ADDRESSES: + if not _client_is_trusted(self.client_address[0]): self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Only reachable from localhost."}) return From 454d039da52729fe620a7736913154f1dc0ea9bd Mon Sep 17 00:00:00 2001 From: Guillaume Date: Wed, 26 Aug 2026 12:26:08 +0200 Subject: [PATCH 2/6] Fix/v2.1 UI fixes (#300) * fix(v2.1): video-gen report media + server-authoritative subview flag - reporting: add a "Generated Media" section (poster thumbnails per media field) so video/image-generation runs show their artifacts instead of an empty report; guard media columns in the Distributions path (no longer mislabelled "no numeric values"); surface a swallowed get_combined_df error as a warning so a broken dataframe isn't silently hidden. - data_service/proto: DataSamplesResponse gains is_subview/view_count/ total_count, stamped from the backend's _is_filtered state on every GetDataSamples, so a fresh client can render the subview warning ribbon with no cached UI state. Regenerated pb2 with grpcio-tools 1.68 (gencode 5.28.1). - tests: report media/distribution-guard coverage. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(v2.1): backend min/max curve decimation so spikes survive 10k->1k get_signal_history_downsampled emitted the earliest-step point per bucket, so a spike between bucket edges (unless separately flagged as marker/note/outlier) was dropped server-side before the browser ever saw it. Now it emits each bucket's min-value AND max-value rows (min/max decimation); the bucket count is halved so the total stays ~max_points. 10k points -> ~1k, with spikes. + test: a non-flagged mid-bucket value spike survives, output stays ~max_points. * Check OS and adapt cmd --- tests/backend/test_logger_scale.py | 40 +++ tests/test_opencode_process.py | 10 + tests/test_reporting_media.py | 65 +++++ weightslab/backend/logger.py | 40 ++- weightslab/proto/experiment_service.proto | 8 + weightslab/proto/experiment_service_pb2.py | 262 ++++++++++---------- weightslab/reporting.py | 146 +++++++++++ weightslab/src.py | 8 +- weightslab/trainer/services/data_service.py | 30 ++- 9 files changed, 469 insertions(+), 140 deletions(-) create mode 100644 tests/test_reporting_media.py diff --git a/tests/backend/test_logger_scale.py b/tests/backend/test_logger_scale.py index d67b8d4c..5a6f8d2f 100644 --- a/tests/backend/test_logger_scale.py +++ b/tests/backend/test_logger_scale.py @@ -345,6 +345,46 @@ def test_special_points_survive_decimation(big_logger): "outlier-bearing steps were decimated away" +def test_value_spike_survives_decimation(tmp_path): + """A tall spike that is NOT flagged (no marker/note/outlier) and does not sit + on a bucket edge must still survive — min/max decimation keeps each bucket's + extreme, whereas earliest-per-bucket dropped it. Also: 10k -> ~max_points.""" + db = tmp_path / "spike.duckdb" + lg = LoggerQueue(register=False, db_path=str(db)) + lg.chkpt_manager = None + n, spike_step, spike_val = 10000, 3737, 999.0 + lg._conn.execute( + f""" + INSERT INTO signals ( + metric_name, experiment_hash, step, metric_value, timestamp, + audit_mode, is_evaluation_marker, split_name, evaluation_tags, + point_note, outliers, outlier_count, sample_count, + trend_value, trend_margin, value_min, value_max, seq) + SELECT 'loss', 'run0', t.i::INTEGER, + CASE WHEN t.i = {spike_step} THEN {spike_val} + ELSE 1.0 + 0.01 * sin(t.i / 9.0) END, + 1787000000 + t.i, FALSE, FALSE, 'train', '[]', + '', '', 0, 32, NULL, NULL, NULL, NULL, t.i + FROM range(0, {n}) AS t(i) + """ + ) + try: + hist = lg.get_signal_history_downsampled(max_points=1000) + entries = [e for per_hash in hist.values() + for steps in per_hash.values() + for lst in steps.values() for e in lst] + values = [e.get("metric_value") for e in entries] + assert any(abs((v or 0) - spike_val) < 1e-6 for v in values), \ + "value spike was decimated away" + # ~max_points, not the full 10k and not a handful. + assert 200 <= len(entries) <= 2200, f"unexpected emitted count {len(entries)}" + finally: + try: + lg.stop_background_flush() + except Exception: + pass + + def test_entries_carry_full_metadata(big_logger): """Each rendered point must arrive with the metadata the UI draws with.""" metric, h = next(iter(big_logger.truth)) diff --git a/tests/test_opencode_process.py b/tests/test_opencode_process.py index 9bc89e6f..f854b6ce 100644 --- a/tests/test_opencode_process.py +++ b/tests/test_opencode_process.py @@ -16,6 +16,7 @@ import json import os import signal +import subprocess import sys import tempfile import unittest @@ -47,6 +48,15 @@ def stop_workspace_server(workspace_dir): return if not pid: return + if os.name == "nt": + try: + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(pid)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except Exception: + pass + return try: os.killpg(os.getpgid(pid), signal.SIGTERM) except OSError: diff --git a/tests/test_reporting_media.py b/tests/test_reporting_media.py new file mode 100644 index 00000000..e551c1ce --- /dev/null +++ b/tests/test_reporting_media.py @@ -0,0 +1,65 @@ +"""Report generation for media (video/image) use cases: the report must surface +generated media instead of ignoring it, and must not mislabel a media column as +an empty numeric signal in the Distributions section.""" +import io +import unittest + +import pandas as pd + +from weightslab import reporting +from weightslab.data import media_store + + +def _png(color=(200, 60, 60)): + from PIL import Image + b = io.BytesIO() + Image.new("RGB", (16, 16), color).save(b, "PNG") + return b.getvalue() + + +class MediaReportTests(unittest.TestCase): + def setUp(self): + media_store.clear() + self.addCleanup(media_store.clear) + for sid in range(3): + media_store.put("pred_video", sid, data=b"FAKE", mime="video/mp4", + kind="video", poster=_png()) + self.df = pd.DataFrame( + {"media:pred_video": [media_store.descriptor_json(media_store.get("pred_video", s)) + for s in range(3)], + "signals//train/fm_loss": [0.5, 0.3, 0.9]}, + index=pd.Index([0, 1, 2], name="sample_id"), + ) + + def test_compute_media_examples_finds_field_and_posters(self): + examples = reporting.compute_media_examples(self.df) + self.assertEqual(len(examples), 1) + ex = examples[0] + self.assertEqual(ex["field"], "pred_video") + self.assertEqual(ex["kind"], "video") + self.assertEqual(ex["count"], 3) + self.assertTrue(ex["thumbnails"], "expected poster thumbnails") + self.assertTrue(ex["thumbnails"][0]["poster_uri"].startswith("data:image/")) + + def test_media_examples_empty_without_media(self): + plain = pd.DataFrame({"signals//loss": [1.0, 2.0]}) + self.assertEqual(reporting.compute_media_examples(plain), []) + + def test_media_section_html_renders_and_is_empty_when_none(self): + html = reporting._media_section_html(reporting.compute_media_examples(self.df)) + self.assertIn("Generated Media", html) + self.assertIn("pred_video", html) + self.assertIn("data:image/", html) + self.assertEqual(reporting._media_section_html([]), "") + + def test_distribution_on_media_column_is_flagged_not_empty_numeric(self): + entries = reporting.compute_distribution_entries(self.df, ["pred_video"], plt=None) + self.assertEqual(len(entries), 1) + self.assertTrue(entries[0].get("is_media")) + card = reporting._distribution_card_html(entries[0], "b0") + self.assertIn("is a media column", card) + self.assertNotIn("No numeric values logged", card) + + +if __name__ == "__main__": + unittest.main() diff --git a/weightslab/backend/logger.py b/weightslab/backend/logger.py index 979715d4..7ce7febe 100644 --- a/weightslab/backend/logger.py +++ b/weightslab/backend/logger.py @@ -1487,10 +1487,13 @@ def get_signal_history_downsampled( actually drawable -- not by the table size. This is what makes a hundred-million-row history openable. - The curve is split into ``max_points`` equal step-buckets and one - representative (the bucket's earliest step) is emitted per bucket, via a - streaming hash aggregate rather than a sort/window. Three further rules - keep the reduced curve faithful: + The curve is split into ``max_points / 2`` equal step-buckets and TWO + representatives — the bucket's minimum-value and maximum-value rows — are + emitted per bucket (min/max decimation), via a streaming hash aggregate + rather than a sort/window. Keeping both extremes is what preserves spikes + that fall between bucket edges (earliest-per-bucket used to drop them); + halving the bucket count keeps the total at ~``max_points`` per curve. + Three further rules keep the reduced curve faithful: * the curve's true first and last steps are always emitted, so endpoints and the x-extent never move under downsampling; @@ -1511,14 +1514,19 @@ def get_signal_history_downsampled( keep_special: emit marker/annotated/outlier rows regardless of bucketing. """ - n_buckets = max(int(max_points or _DEFAULT_MAX_POINTS_PER_CURVE), + # Two representatives per bucket (the min-value and max-value rows, see + # `reps` below) preserve spikes, so halve the bucket count to still net + # ~max_points per curve overall. 10k points -> 500 buckets x {min,max} + # -> ~1000 points, WITH the spikes that plain earliest-per-bucket + # decimation used to drop. + n_buckets = max(int(max_points or _DEFAULT_MAX_POINTS_PER_CURVE) // 2, _MIN_POINTS_PER_CURVE) where, params = self._scope_filters(metric_names, exp_hashes, x_min, x_max) cols = ", ".join(_SIGNAL_READ_COLS) # arg_min(col, step) picks each column from the bucket's earliest-step # row, so a representative is one real row rather than a blend. Rows # sharing a step within a bucket tie arbitrarily -- acceptable for a - # decimated view, and the zoom path resolves them. + # decimated view, and the zoom path resolves them. Used for the endpoints. picks = ", ".join( f"arg_min({c}, step) AS {c}" for c in _SIGNAL_READ_COLS if c not in ("metric_name", "experiment_hash", "step") @@ -1527,6 +1535,18 @@ def get_signal_history_downsampled( f"arg_max({c}, step) AS {c}" for c in _SIGNAL_READ_COLS if c not in ("metric_name", "experiment_hash", "step") ) + # Per-bucket VALUE extremes: the whole row at the bucket's min metric_value + # and the whole row at its max metric_value (step included, via + # arg_min/arg_max over metric_value). This is min/max decimation — a spike + # is by definition its bucket's extreme, so it always survives. + picks_vmin = ", ".join( + f"arg_min({c}, metric_value) AS {c}" for c in _SIGNAL_READ_COLS + if c not in ("metric_name", "experiment_hash") + ) + picks_vmax = ", ".join( + f"arg_max({c}, metric_value) AS {c}" for c in _SIGNAL_READ_COLS + if c not in ("metric_name", "experiment_hash") + ) sql = f""" WITH scoped AS ( SELECT {cols} FROM signals WHERE 1=1{where} @@ -1560,7 +1580,13 @@ def get_signal_history_downsampled( ON s.metric_name = b.m AND s.experiment_hash IS NOT DISTINCT FROM b.h ), reps AS ( - SELECT metric_name, experiment_hash, MIN(step) AS step, {picks} + -- Min/max decimation: keep BOTH the lowest- and highest-value row of + -- each bucket so up- and down-spikes between bucket edges survive + -- (plain earliest-per-bucket dropped them). + SELECT metric_name, experiment_hash, {picks_vmin} + FROM tagged GROUP BY metric_name, experiment_hash, bucket + UNION ALL + SELECT metric_name, experiment_hash, {picks_vmax} FROM tagged GROUP BY metric_name, experiment_hash, bucket ), ends AS ( diff --git a/weightslab/proto/experiment_service.proto b/weightslab/proto/experiment_service.proto index 337c51d4..17386eb8 100644 --- a/weightslab/proto/experiment_service.proto +++ b/weightslab/proto/experiment_service.proto @@ -539,6 +539,14 @@ message DataSamplesResponse { bool success = 1; string message = 2; repeated DataRecord data_records = 3; + // True when the server is currently serving a FILTERED/agent-generated + // subview of the dataset rather than the full dataset. Server-authoritative + // so a fresh client (private window, no cached UI state) can still show the + // "you are viewing a subview" warning ribbon and offer a reset. Mirrors the + // backend's own _is_filtered flag. + bool is_subview = 4; + int64 view_count = 5; // rows in the current (possibly filtered) view + int64 total_count = 6; // rows in the full dataset } // --- Server-side histogram binning --- diff --git a/weightslab/proto/experiment_service_pb2.py b/weightslab/proto/experiment_service_pb2.py index 5030a4df..3709cc25 100644 --- a/weightslab/proto/experiment_service_pb2.py +++ b/weightslab/proto/experiment_service_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)weightslab/proto/experiment_service.proto\"\x81\x02\n\x1aGetLatestLoggerDataRequest\x12\x1c\n\x14request_full_history\x18\x01 \x01(\x08\x12\x12\n\nmax_points\x18\x02 \x01(\x05\x12\x17\n\x0f\x62reak_by_slices\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\x12\x12\n\ngraph_name\x18\x05 \x01(\t\x12\r\n\x05x_min\x18\x06 \x01(\x03\x12\r\n\x05x_max\x18\x07 \x01(\x03\x12\x13\n\x0bhas_x_range\x18\x08 \x01(\x08\x12\x14\n\x0cmetric_names\x18\t \x03(\t\x12\x19\n\x11\x65xperiment_hashes\x18\n \x03(\t\x12\x12\n\nindex_only\x18\x0b \x01(\x08\"\xa2\x01\n\x10SignalCurveIndex\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x12\n\nfirst_step\x18\x03 \x01(\x03\x12\x11\n\tlast_step\x18\x04 \x01(\x03\x12\x13\n\x0bpoint_count\x18\x05 \x01(\x03\x12\x11\n\tvalue_min\x18\x06 \x01(\x01\x12\x11\n\tvalue_max\x18\x07 \x01(\x01\"1\n\rSignalOutlier\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"\xd2\x03\n\x0fLoggerDataPoint\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x11\n\tmodel_age\x18\x02 \x01(\x05\x12\x14\n\x0cmetric_value\x18\x03 \x01(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\x12\x11\n\tsample_id\x18\x06 \x01(\t\x12\x1c\n\x14is_evaluation_marker\x18\x07 \x01(\x08\x12\x12\n\nsplit_name\x18\x08 \x01(\t\x12\x17\n\x0f\x65valuation_tags\x18\t \x03(\t\x12\x12\n\npoint_note\x18\n \x01(\t\x12\x12\n\naudit_mode\x18\x0b \x01(\x08\x12 \n\x08outliers\x18\x0c \x03(\x0b\x32\x0e.SignalOutlier\x12\x15\n\routlier_count\x18\r \x01(\x05\x12\x14\n\x0csample_count\x18\x0e \x01(\x05\x12\x13\n\x0btrend_value\x18\x0f \x01(\x02\x12\x14\n\x0ctrend_margin\x18\x10 \x01(\x02\x12\x16\n\x0ehas_trend_band\x18\x11 \x01(\x08\x12\x11\n\tvalue_min\x18\x12 \x01(\x02\x12\x11\n\tvalue_max\x18\x13 \x01(\x02\x12\x17\n\x0fhas_value_range\x18\x14 \x01(\x08\"\x9a\x01\n\x1bGetLatestLoggerDataResponse\x12 \n\x06points\x18\x01 \x03(\x0b\x32\x10.LoggerDataPoint\x12\x1a\n\x12weightslab_version\x18\x02 \x01(\t\x12!\n\x06\x63urves\x18\x03 \x03(\x0b\x32\x11.SignalCurveIndex\x12\x1a\n\x12\x61pplied_max_points\x18\x04 \x01(\x05\"\x07\n\x05\x45mpty\"/\n\x08NeuronId\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tneuron_id\x18\x02 \x01(\x05\"\x91\x02\n\x0fWeightOperation\x12*\n\x07op_type\x18\x01 \x01(\x0e\x32\x14.WeightOperationTypeH\x00\x88\x01\x01\x12\x15\n\x08layer_id\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1d\n\nneuron_ids\x18\x03 \x03(\x0b\x32\t.NeuronId\x12\x16\n\x0eneurons_to_add\x18\t \x01(\x05\x12 \n\x18zerofy_from_incoming_ids\x18\x0b \x03(\x05\x12\x1c\n\x14zerofy_to_neuron_ids\x18\x0c \x03(\x05\x12+\n\x11zerofy_predicates\x18\r \x03(\x0e\x32\x10.ZerofyPredicateB\n\n\x08_op_typeB\x0b\n\t_layer_id\"_\n\x17WeightsOperationRequest\x12/\n\x10weight_operation\x18\x01 \x01(\x0b\x32\x10.WeightOperationH\x00\x88\x01\x01\x42\x13\n\x11_weight_operation\"<\n\x18WeightsOperationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xc1\x05\n\x0fHyperParameters\x12\x1c\n\x0f\x65xperiment_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12!\n\x14training_steps_to_do\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x17\n\nbatch_size\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12 \n\x13\x66ull_eval_frequency\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12 \n\x13\x63heckpont_frequency\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bis_training\x18\x07 \x01(\x08H\x06\x88\x01\x01\x12\x15\n\x08nb_steps\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x19\n\x0c\x61uditor_mode\x18\t \x01(\x08H\x08\x88\x01\x01\x12\x1d\n\x10train_batch_size\x18\n \x01(\x05H\t\x88\x01\x01\x12\x1b\n\x0eval_batch_size\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x1c\n\x0ftest_batch_size\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x1c\n\x0f\x65valuation_mode\x18\r \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11\x65valuation_config\x18\x0e \x01(\tH\r\x88\x01\x01\x42\x12\n\x10_experiment_nameB\x17\n\x15_training_steps_to_doB\x10\n\x0e_learning_rateB\r\n\x0b_batch_sizeB\x16\n\x14_full_eval_frequencyB\x16\n\x14_checkpont_frequencyB\x0e\n\x0c_is_trainingB\x0b\n\t_nb_stepsB\x0f\n\r_auditor_modeB\x13\n\x11_train_batch_sizeB\x11\n\x0f_val_batch_sizeB\x12\n\x10_test_batch_sizeB\x12\n\x10_evaluation_modeB\x14\n\x12_evaluation_config\",\n\rMetricsStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"~\n\rAnnotatStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x08metadata\x18\x02 \x03(\x0b\x32\x1c.AnnotatStatus.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x90\x02\n\x10TrainingStatusEx\x12\x16\n\ttimestamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x0f\x65xperiment_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tmodel_age\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12+\n\x0emetrics_status\x18\x04 \x01(\x0b\x32\x0e.MetricsStatusH\x03\x88\x01\x01\x12+\n\x0e\x61nnotat_status\x18\x05 \x01(\x0b\x32\x0e.AnnotatStatusH\x04\x88\x01\x01\x42\x0c\n\n_timestampB\x12\n\x10_experiment_nameB\x0c\n\n_model_ageB\x11\n\x0f_metrics_statusB\x11\n\x0f_annotat_status\"]\n\x15HyperParameterCommand\x12/\n\x10hyper_parameters\x18\x01 \x01(\x0b\x32\x10.HyperParametersH\x00\x88\x01\x01\x42\x13\n\x11_hyper_parameters\">\n\x14\x44\x65nySamplesOperation\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\"0\n\x17LoadCheckpointOperation\x12\x15\n\rcheckpoint_id\x18\x01 \x01(\x05\"b\n\x11PlotNoteOperation\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x0c\n\x04note\x18\x04 \x01(\t\"L\n\x17SaveCheckpointOperation\x12\x19\n\x11save_architecture\x18\x01 \x01(\x08\x12\x16\n\x0esave_optimizer\x18\x02 \x01(\x08\"\x1a\n\x18RestartInstanceOperation\"\x8d\x08\n\x0eTrainerCommand\x12\x1c\n\x14get_hyper_parameters\x18\x04 \x01(\x08\x12\x1e\n\x16get_interactive_layers\x18\x05 \x01(\x08\x12\x1d\n\x10get_data_records\x18\x06 \x01(\tH\x00\x88\x01\x01\x12%\n\x18get_single_layer_info_id\x18\x08 \x01(\x05H\x01\x88\x01\x01\x12;\n\x16hyper_parameter_change\x18\x01 \x01(\x0b\x32\x16.HyperParameterCommandH\x02\x88\x01\x01\x12:\n\x16\x64\x65ny_samples_operation\x18\x07 \x01(\x0b\x32\x15.DenySamplesOperationH\x03\x88\x01\x01\x12?\n\x1b\x64\x65ny_eval_samples_operation\x18\n \x01(\x0b\x32\x15.DenySamplesOperationH\x04\x88\x01\x01\x12@\n\x19load_checkpoint_operation\x18\t \x01(\x0b\x32\x18.LoadCheckpointOperationH\x05\x88\x01\x01\x12\x42\n\x1eremove_from_denylist_operation\x18\x0b \x01(\x0b\x32\x15.DenySamplesOperationH\x06\x88\x01\x01\x12G\n#remove_eval_from_denylist_operation\x18\x0c \x01(\x0b\x32\x15.DenySamplesOperationH\x07\x88\x01\x01\x12\x34\n\x13plot_note_operation\x18\r \x01(\x0b\x32\x12.PlotNoteOperationH\x08\x88\x01\x01\x12@\n\x19save_checkpoint_operation\x18\x0e \x01(\x0b\x32\x18.SaveCheckpointOperationH\t\x88\x01\x01\x12\x39\n\x11restart_operation\x18\x0f \x01(\x0b\x32\x19.RestartInstanceOperationH\n\x88\x01\x01\x42\x13\n\x11_get_data_recordsB\x1b\n\x19_get_single_layer_info_idB\x19\n\x17_hyper_parameter_changeB\x19\n\x17_deny_samples_operationB\x1e\n\x1c_deny_eval_samples_operationB\x1c\n\x1a_load_checkpoint_operationB!\n\x1f_remove_from_denylist_operationB&\n$_remove_eval_from_denylist_operationB\x16\n\x14_plot_note_operationB\x1c\n\x1a_save_checkpoint_operationB\x14\n\x12_restart_operation\"\x9d\x01\n\x12HyperParameterDesc\x12\r\n\x05label\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x1c\n\x0fnumerical_value\x18\x04 \x01(\x02H\x00\x88\x01\x01\x12\x19\n\x0cstring_value\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x12\n\x10_numerical_valueB\x0f\n\r_string_value\"\xf2\x02\n\x10NeuronStatistics\x12!\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronIdH\x00\x88\x01\x01\x12\x17\n\nneuron_age\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1f\n\x12train_trigger_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x1e\n\x11\x65val_trigger_rate\x18\x04 \x01(\x02H\x03\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x07 \x01(\x02H\x04\x88\x01\x01\x12\x36\n\x0bincoming_lr\x18\x08 \x03(\x0b\x32!.NeuronStatistics.IncomingLrEntry\x1a\x31\n\x0fIncomingLrEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\x42\x0c\n\n_neuron_idB\r\n\x0b_neuron_ageB\x15\n\x13_train_trigger_rateB\x14\n\x12_eval_trigger_rateB\x10\n\x0e_learning_rate\"\xf0\x02\n\x13LayerRepresentation\x12\x15\n\x08layer_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rneurons_count\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12#\n\x16incoming_neurons_count\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x13\n\x06stride\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12-\n\x12neurons_statistics\x18\n \x03(\x0b\x32\x11.NeuronStatisticsB\x0b\n\t_layer_idB\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x10\n\x0e_neurons_countB\x19\n\x17_incoming_neurons_countB\x0e\n\x0c_kernel_sizeB\t\n\x07_stride\"H\n\x11\x41\x63tivationRequest\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tsample_id\x18\x02 \x01(\t\x12\x0e\n\x06origin\x18\x03 \x01(\t\"H\n\rActivationMap\x12\x11\n\tneuron_id\x18\x01 \x01(\x05\x12\x0e\n\x06values\x18\x02 \x03(\x02\x12\t\n\x01H\x18\x03 \x01(\x05\x12\t\n\x01W\x18\x04 \x01(\x05\"d\n\x12\x41\x63tivationResponse\x12\x12\n\nlayer_type\x18\x01 \x01(\t\x12\x15\n\rneurons_count\x18\x02 \x01(\x05\x12#\n\x0b\x61\x63tivations\x18\x03 \x03(\x0b\x32\x0e.ActivationMap\"\x93\x01\n\tTaskField\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x13\n\tint_value\x18\x03 \x01(\x05H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x05 \x01(\x0cH\x00\x12\x14\n\nbool_value\x18\x06 \x01(\x08H\x00\x42\x07\n\x05value\"\x87\x03\n\x0eRecordMetadata\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x14\n\x0csample_label\x18\x02 \x03(\x05\x12\x19\n\x11sample_prediction\x18\x03 \x03(\x05\x12=\n\x10sample_last_loss\x18\x04 \x03(\x0b\x32#.RecordMetadata.SampleLastLossEntry\x12\x19\n\x11sample_encounters\x18\x05 \x01(\x05\x12\x18\n\x10sample_discarded\x18\x06 \x01(\x08\x12 \n\x0c\x65xtra_fields\x18\x07 \x03(\x0b\x32\n.TaskField\x12\x16\n\x0eprediction_raw\x18\t \x01(\x0c\x12\x11\n\ttask_type\x18\n \x01(\t\x12\x19\n\x11sample_label_text\x18\x0b \x03(\t\x12\x1e\n\x16sample_prediction_text\x18\x0c \x03(\t\x1a\x35\n\x13SampleLastLossEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x93\x01\n\x10SampleStatistics\x12\x13\n\x06origin\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0csample_count\x18\x07 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\ttask_type\x18\t \x01(\t\x12 \n\x07records\x18\x08 \x03(\x0b\x32\x0f.RecordMetadataB\t\n\x07_originB\x0f\n\r_sample_count\"\xe6\x01\n\x0f\x43ommandResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x33\n\x16hyper_parameters_descs\x18\x03 \x03(\x0b\x32\x13.HyperParameterDesc\x12\x33\n\x15layer_representations\x18\x04 \x03(\x0b\x32\x14.LayerRepresentation\x12\x31\n\x11sample_statistics\x18\x05 \x01(\x0b\x32\x11.SampleStatisticsH\x00\x88\x01\x01\x42\x14\n\x12_sample_statistics\"U\n\rSampleRequest\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_origin\"\xad\x02\n\x15SampleRequestResponse\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05label\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x11\n\x04\x64\x61ta\x18\x04 \x01(\x0cH\x03\x88\x01\x01\x12\x1a\n\rerror_message\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x15\n\x08raw_data\x18\x06 \x01(\x0cH\x05\x88\x01\x01\x12\x11\n\x04mask\x18\x07 \x01(\x0cH\x06\x88\x01\x01\x12\x17\n\nprediction\x18\x08 \x01(\x0cH\x07\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_originB\x08\n\x06_labelB\x07\n\x05_dataB\x10\n\x0e_error_messageB\x0b\n\t_raw_dataB\x07\n\x05_maskB\r\n\x0b_prediction\"\x92\x01\n\x12\x42\x61tchSampleRequest\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x19\n\x0cresize_width\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x1a\n\rresize_height\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x0f\n\r_resize_widthB\x10\n\x0e_resize_height\">\n\x13\x42\x61tchSampleResponse\x12\'\n\x07samples\x18\x01 \x03(\x0b\x32\x16.SampleRequestResponse\".\n\x0eWeightsRequest\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\"\x9d\x02\n\x0fWeightsResponse\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x08incoming\x18\x04 \x01(\x05\x12\x10\n\x08outgoing\x18\x05 \x01(\x05\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x0f\n\x07weights\x18\x07 \x03(\x02\x12\x0f\n\x07success\x18\x0b \x01(\x08\x12\x1a\n\rerror_message\x18\x0c \x01(\tH\x03\x88\x01\x01\x42\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x0e\n\x0c_kernel_sizeB\x10\n\x0e_error_message\"R\n\x10\x44\x61taQueryRequest\x12\r\n\x05query\x18\x01 \x01(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\x12\x1b\n\x13is_natural_language\x18\x03 \x01(\x08\"5\n\x11\x43\x61tegoricalTagDef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ncategories\x18\x02 \x03(\t\"\xa9\x02\n\x11\x44\x61taQueryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1d\n\x15number_of_all_samples\x18\x03 \x01(\x05\x12%\n\x1dnumber_of_samples_in_the_loop\x18\x04 \x01(\x05\x12#\n\x1bnumber_of_discarded_samples\x18\x05 \x01(\x05\x12\x13\n\x0bunique_tags\x18\x06 \x03(\t\x12+\n\x11\x61gent_intent_type\x18\x07 \x01(\x0e\x32\x10.AgentIntentType\x12\x17\n\x0f\x61nalysis_result\x18\x08 \x01(\t\x12,\n\x10\x63\x61tegorical_tags\x18\t \x03(\x0b\x32\x12.CategoricalTagDef\"\xc2\x01\n\x12\x44\x61taSamplesRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12 \n\x18include_transformed_data\x18\x03 \x01(\x08\x12\x18\n\x10include_raw_data\x18\x04 \x01(\x08\x12\x19\n\x11stats_to_retrieve\x18\x05 \x03(\t\x12\x14\n\x0cresize_width\x18\x06 \x01(\x05\x12\x15\n\rresize_height\x18\x07 \x01(\x05\"m\n\x08\x44\x61taStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x05\x12\r\n\x05value\x18\x04 \x03(\x02\x12\x14\n\x0cvalue_string\x18\x05 \x01(\t\x12\x11\n\tthumbnail\x18\x06 \x01(\x0c\">\n\nDataRecord\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x1d\n\ndata_stats\x18\x02 \x03(\x0b\x32\t.DataStat\"Z\n\x13\x44\x61taSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12!\n\x0c\x64\x61ta_records\x18\x03 \x03(\x0b\x32\x0b.DataRecord\"C\n\x0fHistogramSubBar\x12\x0e\n\x06origin\x18\x01 \x01(\t\x12\x11\n\tdiscarded\x18\x02 \x01(\x08\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"h\n\x0cHistogramBin\x12\x0b\n\x03min\x18\x01 \x01(\x01\x12\x0b\n\x03max\x18\x02 \x01(\x01\x12\x0b\n\x03\x61vg\x18\x03 \x01(\x01\x12\r\n\x05\x63ount\x18\x04 \x01(\x03\x12\"\n\x08sub_bars\x18\x05 \x03(\x0b\x32\x10.HistogramSubBar\"[\n\x17\x43\x61tegoricalHistogramBar\x12\r\n\x05label\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x03\x12\"\n\x08sub_bars\x18\x03 \x03(\x0b\x32\x10.HistogramSubBar\"4\n\x10HistogramRequest\x12\x0e\n\x06\x63olumn\x18\x01 \x01(\t\x12\x10\n\x08max_bins\x18\x02 \x01(\x05\"\xb2\x01\n\x11HistogramResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\ntotal_rows\x18\x03 \x01(\x03\x12\x1b\n\x04\x62ins\x18\x04 \x03(\x0b\x32\r.HistogramBin\x12\x16\n\x0eis_categorical\x18\x05 \x01(\x08\x12\x32\n\x10\x63\x61tegorical_bars\x18\x06 \x03(\x0b\x32\x18.CategoricalHistogramBar\"W\n\x12GetMetaDataRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12\x17\n\x0fmodal_sample_id\x18\x03 \x01(\t\"\x99\x01\n\x13GetMetaDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1a\n\x12\x61ll_metadata_names\x18\x03 \x03(\t\x12!\n\x0cgrid_records\x18\x04 \x03(\x0b\x32\x0b.DataRecord\x12!\n\x0cmodal_record\x18\x05 \x01(\x0b\x32\x0b.DataRecord\"j\n\x12StepSamplesRequest\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x13\n\x0bmax_samples\x18\x04 \x01(\x05\"{\n\x13StepSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nsample_ids\x18\x03 \x03(\t\x12\x17\n\x0ftotal_available\x18\x04 \x01(\x05\x12\x15\n\rsample_values\x18\x05 \x03(\x02\"Y\n\x1aGetSignalTrajectoryRequest\x12\x13\n\x0bsignal_name\x18\x01 \x01(\t\x12\x12\n\nsample_ids\x18\x02 \x03(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"4\n\x10SignalTrajectory\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x03(\x02\"}\n\x1bGetSignalTrajectoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12\'\n\x0ctrajectories\x18\x04 \x03(\x0b\x32\x11.SignalTrajectory\"Y\n\x11PointCloudRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\x12\r\n\x05\x66ield\x18\x04 \x01(\t\"\xbf\x01\n\x0fPointCloudChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nnum_points\x18\x03 \x01(\x05\x12\x14\n\x0cnum_features\x18\x04 \x01(\x05\x12\x10\n\x08pc_range\x18\x05 \x03(\x02\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\x07 \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x08 \x01(\x05\x12\x15\n\rfeature_names\x18\t \x03(\t\"b\n\x0cMediaRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x0c\n\x04kind\x18\x03 \x01(\t\x12\x12\n\nmax_frames\x18\x04 \x01(\x05\x12\r\n\x05\x66ield\x18\x05 \x01(\t\"\x92\x02\n\nMediaChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\tmime_type\x18\x03 \x01(\t\x12\x13\n\x0b\x66rame_count\x18\x04 \x01(\x05\x12\x0b\n\x03\x66ps\x18\x05 \x01(\x02\x12\x11\n\thas_audio\x18\x06 \x01(\x08\x12\r\n\x05width\x18\x07 \x01(\x05\x12\x0e\n\x06height\x18\x08 \x01(\x05\x12\x13\n\x0btotal_bytes\x18\t \x01(\x05\x12\x18\n\x10\x64uration_seconds\x18\n \x01(\x02\x12\x13\n\x0bsample_rate\x18\x0b \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x0c \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\r \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x0e \x01(\x05\"\x8c\x02\n\x10\x44\x61taEditsRequest\x12\x11\n\tstat_name\x18\x01 \x01(\t\x12\x13\n\x0b\x66loat_value\x18\x02 \x01(\x02\x12\x14\n\x0cstring_value\x18\x03 \x01(\t\x12\x12\n\nbool_value\x18\x04 \x01(\x08\x12\x1d\n\x04type\x18\x05 \x01(\x0e\x32\x0f.SampleEditType\x12\x13\n\x0bsamples_ids\x18\x06 \x03(\t\x12\x16\n\x0esample_origins\x18\x07 \x03(\t\x12\x16\n\x0eis_categorical\x18\x08 \x01(\x08\x12\x12\n\ncategories\x18\t \x03(\t\x12\x15\n\rsample_values\x18\n \x03(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x0b \x01(\t\"5\n\x11\x44\x61taEditsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\":\n\x12\x44\x61taSplitsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0bsplit_names\x18\x02 \x03(\t\"9\n\x13\x41gentHealthResponse\x12\x11\n\tavailable\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"^\n\x16InitializeAgentRequest\x12\x0f\n\x07\x61pi_key\x18\x01 \x01(\t\x12$\n\x08provider\x18\x02 \x01(\x0e\x32\x12.AgentProviderType\x12\r\n\x05model\x18\x03 \x01(\t\";\n\x17InitializeAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"(\n\x17\x43hangeAgentModelRequest\x12\r\n\x05model\x18\x01 \x01(\t\"<\n\x18\x43hangeAgentModelResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x17\n\x15GetAgentModelsRequest\"J\n\x16GetAgentModelsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06models\x18\x02 \x03(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\"6\n\x12ResetAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"=\n\x19\x43learAgentHistoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"?\n\x1b\x43ompactAgentHistoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xe5\x01\n\x1cGetAgentContextUsageResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\r\n\x05model\x18\x03 \x01(\t\x12\x16\n\x0e\x63ontext_window\x18\x04 \x01(\x03\x12\x14\n\x0cinput_tokens\x18\x05 \x01(\x03\x12\x15\n\routput_tokens\x18\x06 \x01(\x03\x12\x18\n\x10reasoning_tokens\x18\x07 \x01(\x03\x12\x19\n\x11\x63\x61\x63he_read_tokens\x18\x08 \x01(\x03\x12\x1a\n\x12\x63\x61\x63he_write_tokens\x18\t \x01(\x03\"3\n\x18RestoreCheckpointRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\"=\n\x19RestoreCheckpointResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xa8\x01\n\x11\x45xperimentRunInfo\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_name\x18\x02 \x01(\t\x12\r\n\x05notes\x18\x03 \x01(\t\x12\x0f\n\x07\x63reated\x18\x04 \x01(\t\x12\x11\n\tlast_used\x18\x05 \x01(\t\x12\x1a\n\x12latest_weight_step\x18\x06 \x01(\x05\x12\x12\n\nis_current\x18\x07 \x01(\x08\"\x1b\n\x19ListExperimentRunsRequest\">\n\x1aListExperimentRunsResponse\x12 \n\x04runs\x18\x01 \x03(\x0b\x32\x12.ExperimentRunInfo\"G\n\x1aRenameExperimentRunRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\x12\x10\n\x08new_name\x18\x02 \x01(\t\"?\n\x1bRenameExperimentRunResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"F\n\x1cSetExperimentRunNotesRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\x12\r\n\x05notes\x18\x02 \x01(\t\"A\n\x1dSetExperimentRunNotesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"R\n\x18TriggerEvaluationRequest\x12\x12\n\nsplit_name\x18\x01 \x01(\t\x12\x0c\n\x04tags\x18\x02 \x03(\t\x12\x14\n\x0cuse_full_set\x18\x03 \x01(\x08\"=\n\x19TriggerEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x1c\n\x1aGetEvaluationStatusRequest\"\x81\x01\n\x1bGetEvaluationStatusResponse\x12\x0e\n\x06status\x18\x01 \x01(\t\x12\x0f\n\x07\x63urrent\x18\x02 \x01(\x05\x12\r\n\x05total\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nsplit_name\x18\x06 \x01(\t\")\n\x17\x43\x61ncelEvaluationRequest\x12\x0e\n\x06reason\x18\x01 \x01(\t\"<\n\x18\x43\x61ncelEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"7\n\x16RunNotebookCellRequest\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x0f\n\x07\x63\x65ll_id\x18\x02 \x01(\t\"2\n\x10NotebookCellDone\x12\x12\n\nexec_count\x18\x01 \x01(\x05\x12\n\n\x02ok\x18\x02 \x01(\x08\"\x1e\n\x1cInterruptNotebookCellRequest\":\n\x1dInterruptNotebookCellResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xbd\x01\n\x11NotebookCellChunk\x12\x0f\n\x07\x63\x65ll_id\x18\x01 \x01(\t\x12\x10\n\x06stdout\x18\x02 \x01(\tH\x00\x12\x10\n\x06stderr\x18\x03 \x01(\tH\x00\x12\x15\n\x0bresult_text\x18\x04 \x01(\tH\x00\x12\x13\n\timage_png\x18\x05 \x01(\x0cH\x00\x12\x19\n\x0f\x65rror_traceback\x18\x06 \x01(\tH\x00\x12!\n\x04\x64one\x18\x07 \x01(\x0b\x32\x11.NotebookCellDoneH\x00\x42\t\n\x07payload\"S\n\x10NotebookResponse\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0f\n\x07\x65xisted\x18\x02 \x01(\x08\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"7\n\x13SaveNotebookRequest\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"M\n\x14SaveNotebookResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"C\n\x1bGenerateNotebookCodeRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x14\n\x0c\x63ontext_code\x18\x02 \x01(\t\"\\\n\x1cGenerateNotebookCodeResponse\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x13\n\x0b\x65xplanation\x18\x02 \x01(\t\x12\n\n\x02ok\x18\x03 \x01(\x08\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"~\n\x18\x45xportAnnotationsRequest\x12\'\n\x06\x66ormat\x18\x01 \x01(\x0e\x32\x17.AnnotationExportFormat\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x1b\n\x13include_predictions\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\"\x88\x01\n\x19\x45xportAnnotationsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0f\n\x07payload\x18\x03 \x01(\x0c\x12\x10\n\x08\x66ilename\x18\x04 \x01(\t\x12\x11\n\tmime_type\x18\x05 \x01(\t\x12\x13\n\x0bimage_count\x18\x06 \x01(\x05*d\n\x13WeightOperationType\x12\n\n\x06ZEROFY\x10\x00\x12\x10\n\x0cREINITIALIZE\x10\x01\x12\n\n\x06\x46REEZE\x10\x02\x12\x12\n\x0eREMOVE_NEURONS\x10\t\x12\x0f\n\x0b\x41\x44\x44_NEURONS\x10\n*o\n\x0fZerofyPredicate\x12\x19\n\x15ZEROFY_PREDICATE_NONE\x10\x00\x12 \n\x1cZEROFY_PREDICATE_WITH_FROZEN\x10\x01\x12\x1f\n\x1bZEROFY_PREDICATE_WITH_OLDER\x10\x02*M\n\x0f\x41gentIntentType\x12\x12\n\x0eINTENT_UNKNOWN\x10\x00\x12\x11\n\rINTENT_FILTER\x10\x01\x12\x13\n\x0fINTENT_ANALYSIS\x10\x02*I\n\x0eSampleEditType\x12\x11\n\rEDIT_OVERRIDE\x10\x00\x12\x13\n\x0f\x45\x44IT_ACCUMULATE\x10\x01\x12\x0f\n\x0b\x45\x44IT_REMOVE\x10\x02*C\n\x11\x41gentProviderType\x12\x17\n\x13PROVIDER_OPENROUTER\x10\x00\x12\x15\n\x11PROVIDER_OPENCODE\x10\x01*m\n\x16\x41nnotationExportFormat\x12\x16\n\x12\x45XPORT_FORMAT_CVAT\x10\x00\x12\x1e\n\x1a\x45XPORT_FORMAT_LABEL_STUDIO\x10\x01\x12\x1b\n\x17\x45XPORT_FORMAT_V7_DARWIN\x10\x02\x32\xfe\x12\n\x11\x45xperimentService\x12P\n\x13GetLatestLoggerData\x12\x1b.GetLatestLoggerDataRequest\x1a\x1c.GetLatestLoggerDataResponse\x12\x36\n\x11\x45xperimentCommand\x12\x0f.TrainerCommand\x1a\x10.CommandResponse\x12H\n\x11ManipulateWeights\x12\x18.WeightsOperationRequest\x1a\x19.WeightsOperationResponse\x12/\n\nGetWeights\x12\x0f.WeightsRequest\x1a\x10.WeightsResponse\x12\x39\n\x0eGetActivations\x12\x12.ActivationRequest\x1a\x13.ActivationResponse\x12\x37\n\nGetSamples\x12\x13.BatchSampleRequest\x1a\x14.BatchSampleResponse\x12\x37\n\x0e\x41pplyDataQuery\x12\x11.DataQueryRequest\x1a\x12.DataQueryResponse\x12;\n\x0eGetDataSamples\x12\x13.DataSamplesRequest\x1a\x14.DataSamplesResponse\x12\x35\n\x0cGetHistogram\x12\x11.HistogramRequest\x1a\x12.HistogramResponse\x12\x38\n\x0bGetMetaData\x12\x13.GetMetaDataRequest\x1a\x14.GetMetaDataResponse\x12P\n\x13GetSignalTrajectory\x12\x1b.GetSignalTrajectoryRequest\x1a\x1c.GetSignalTrajectoryResponse\x12;\n\x0eGetStepSamples\x12\x13.StepSamplesRequest\x1a\x14.StepSamplesResponse\x12\x37\n\rGetPointCloud\x12\x12.PointCloudRequest\x1a\x10.PointCloudChunk0\x01\x12(\n\x08GetMedia\x12\r.MediaRequest\x1a\x0b.MediaChunk0\x01\x12\x37\n\x0e\x45\x64itDataSample\x12\x11.DataEditsRequest\x1a\x12.DataEditsResponse\x12,\n\rGetDataSplits\x12\x06.Empty\x1a\x13.DataSplitsResponse\x12\x30\n\x10\x43heckAgentHealth\x12\x06.Empty\x1a\x14.AgentHealthResponse\x12\x44\n\x0fInitializeAgent\x12\x17.InitializeAgentRequest\x1a\x18.InitializeAgentResponse\x12G\n\x10\x43hangeAgentModel\x12\x18.ChangeAgentModelRequest\x1a\x19.ChangeAgentModelResponse\x12\x41\n\x0eGetAgentModels\x12\x16.GetAgentModelsRequest\x1a\x17.GetAgentModelsResponse\x12)\n\nResetAgent\x12\x06.Empty\x1a\x13.ResetAgentResponse\x12\x37\n\x11\x43learAgentHistory\x12\x06.Empty\x1a\x1a.ClearAgentHistoryResponse\x12;\n\x13\x43ompactAgentHistory\x12\x06.Empty\x1a\x1c.CompactAgentHistoryResponse\x12=\n\x14GetAgentContextUsage\x12\x06.Empty\x1a\x1d.GetAgentContextUsageResponse\x12@\n\x0fRunNotebookCell\x12\x17.RunNotebookCellRequest\x1a\x12.NotebookCellChunk0\x01\x12V\n\x15InterruptNotebookCell\x12\x1d.InterruptNotebookCellRequest\x1a\x1e.InterruptNotebookCellResponse\x12(\n\x0bGetNotebook\x12\x06.Empty\x1a\x11.NotebookResponse\x12;\n\x0cSaveNotebook\x12\x14.SaveNotebookRequest\x1a\x15.SaveNotebookResponse\x12S\n\x14GenerateNotebookCode\x12\x1c.GenerateNotebookCodeRequest\x1a\x1d.GenerateNotebookCodeResponse\x12J\n\x11RestoreCheckpoint\x12\x19.RestoreCheckpointRequest\x1a\x1a.RestoreCheckpointResponse\x12M\n\x12ListExperimentRuns\x12\x1a.ListExperimentRunsRequest\x1a\x1b.ListExperimentRunsResponse\x12P\n\x13RenameExperimentRun\x12\x1b.RenameExperimentRunRequest\x1a\x1c.RenameExperimentRunResponse\x12V\n\x15SetExperimentRunNotes\x12\x1d.SetExperimentRunNotesRequest\x1a\x1e.SetExperimentRunNotesResponse\x12J\n\x11TriggerEvaluation\x12\x19.TriggerEvaluationRequest\x1a\x1a.TriggerEvaluationResponse\x12P\n\x13GetEvaluationStatus\x12\x1b.GetEvaluationStatusRequest\x1a\x1c.GetEvaluationStatusResponse\x12G\n\x10\x43\x61ncelEvaluation\x12\x18.CancelEvaluationRequest\x1a\x19.CancelEvaluationResponse\x12J\n\x11\x45xportAnnotations\x12\x19.ExportAnnotationsRequest\x1a\x1a.ExportAnnotationsResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)weightslab/proto/experiment_service.proto\"\x81\x02\n\x1aGetLatestLoggerDataRequest\x12\x1c\n\x14request_full_history\x18\x01 \x01(\x08\x12\x12\n\nmax_points\x18\x02 \x01(\x05\x12\x17\n\x0f\x62reak_by_slices\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\x12\x12\n\ngraph_name\x18\x05 \x01(\t\x12\r\n\x05x_min\x18\x06 \x01(\x03\x12\r\n\x05x_max\x18\x07 \x01(\x03\x12\x13\n\x0bhas_x_range\x18\x08 \x01(\x08\x12\x14\n\x0cmetric_names\x18\t \x03(\t\x12\x19\n\x11\x65xperiment_hashes\x18\n \x03(\t\x12\x12\n\nindex_only\x18\x0b \x01(\x08\"\xa2\x01\n\x10SignalCurveIndex\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x12\n\nfirst_step\x18\x03 \x01(\x03\x12\x11\n\tlast_step\x18\x04 \x01(\x03\x12\x13\n\x0bpoint_count\x18\x05 \x01(\x03\x12\x11\n\tvalue_min\x18\x06 \x01(\x01\x12\x11\n\tvalue_max\x18\x07 \x01(\x01\"1\n\rSignalOutlier\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"\xd2\x03\n\x0fLoggerDataPoint\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x11\n\tmodel_age\x18\x02 \x01(\x05\x12\x14\n\x0cmetric_value\x18\x03 \x01(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\x12\x11\n\tsample_id\x18\x06 \x01(\t\x12\x1c\n\x14is_evaluation_marker\x18\x07 \x01(\x08\x12\x12\n\nsplit_name\x18\x08 \x01(\t\x12\x17\n\x0f\x65valuation_tags\x18\t \x03(\t\x12\x12\n\npoint_note\x18\n \x01(\t\x12\x12\n\naudit_mode\x18\x0b \x01(\x08\x12 \n\x08outliers\x18\x0c \x03(\x0b\x32\x0e.SignalOutlier\x12\x15\n\routlier_count\x18\r \x01(\x05\x12\x14\n\x0csample_count\x18\x0e \x01(\x05\x12\x13\n\x0btrend_value\x18\x0f \x01(\x02\x12\x14\n\x0ctrend_margin\x18\x10 \x01(\x02\x12\x16\n\x0ehas_trend_band\x18\x11 \x01(\x08\x12\x11\n\tvalue_min\x18\x12 \x01(\x02\x12\x11\n\tvalue_max\x18\x13 \x01(\x02\x12\x17\n\x0fhas_value_range\x18\x14 \x01(\x08\"\x9a\x01\n\x1bGetLatestLoggerDataResponse\x12 \n\x06points\x18\x01 \x03(\x0b\x32\x10.LoggerDataPoint\x12\x1a\n\x12weightslab_version\x18\x02 \x01(\t\x12!\n\x06\x63urves\x18\x03 \x03(\x0b\x32\x11.SignalCurveIndex\x12\x1a\n\x12\x61pplied_max_points\x18\x04 \x01(\x05\"\x07\n\x05\x45mpty\"/\n\x08NeuronId\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tneuron_id\x18\x02 \x01(\x05\"\x91\x02\n\x0fWeightOperation\x12*\n\x07op_type\x18\x01 \x01(\x0e\x32\x14.WeightOperationTypeH\x00\x88\x01\x01\x12\x15\n\x08layer_id\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1d\n\nneuron_ids\x18\x03 \x03(\x0b\x32\t.NeuronId\x12\x16\n\x0eneurons_to_add\x18\t \x01(\x05\x12 \n\x18zerofy_from_incoming_ids\x18\x0b \x03(\x05\x12\x1c\n\x14zerofy_to_neuron_ids\x18\x0c \x03(\x05\x12+\n\x11zerofy_predicates\x18\r \x03(\x0e\x32\x10.ZerofyPredicateB\n\n\x08_op_typeB\x0b\n\t_layer_id\"_\n\x17WeightsOperationRequest\x12/\n\x10weight_operation\x18\x01 \x01(\x0b\x32\x10.WeightOperationH\x00\x88\x01\x01\x42\x13\n\x11_weight_operation\"<\n\x18WeightsOperationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xc1\x05\n\x0fHyperParameters\x12\x1c\n\x0f\x65xperiment_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12!\n\x14training_steps_to_do\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x17\n\nbatch_size\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12 \n\x13\x66ull_eval_frequency\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12 \n\x13\x63heckpont_frequency\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bis_training\x18\x07 \x01(\x08H\x06\x88\x01\x01\x12\x15\n\x08nb_steps\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x19\n\x0c\x61uditor_mode\x18\t \x01(\x08H\x08\x88\x01\x01\x12\x1d\n\x10train_batch_size\x18\n \x01(\x05H\t\x88\x01\x01\x12\x1b\n\x0eval_batch_size\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x1c\n\x0ftest_batch_size\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x1c\n\x0f\x65valuation_mode\x18\r \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11\x65valuation_config\x18\x0e \x01(\tH\r\x88\x01\x01\x42\x12\n\x10_experiment_nameB\x17\n\x15_training_steps_to_doB\x10\n\x0e_learning_rateB\r\n\x0b_batch_sizeB\x16\n\x14_full_eval_frequencyB\x16\n\x14_checkpont_frequencyB\x0e\n\x0c_is_trainingB\x0b\n\t_nb_stepsB\x0f\n\r_auditor_modeB\x13\n\x11_train_batch_sizeB\x11\n\x0f_val_batch_sizeB\x12\n\x10_test_batch_sizeB\x12\n\x10_evaluation_modeB\x14\n\x12_evaluation_config\",\n\rMetricsStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"~\n\rAnnotatStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x08metadata\x18\x02 \x03(\x0b\x32\x1c.AnnotatStatus.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x90\x02\n\x10TrainingStatusEx\x12\x16\n\ttimestamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x0f\x65xperiment_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tmodel_age\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12+\n\x0emetrics_status\x18\x04 \x01(\x0b\x32\x0e.MetricsStatusH\x03\x88\x01\x01\x12+\n\x0e\x61nnotat_status\x18\x05 \x01(\x0b\x32\x0e.AnnotatStatusH\x04\x88\x01\x01\x42\x0c\n\n_timestampB\x12\n\x10_experiment_nameB\x0c\n\n_model_ageB\x11\n\x0f_metrics_statusB\x11\n\x0f_annotat_status\"]\n\x15HyperParameterCommand\x12/\n\x10hyper_parameters\x18\x01 \x01(\x0b\x32\x10.HyperParametersH\x00\x88\x01\x01\x42\x13\n\x11_hyper_parameters\">\n\x14\x44\x65nySamplesOperation\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\"0\n\x17LoadCheckpointOperation\x12\x15\n\rcheckpoint_id\x18\x01 \x01(\x05\"b\n\x11PlotNoteOperation\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x0c\n\x04note\x18\x04 \x01(\t\"L\n\x17SaveCheckpointOperation\x12\x19\n\x11save_architecture\x18\x01 \x01(\x08\x12\x16\n\x0esave_optimizer\x18\x02 \x01(\x08\"\x1a\n\x18RestartInstanceOperation\"\x8d\x08\n\x0eTrainerCommand\x12\x1c\n\x14get_hyper_parameters\x18\x04 \x01(\x08\x12\x1e\n\x16get_interactive_layers\x18\x05 \x01(\x08\x12\x1d\n\x10get_data_records\x18\x06 \x01(\tH\x00\x88\x01\x01\x12%\n\x18get_single_layer_info_id\x18\x08 \x01(\x05H\x01\x88\x01\x01\x12;\n\x16hyper_parameter_change\x18\x01 \x01(\x0b\x32\x16.HyperParameterCommandH\x02\x88\x01\x01\x12:\n\x16\x64\x65ny_samples_operation\x18\x07 \x01(\x0b\x32\x15.DenySamplesOperationH\x03\x88\x01\x01\x12?\n\x1b\x64\x65ny_eval_samples_operation\x18\n \x01(\x0b\x32\x15.DenySamplesOperationH\x04\x88\x01\x01\x12@\n\x19load_checkpoint_operation\x18\t \x01(\x0b\x32\x18.LoadCheckpointOperationH\x05\x88\x01\x01\x12\x42\n\x1eremove_from_denylist_operation\x18\x0b \x01(\x0b\x32\x15.DenySamplesOperationH\x06\x88\x01\x01\x12G\n#remove_eval_from_denylist_operation\x18\x0c \x01(\x0b\x32\x15.DenySamplesOperationH\x07\x88\x01\x01\x12\x34\n\x13plot_note_operation\x18\r \x01(\x0b\x32\x12.PlotNoteOperationH\x08\x88\x01\x01\x12@\n\x19save_checkpoint_operation\x18\x0e \x01(\x0b\x32\x18.SaveCheckpointOperationH\t\x88\x01\x01\x12\x39\n\x11restart_operation\x18\x0f \x01(\x0b\x32\x19.RestartInstanceOperationH\n\x88\x01\x01\x42\x13\n\x11_get_data_recordsB\x1b\n\x19_get_single_layer_info_idB\x19\n\x17_hyper_parameter_changeB\x19\n\x17_deny_samples_operationB\x1e\n\x1c_deny_eval_samples_operationB\x1c\n\x1a_load_checkpoint_operationB!\n\x1f_remove_from_denylist_operationB&\n$_remove_eval_from_denylist_operationB\x16\n\x14_plot_note_operationB\x1c\n\x1a_save_checkpoint_operationB\x14\n\x12_restart_operation\"\x9d\x01\n\x12HyperParameterDesc\x12\r\n\x05label\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x1c\n\x0fnumerical_value\x18\x04 \x01(\x02H\x00\x88\x01\x01\x12\x19\n\x0cstring_value\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x12\n\x10_numerical_valueB\x0f\n\r_string_value\"\xf2\x02\n\x10NeuronStatistics\x12!\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronIdH\x00\x88\x01\x01\x12\x17\n\nneuron_age\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1f\n\x12train_trigger_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x1e\n\x11\x65val_trigger_rate\x18\x04 \x01(\x02H\x03\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x07 \x01(\x02H\x04\x88\x01\x01\x12\x36\n\x0bincoming_lr\x18\x08 \x03(\x0b\x32!.NeuronStatistics.IncomingLrEntry\x1a\x31\n\x0fIncomingLrEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\x42\x0c\n\n_neuron_idB\r\n\x0b_neuron_ageB\x15\n\x13_train_trigger_rateB\x14\n\x12_eval_trigger_rateB\x10\n\x0e_learning_rate\"\xf0\x02\n\x13LayerRepresentation\x12\x15\n\x08layer_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rneurons_count\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12#\n\x16incoming_neurons_count\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x13\n\x06stride\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12-\n\x12neurons_statistics\x18\n \x03(\x0b\x32\x11.NeuronStatisticsB\x0b\n\t_layer_idB\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x10\n\x0e_neurons_countB\x19\n\x17_incoming_neurons_countB\x0e\n\x0c_kernel_sizeB\t\n\x07_stride\"H\n\x11\x41\x63tivationRequest\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tsample_id\x18\x02 \x01(\t\x12\x0e\n\x06origin\x18\x03 \x01(\t\"H\n\rActivationMap\x12\x11\n\tneuron_id\x18\x01 \x01(\x05\x12\x0e\n\x06values\x18\x02 \x03(\x02\x12\t\n\x01H\x18\x03 \x01(\x05\x12\t\n\x01W\x18\x04 \x01(\x05\"d\n\x12\x41\x63tivationResponse\x12\x12\n\nlayer_type\x18\x01 \x01(\t\x12\x15\n\rneurons_count\x18\x02 \x01(\x05\x12#\n\x0b\x61\x63tivations\x18\x03 \x03(\x0b\x32\x0e.ActivationMap\"\x93\x01\n\tTaskField\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x13\n\tint_value\x18\x03 \x01(\x05H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x05 \x01(\x0cH\x00\x12\x14\n\nbool_value\x18\x06 \x01(\x08H\x00\x42\x07\n\x05value\"\x87\x03\n\x0eRecordMetadata\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x14\n\x0csample_label\x18\x02 \x03(\x05\x12\x19\n\x11sample_prediction\x18\x03 \x03(\x05\x12=\n\x10sample_last_loss\x18\x04 \x03(\x0b\x32#.RecordMetadata.SampleLastLossEntry\x12\x19\n\x11sample_encounters\x18\x05 \x01(\x05\x12\x18\n\x10sample_discarded\x18\x06 \x01(\x08\x12 \n\x0c\x65xtra_fields\x18\x07 \x03(\x0b\x32\n.TaskField\x12\x16\n\x0eprediction_raw\x18\t \x01(\x0c\x12\x11\n\ttask_type\x18\n \x01(\t\x12\x19\n\x11sample_label_text\x18\x0b \x03(\t\x12\x1e\n\x16sample_prediction_text\x18\x0c \x03(\t\x1a\x35\n\x13SampleLastLossEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x93\x01\n\x10SampleStatistics\x12\x13\n\x06origin\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0csample_count\x18\x07 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\ttask_type\x18\t \x01(\t\x12 \n\x07records\x18\x08 \x03(\x0b\x32\x0f.RecordMetadataB\t\n\x07_originB\x0f\n\r_sample_count\"\xe6\x01\n\x0f\x43ommandResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x33\n\x16hyper_parameters_descs\x18\x03 \x03(\x0b\x32\x13.HyperParameterDesc\x12\x33\n\x15layer_representations\x18\x04 \x03(\x0b\x32\x14.LayerRepresentation\x12\x31\n\x11sample_statistics\x18\x05 \x01(\x0b\x32\x11.SampleStatisticsH\x00\x88\x01\x01\x42\x14\n\x12_sample_statistics\"U\n\rSampleRequest\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_origin\"\xad\x02\n\x15SampleRequestResponse\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05label\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x11\n\x04\x64\x61ta\x18\x04 \x01(\x0cH\x03\x88\x01\x01\x12\x1a\n\rerror_message\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x15\n\x08raw_data\x18\x06 \x01(\x0cH\x05\x88\x01\x01\x12\x11\n\x04mask\x18\x07 \x01(\x0cH\x06\x88\x01\x01\x12\x17\n\nprediction\x18\x08 \x01(\x0cH\x07\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_originB\x08\n\x06_labelB\x07\n\x05_dataB\x10\n\x0e_error_messageB\x0b\n\t_raw_dataB\x07\n\x05_maskB\r\n\x0b_prediction\"\x92\x01\n\x12\x42\x61tchSampleRequest\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x19\n\x0cresize_width\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x1a\n\rresize_height\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x0f\n\r_resize_widthB\x10\n\x0e_resize_height\">\n\x13\x42\x61tchSampleResponse\x12\'\n\x07samples\x18\x01 \x03(\x0b\x32\x16.SampleRequestResponse\".\n\x0eWeightsRequest\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\"\x9d\x02\n\x0fWeightsResponse\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x08incoming\x18\x04 \x01(\x05\x12\x10\n\x08outgoing\x18\x05 \x01(\x05\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x0f\n\x07weights\x18\x07 \x03(\x02\x12\x0f\n\x07success\x18\x0b \x01(\x08\x12\x1a\n\rerror_message\x18\x0c \x01(\tH\x03\x88\x01\x01\x42\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x0e\n\x0c_kernel_sizeB\x10\n\x0e_error_message\"R\n\x10\x44\x61taQueryRequest\x12\r\n\x05query\x18\x01 \x01(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\x12\x1b\n\x13is_natural_language\x18\x03 \x01(\x08\"5\n\x11\x43\x61tegoricalTagDef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ncategories\x18\x02 \x03(\t\"\xa9\x02\n\x11\x44\x61taQueryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1d\n\x15number_of_all_samples\x18\x03 \x01(\x05\x12%\n\x1dnumber_of_samples_in_the_loop\x18\x04 \x01(\x05\x12#\n\x1bnumber_of_discarded_samples\x18\x05 \x01(\x05\x12\x13\n\x0bunique_tags\x18\x06 \x03(\t\x12+\n\x11\x61gent_intent_type\x18\x07 \x01(\x0e\x32\x10.AgentIntentType\x12\x17\n\x0f\x61nalysis_result\x18\x08 \x01(\t\x12,\n\x10\x63\x61tegorical_tags\x18\t \x03(\x0b\x32\x12.CategoricalTagDef\"\xc2\x01\n\x12\x44\x61taSamplesRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12 \n\x18include_transformed_data\x18\x03 \x01(\x08\x12\x18\n\x10include_raw_data\x18\x04 \x01(\x08\x12\x19\n\x11stats_to_retrieve\x18\x05 \x03(\t\x12\x14\n\x0cresize_width\x18\x06 \x01(\x05\x12\x15\n\rresize_height\x18\x07 \x01(\x05\"m\n\x08\x44\x61taStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x05\x12\r\n\x05value\x18\x04 \x03(\x02\x12\x14\n\x0cvalue_string\x18\x05 \x01(\t\x12\x11\n\tthumbnail\x18\x06 \x01(\x0c\">\n\nDataRecord\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x1d\n\ndata_stats\x18\x02 \x03(\x0b\x32\t.DataStat\"\x97\x01\n\x13\x44\x61taSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12!\n\x0c\x64\x61ta_records\x18\x03 \x03(\x0b\x32\x0b.DataRecord\x12\x12\n\nis_subview\x18\x04 \x01(\x08\x12\x12\n\nview_count\x18\x05 \x01(\x03\x12\x13\n\x0btotal_count\x18\x06 \x01(\x03\"C\n\x0fHistogramSubBar\x12\x0e\n\x06origin\x18\x01 \x01(\t\x12\x11\n\tdiscarded\x18\x02 \x01(\x08\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"h\n\x0cHistogramBin\x12\x0b\n\x03min\x18\x01 \x01(\x01\x12\x0b\n\x03max\x18\x02 \x01(\x01\x12\x0b\n\x03\x61vg\x18\x03 \x01(\x01\x12\r\n\x05\x63ount\x18\x04 \x01(\x03\x12\"\n\x08sub_bars\x18\x05 \x03(\x0b\x32\x10.HistogramSubBar\"[\n\x17\x43\x61tegoricalHistogramBar\x12\r\n\x05label\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x03\x12\"\n\x08sub_bars\x18\x03 \x03(\x0b\x32\x10.HistogramSubBar\"4\n\x10HistogramRequest\x12\x0e\n\x06\x63olumn\x18\x01 \x01(\t\x12\x10\n\x08max_bins\x18\x02 \x01(\x05\"\xb2\x01\n\x11HistogramResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\ntotal_rows\x18\x03 \x01(\x03\x12\x1b\n\x04\x62ins\x18\x04 \x03(\x0b\x32\r.HistogramBin\x12\x16\n\x0eis_categorical\x18\x05 \x01(\x08\x12\x32\n\x10\x63\x61tegorical_bars\x18\x06 \x03(\x0b\x32\x18.CategoricalHistogramBar\"W\n\x12GetMetaDataRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12\x17\n\x0fmodal_sample_id\x18\x03 \x01(\t\"\x99\x01\n\x13GetMetaDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1a\n\x12\x61ll_metadata_names\x18\x03 \x03(\t\x12!\n\x0cgrid_records\x18\x04 \x03(\x0b\x32\x0b.DataRecord\x12!\n\x0cmodal_record\x18\x05 \x01(\x0b\x32\x0b.DataRecord\"j\n\x12StepSamplesRequest\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x13\n\x0bmax_samples\x18\x04 \x01(\x05\"{\n\x13StepSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nsample_ids\x18\x03 \x03(\t\x12\x17\n\x0ftotal_available\x18\x04 \x01(\x05\x12\x15\n\rsample_values\x18\x05 \x03(\x02\"Y\n\x1aGetSignalTrajectoryRequest\x12\x13\n\x0bsignal_name\x18\x01 \x01(\t\x12\x12\n\nsample_ids\x18\x02 \x03(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"4\n\x10SignalTrajectory\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x03(\x02\"}\n\x1bGetSignalTrajectoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12\'\n\x0ctrajectories\x18\x04 \x03(\x0b\x32\x11.SignalTrajectory\"Y\n\x11PointCloudRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\x12\r\n\x05\x66ield\x18\x04 \x01(\t\"\xbf\x01\n\x0fPointCloudChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nnum_points\x18\x03 \x01(\x05\x12\x14\n\x0cnum_features\x18\x04 \x01(\x05\x12\x10\n\x08pc_range\x18\x05 \x03(\x02\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\x07 \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x08 \x01(\x05\x12\x15\n\rfeature_names\x18\t \x03(\t\"b\n\x0cMediaRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x0c\n\x04kind\x18\x03 \x01(\t\x12\x12\n\nmax_frames\x18\x04 \x01(\x05\x12\r\n\x05\x66ield\x18\x05 \x01(\t\"\x92\x02\n\nMediaChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\tmime_type\x18\x03 \x01(\t\x12\x13\n\x0b\x66rame_count\x18\x04 \x01(\x05\x12\x0b\n\x03\x66ps\x18\x05 \x01(\x02\x12\x11\n\thas_audio\x18\x06 \x01(\x08\x12\r\n\x05width\x18\x07 \x01(\x05\x12\x0e\n\x06height\x18\x08 \x01(\x05\x12\x13\n\x0btotal_bytes\x18\t \x01(\x05\x12\x18\n\x10\x64uration_seconds\x18\n \x01(\x02\x12\x13\n\x0bsample_rate\x18\x0b \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x0c \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\r \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x0e \x01(\x05\"\x8c\x02\n\x10\x44\x61taEditsRequest\x12\x11\n\tstat_name\x18\x01 \x01(\t\x12\x13\n\x0b\x66loat_value\x18\x02 \x01(\x02\x12\x14\n\x0cstring_value\x18\x03 \x01(\t\x12\x12\n\nbool_value\x18\x04 \x01(\x08\x12\x1d\n\x04type\x18\x05 \x01(\x0e\x32\x0f.SampleEditType\x12\x13\n\x0bsamples_ids\x18\x06 \x03(\t\x12\x16\n\x0esample_origins\x18\x07 \x03(\t\x12\x16\n\x0eis_categorical\x18\x08 \x01(\x08\x12\x12\n\ncategories\x18\t \x03(\t\x12\x15\n\rsample_values\x18\n \x03(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x0b \x01(\t\"5\n\x11\x44\x61taEditsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\":\n\x12\x44\x61taSplitsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0bsplit_names\x18\x02 \x03(\t\"9\n\x13\x41gentHealthResponse\x12\x11\n\tavailable\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"^\n\x16InitializeAgentRequest\x12\x0f\n\x07\x61pi_key\x18\x01 \x01(\t\x12$\n\x08provider\x18\x02 \x01(\x0e\x32\x12.AgentProviderType\x12\r\n\x05model\x18\x03 \x01(\t\";\n\x17InitializeAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"(\n\x17\x43hangeAgentModelRequest\x12\r\n\x05model\x18\x01 \x01(\t\"<\n\x18\x43hangeAgentModelResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x17\n\x15GetAgentModelsRequest\"J\n\x16GetAgentModelsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06models\x18\x02 \x03(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\"6\n\x12ResetAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"=\n\x19\x43learAgentHistoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"?\n\x1b\x43ompactAgentHistoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xe5\x01\n\x1cGetAgentContextUsageResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\r\n\x05model\x18\x03 \x01(\t\x12\x16\n\x0e\x63ontext_window\x18\x04 \x01(\x03\x12\x14\n\x0cinput_tokens\x18\x05 \x01(\x03\x12\x15\n\routput_tokens\x18\x06 \x01(\x03\x12\x18\n\x10reasoning_tokens\x18\x07 \x01(\x03\x12\x19\n\x11\x63\x61\x63he_read_tokens\x18\x08 \x01(\x03\x12\x1a\n\x12\x63\x61\x63he_write_tokens\x18\t \x01(\x03\"3\n\x18RestoreCheckpointRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\"=\n\x19RestoreCheckpointResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xa8\x01\n\x11\x45xperimentRunInfo\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_name\x18\x02 \x01(\t\x12\r\n\x05notes\x18\x03 \x01(\t\x12\x0f\n\x07\x63reated\x18\x04 \x01(\t\x12\x11\n\tlast_used\x18\x05 \x01(\t\x12\x1a\n\x12latest_weight_step\x18\x06 \x01(\x05\x12\x12\n\nis_current\x18\x07 \x01(\x08\"\x1b\n\x19ListExperimentRunsRequest\">\n\x1aListExperimentRunsResponse\x12 \n\x04runs\x18\x01 \x03(\x0b\x32\x12.ExperimentRunInfo\"G\n\x1aRenameExperimentRunRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\x12\x10\n\x08new_name\x18\x02 \x01(\t\"?\n\x1bRenameExperimentRunResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"F\n\x1cSetExperimentRunNotesRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\x12\r\n\x05notes\x18\x02 \x01(\t\"A\n\x1dSetExperimentRunNotesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"R\n\x18TriggerEvaluationRequest\x12\x12\n\nsplit_name\x18\x01 \x01(\t\x12\x0c\n\x04tags\x18\x02 \x03(\t\x12\x14\n\x0cuse_full_set\x18\x03 \x01(\x08\"=\n\x19TriggerEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x1c\n\x1aGetEvaluationStatusRequest\"\x81\x01\n\x1bGetEvaluationStatusResponse\x12\x0e\n\x06status\x18\x01 \x01(\t\x12\x0f\n\x07\x63urrent\x18\x02 \x01(\x05\x12\r\n\x05total\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nsplit_name\x18\x06 \x01(\t\")\n\x17\x43\x61ncelEvaluationRequest\x12\x0e\n\x06reason\x18\x01 \x01(\t\"<\n\x18\x43\x61ncelEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"7\n\x16RunNotebookCellRequest\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x0f\n\x07\x63\x65ll_id\x18\x02 \x01(\t\"2\n\x10NotebookCellDone\x12\x12\n\nexec_count\x18\x01 \x01(\x05\x12\n\n\x02ok\x18\x02 \x01(\x08\"\x1e\n\x1cInterruptNotebookCellRequest\":\n\x1dInterruptNotebookCellResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xbd\x01\n\x11NotebookCellChunk\x12\x0f\n\x07\x63\x65ll_id\x18\x01 \x01(\t\x12\x10\n\x06stdout\x18\x02 \x01(\tH\x00\x12\x10\n\x06stderr\x18\x03 \x01(\tH\x00\x12\x15\n\x0bresult_text\x18\x04 \x01(\tH\x00\x12\x13\n\timage_png\x18\x05 \x01(\x0cH\x00\x12\x19\n\x0f\x65rror_traceback\x18\x06 \x01(\tH\x00\x12!\n\x04\x64one\x18\x07 \x01(\x0b\x32\x11.NotebookCellDoneH\x00\x42\t\n\x07payload\"S\n\x10NotebookResponse\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0f\n\x07\x65xisted\x18\x02 \x01(\x08\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"7\n\x13SaveNotebookRequest\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"M\n\x14SaveNotebookResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"C\n\x1bGenerateNotebookCodeRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x14\n\x0c\x63ontext_code\x18\x02 \x01(\t\"\\\n\x1cGenerateNotebookCodeResponse\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x13\n\x0b\x65xplanation\x18\x02 \x01(\t\x12\n\n\x02ok\x18\x03 \x01(\x08\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"~\n\x18\x45xportAnnotationsRequest\x12\'\n\x06\x66ormat\x18\x01 \x01(\x0e\x32\x17.AnnotationExportFormat\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x1b\n\x13include_predictions\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\"\x88\x01\n\x19\x45xportAnnotationsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0f\n\x07payload\x18\x03 \x01(\x0c\x12\x10\n\x08\x66ilename\x18\x04 \x01(\t\x12\x11\n\tmime_type\x18\x05 \x01(\t\x12\x13\n\x0bimage_count\x18\x06 \x01(\x05*d\n\x13WeightOperationType\x12\n\n\x06ZEROFY\x10\x00\x12\x10\n\x0cREINITIALIZE\x10\x01\x12\n\n\x06\x46REEZE\x10\x02\x12\x12\n\x0eREMOVE_NEURONS\x10\t\x12\x0f\n\x0b\x41\x44\x44_NEURONS\x10\n*o\n\x0fZerofyPredicate\x12\x19\n\x15ZEROFY_PREDICATE_NONE\x10\x00\x12 \n\x1cZEROFY_PREDICATE_WITH_FROZEN\x10\x01\x12\x1f\n\x1bZEROFY_PREDICATE_WITH_OLDER\x10\x02*M\n\x0f\x41gentIntentType\x12\x12\n\x0eINTENT_UNKNOWN\x10\x00\x12\x11\n\rINTENT_FILTER\x10\x01\x12\x13\n\x0fINTENT_ANALYSIS\x10\x02*I\n\x0eSampleEditType\x12\x11\n\rEDIT_OVERRIDE\x10\x00\x12\x13\n\x0f\x45\x44IT_ACCUMULATE\x10\x01\x12\x0f\n\x0b\x45\x44IT_REMOVE\x10\x02*C\n\x11\x41gentProviderType\x12\x17\n\x13PROVIDER_OPENROUTER\x10\x00\x12\x15\n\x11PROVIDER_OPENCODE\x10\x01*m\n\x16\x41nnotationExportFormat\x12\x16\n\x12\x45XPORT_FORMAT_CVAT\x10\x00\x12\x1e\n\x1a\x45XPORT_FORMAT_LABEL_STUDIO\x10\x01\x12\x1b\n\x17\x45XPORT_FORMAT_V7_DARWIN\x10\x02\x32\xfe\x12\n\x11\x45xperimentService\x12P\n\x13GetLatestLoggerData\x12\x1b.GetLatestLoggerDataRequest\x1a\x1c.GetLatestLoggerDataResponse\x12\x36\n\x11\x45xperimentCommand\x12\x0f.TrainerCommand\x1a\x10.CommandResponse\x12H\n\x11ManipulateWeights\x12\x18.WeightsOperationRequest\x1a\x19.WeightsOperationResponse\x12/\n\nGetWeights\x12\x0f.WeightsRequest\x1a\x10.WeightsResponse\x12\x39\n\x0eGetActivations\x12\x12.ActivationRequest\x1a\x13.ActivationResponse\x12\x37\n\nGetSamples\x12\x13.BatchSampleRequest\x1a\x14.BatchSampleResponse\x12\x37\n\x0e\x41pplyDataQuery\x12\x11.DataQueryRequest\x1a\x12.DataQueryResponse\x12;\n\x0eGetDataSamples\x12\x13.DataSamplesRequest\x1a\x14.DataSamplesResponse\x12\x35\n\x0cGetHistogram\x12\x11.HistogramRequest\x1a\x12.HistogramResponse\x12\x38\n\x0bGetMetaData\x12\x13.GetMetaDataRequest\x1a\x14.GetMetaDataResponse\x12P\n\x13GetSignalTrajectory\x12\x1b.GetSignalTrajectoryRequest\x1a\x1c.GetSignalTrajectoryResponse\x12;\n\x0eGetStepSamples\x12\x13.StepSamplesRequest\x1a\x14.StepSamplesResponse\x12\x37\n\rGetPointCloud\x12\x12.PointCloudRequest\x1a\x10.PointCloudChunk0\x01\x12(\n\x08GetMedia\x12\r.MediaRequest\x1a\x0b.MediaChunk0\x01\x12\x37\n\x0e\x45\x64itDataSample\x12\x11.DataEditsRequest\x1a\x12.DataEditsResponse\x12,\n\rGetDataSplits\x12\x06.Empty\x1a\x13.DataSplitsResponse\x12\x30\n\x10\x43heckAgentHealth\x12\x06.Empty\x1a\x14.AgentHealthResponse\x12\x44\n\x0fInitializeAgent\x12\x17.InitializeAgentRequest\x1a\x18.InitializeAgentResponse\x12G\n\x10\x43hangeAgentModel\x12\x18.ChangeAgentModelRequest\x1a\x19.ChangeAgentModelResponse\x12\x41\n\x0eGetAgentModels\x12\x16.GetAgentModelsRequest\x1a\x17.GetAgentModelsResponse\x12)\n\nResetAgent\x12\x06.Empty\x1a\x13.ResetAgentResponse\x12\x37\n\x11\x43learAgentHistory\x12\x06.Empty\x1a\x1a.ClearAgentHistoryResponse\x12;\n\x13\x43ompactAgentHistory\x12\x06.Empty\x1a\x1c.CompactAgentHistoryResponse\x12=\n\x14GetAgentContextUsage\x12\x06.Empty\x1a\x1d.GetAgentContextUsageResponse\x12@\n\x0fRunNotebookCell\x12\x17.RunNotebookCellRequest\x1a\x12.NotebookCellChunk0\x01\x12V\n\x15InterruptNotebookCell\x12\x1d.InterruptNotebookCellRequest\x1a\x1e.InterruptNotebookCellResponse\x12(\n\x0bGetNotebook\x12\x06.Empty\x1a\x11.NotebookResponse\x12;\n\x0cSaveNotebook\x12\x14.SaveNotebookRequest\x1a\x15.SaveNotebookResponse\x12S\n\x14GenerateNotebookCode\x12\x1c.GenerateNotebookCodeRequest\x1a\x1d.GenerateNotebookCodeResponse\x12J\n\x11RestoreCheckpoint\x12\x19.RestoreCheckpointRequest\x1a\x1a.RestoreCheckpointResponse\x12M\n\x12ListExperimentRuns\x12\x1a.ListExperimentRunsRequest\x1a\x1b.ListExperimentRunsResponse\x12P\n\x13RenameExperimentRun\x12\x1b.RenameExperimentRunRequest\x1a\x1c.RenameExperimentRunResponse\x12V\n\x15SetExperimentRunNotes\x12\x1d.SetExperimentRunNotesRequest\x1a\x1e.SetExperimentRunNotesResponse\x12J\n\x11TriggerEvaluation\x12\x19.TriggerEvaluationRequest\x1a\x1a.TriggerEvaluationResponse\x12P\n\x13GetEvaluationStatus\x12\x1b.GetEvaluationStatusRequest\x1a\x1c.GetEvaluationStatusResponse\x12G\n\x10\x43\x61ncelEvaluation\x12\x18.CancelEvaluationRequest\x1a\x19.CancelEvaluationResponse\x12J\n\x11\x45xportAnnotations\x12\x19.ExportAnnotationsRequest\x1a\x1a.ExportAnnotationsResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -37,18 +37,18 @@ _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_options = b'8\001' _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._loaded_options = None _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_options = b'8\001' - _globals['_WEIGHTOPERATIONTYPE']._serialized_start=13429 - _globals['_WEIGHTOPERATIONTYPE']._serialized_end=13529 - _globals['_ZEROFYPREDICATE']._serialized_start=13531 - _globals['_ZEROFYPREDICATE']._serialized_end=13642 - _globals['_AGENTINTENTTYPE']._serialized_start=13644 - _globals['_AGENTINTENTTYPE']._serialized_end=13721 - _globals['_SAMPLEEDITTYPE']._serialized_start=13723 - _globals['_SAMPLEEDITTYPE']._serialized_end=13796 - _globals['_AGENTPROVIDERTYPE']._serialized_start=13798 - _globals['_AGENTPROVIDERTYPE']._serialized_end=13865 - _globals['_ANNOTATIONEXPORTFORMAT']._serialized_start=13867 - _globals['_ANNOTATIONEXPORTFORMAT']._serialized_end=13976 + _globals['_WEIGHTOPERATIONTYPE']._serialized_start=13491 + _globals['_WEIGHTOPERATIONTYPE']._serialized_end=13591 + _globals['_ZEROFYPREDICATE']._serialized_start=13593 + _globals['_ZEROFYPREDICATE']._serialized_end=13704 + _globals['_AGENTINTENTTYPE']._serialized_start=13706 + _globals['_AGENTINTENTTYPE']._serialized_end=13783 + _globals['_SAMPLEEDITTYPE']._serialized_start=13785 + _globals['_SAMPLEEDITTYPE']._serialized_end=13858 + _globals['_AGENTPROVIDERTYPE']._serialized_start=13860 + _globals['_AGENTPROVIDERTYPE']._serialized_end=13927 + _globals['_ANNOTATIONEXPORTFORMAT']._serialized_start=13929 + _globals['_ANNOTATIONEXPORTFORMAT']._serialized_end=14038 _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_start=46 _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_end=303 _globals['_SIGNALCURVEINDEX']._serialized_start=306 @@ -141,122 +141,122 @@ _globals['_DATASTAT']._serialized_end=8018 _globals['_DATARECORD']._serialized_start=8020 _globals['_DATARECORD']._serialized_end=8082 - _globals['_DATASAMPLESRESPONSE']._serialized_start=8084 - _globals['_DATASAMPLESRESPONSE']._serialized_end=8174 - _globals['_HISTOGRAMSUBBAR']._serialized_start=8176 - _globals['_HISTOGRAMSUBBAR']._serialized_end=8243 - _globals['_HISTOGRAMBIN']._serialized_start=8245 - _globals['_HISTOGRAMBIN']._serialized_end=8349 - _globals['_CATEGORICALHISTOGRAMBAR']._serialized_start=8351 - _globals['_CATEGORICALHISTOGRAMBAR']._serialized_end=8442 - _globals['_HISTOGRAMREQUEST']._serialized_start=8444 - _globals['_HISTOGRAMREQUEST']._serialized_end=8496 - _globals['_HISTOGRAMRESPONSE']._serialized_start=8499 - _globals['_HISTOGRAMRESPONSE']._serialized_end=8677 - _globals['_GETMETADATAREQUEST']._serialized_start=8679 - _globals['_GETMETADATAREQUEST']._serialized_end=8766 - _globals['_GETMETADATARESPONSE']._serialized_start=8769 - _globals['_GETMETADATARESPONSE']._serialized_end=8922 - _globals['_STEPSAMPLESREQUEST']._serialized_start=8924 - _globals['_STEPSAMPLESREQUEST']._serialized_end=9030 - _globals['_STEPSAMPLESRESPONSE']._serialized_start=9032 - _globals['_STEPSAMPLESRESPONSE']._serialized_end=9155 - _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_start=9157 - _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_end=9246 - _globals['_SIGNALTRAJECTORY']._serialized_start=9248 - _globals['_SIGNALTRAJECTORY']._serialized_end=9300 - _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_start=9302 - _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_end=9427 - _globals['_POINTCLOUDREQUEST']._serialized_start=9429 - _globals['_POINTCLOUDREQUEST']._serialized_end=9518 - _globals['_POINTCLOUDCHUNK']._serialized_start=9521 - _globals['_POINTCLOUDCHUNK']._serialized_end=9712 - _globals['_MEDIAREQUEST']._serialized_start=9714 - _globals['_MEDIAREQUEST']._serialized_end=9812 - _globals['_MEDIACHUNK']._serialized_start=9815 - _globals['_MEDIACHUNK']._serialized_end=10089 - _globals['_DATAEDITSREQUEST']._serialized_start=10092 - _globals['_DATAEDITSREQUEST']._serialized_end=10360 - _globals['_DATAEDITSRESPONSE']._serialized_start=10362 - _globals['_DATAEDITSRESPONSE']._serialized_end=10415 - _globals['_DATASPLITSRESPONSE']._serialized_start=10417 - _globals['_DATASPLITSRESPONSE']._serialized_end=10475 - _globals['_AGENTHEALTHRESPONSE']._serialized_start=10477 - _globals['_AGENTHEALTHRESPONSE']._serialized_end=10534 - _globals['_INITIALIZEAGENTREQUEST']._serialized_start=10536 - _globals['_INITIALIZEAGENTREQUEST']._serialized_end=10630 - _globals['_INITIALIZEAGENTRESPONSE']._serialized_start=10632 - _globals['_INITIALIZEAGENTRESPONSE']._serialized_end=10691 - _globals['_CHANGEAGENTMODELREQUEST']._serialized_start=10693 - _globals['_CHANGEAGENTMODELREQUEST']._serialized_end=10733 - _globals['_CHANGEAGENTMODELRESPONSE']._serialized_start=10735 - _globals['_CHANGEAGENTMODELRESPONSE']._serialized_end=10795 - _globals['_GETAGENTMODELSREQUEST']._serialized_start=10797 - _globals['_GETAGENTMODELSREQUEST']._serialized_end=10820 - _globals['_GETAGENTMODELSRESPONSE']._serialized_start=10822 - _globals['_GETAGENTMODELSRESPONSE']._serialized_end=10896 - _globals['_RESETAGENTRESPONSE']._serialized_start=10898 - _globals['_RESETAGENTRESPONSE']._serialized_end=10952 - _globals['_CLEARAGENTHISTORYRESPONSE']._serialized_start=10954 - _globals['_CLEARAGENTHISTORYRESPONSE']._serialized_end=11015 - _globals['_COMPACTAGENTHISTORYRESPONSE']._serialized_start=11017 - _globals['_COMPACTAGENTHISTORYRESPONSE']._serialized_end=11080 - _globals['_GETAGENTCONTEXTUSAGERESPONSE']._serialized_start=11083 - _globals['_GETAGENTCONTEXTUSAGERESPONSE']._serialized_end=11312 - _globals['_RESTORECHECKPOINTREQUEST']._serialized_start=11314 - _globals['_RESTORECHECKPOINTREQUEST']._serialized_end=11365 - _globals['_RESTORECHECKPOINTRESPONSE']._serialized_start=11367 - _globals['_RESTORECHECKPOINTRESPONSE']._serialized_end=11428 - _globals['_EXPERIMENTRUNINFO']._serialized_start=11431 - _globals['_EXPERIMENTRUNINFO']._serialized_end=11599 - _globals['_LISTEXPERIMENTRUNSREQUEST']._serialized_start=11601 - _globals['_LISTEXPERIMENTRUNSREQUEST']._serialized_end=11628 - _globals['_LISTEXPERIMENTRUNSRESPONSE']._serialized_start=11630 - _globals['_LISTEXPERIMENTRUNSRESPONSE']._serialized_end=11692 - _globals['_RENAMEEXPERIMENTRUNREQUEST']._serialized_start=11694 - _globals['_RENAMEEXPERIMENTRUNREQUEST']._serialized_end=11765 - _globals['_RENAMEEXPERIMENTRUNRESPONSE']._serialized_start=11767 - _globals['_RENAMEEXPERIMENTRUNRESPONSE']._serialized_end=11830 - _globals['_SETEXPERIMENTRUNNOTESREQUEST']._serialized_start=11832 - _globals['_SETEXPERIMENTRUNNOTESREQUEST']._serialized_end=11902 - _globals['_SETEXPERIMENTRUNNOTESRESPONSE']._serialized_start=11904 - _globals['_SETEXPERIMENTRUNNOTESRESPONSE']._serialized_end=11969 - _globals['_TRIGGEREVALUATIONREQUEST']._serialized_start=11971 - _globals['_TRIGGEREVALUATIONREQUEST']._serialized_end=12053 - _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_start=12055 - _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_end=12116 - _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_start=12118 - _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_end=12146 - _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_start=12149 - _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_end=12278 - _globals['_CANCELEVALUATIONREQUEST']._serialized_start=12280 - _globals['_CANCELEVALUATIONREQUEST']._serialized_end=12321 - _globals['_CANCELEVALUATIONRESPONSE']._serialized_start=12323 - _globals['_CANCELEVALUATIONRESPONSE']._serialized_end=12383 - _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_start=12385 - _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_end=12440 - _globals['_NOTEBOOKCELLDONE']._serialized_start=12442 - _globals['_NOTEBOOKCELLDONE']._serialized_end=12492 - _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_start=12494 - _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_end=12524 - _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_start=12526 - _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_end=12584 - _globals['_NOTEBOOKCELLCHUNK']._serialized_start=12587 - _globals['_NOTEBOOKCELLCHUNK']._serialized_end=12776 - _globals['_NOTEBOOKRESPONSE']._serialized_start=12778 - _globals['_NOTEBOOKRESPONSE']._serialized_end=12861 - _globals['_SAVENOTEBOOKREQUEST']._serialized_start=12863 - _globals['_SAVENOTEBOOKREQUEST']._serialized_end=12918 - _globals['_SAVENOTEBOOKRESPONSE']._serialized_start=12920 - _globals['_SAVENOTEBOOKRESPONSE']._serialized_end=12997 - _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_start=12999 - _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_end=13066 - _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_start=13068 - _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_end=13160 - _globals['_EXPORTANNOTATIONSREQUEST']._serialized_start=13162 - _globals['_EXPORTANNOTATIONSREQUEST']._serialized_end=13288 - _globals['_EXPORTANNOTATIONSRESPONSE']._serialized_start=13291 - _globals['_EXPORTANNOTATIONSRESPONSE']._serialized_end=13427 - _globals['_EXPERIMENTSERVICE']._serialized_start=13979 - _globals['_EXPERIMENTSERVICE']._serialized_end=16409 + _globals['_DATASAMPLESRESPONSE']._serialized_start=8085 + _globals['_DATASAMPLESRESPONSE']._serialized_end=8236 + _globals['_HISTOGRAMSUBBAR']._serialized_start=8238 + _globals['_HISTOGRAMSUBBAR']._serialized_end=8305 + _globals['_HISTOGRAMBIN']._serialized_start=8307 + _globals['_HISTOGRAMBIN']._serialized_end=8411 + _globals['_CATEGORICALHISTOGRAMBAR']._serialized_start=8413 + _globals['_CATEGORICALHISTOGRAMBAR']._serialized_end=8504 + _globals['_HISTOGRAMREQUEST']._serialized_start=8506 + _globals['_HISTOGRAMREQUEST']._serialized_end=8558 + _globals['_HISTOGRAMRESPONSE']._serialized_start=8561 + _globals['_HISTOGRAMRESPONSE']._serialized_end=8739 + _globals['_GETMETADATAREQUEST']._serialized_start=8741 + _globals['_GETMETADATAREQUEST']._serialized_end=8828 + _globals['_GETMETADATARESPONSE']._serialized_start=8831 + _globals['_GETMETADATARESPONSE']._serialized_end=8984 + _globals['_STEPSAMPLESREQUEST']._serialized_start=8986 + _globals['_STEPSAMPLESREQUEST']._serialized_end=9092 + _globals['_STEPSAMPLESRESPONSE']._serialized_start=9094 + _globals['_STEPSAMPLESRESPONSE']._serialized_end=9217 + _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_start=9219 + _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_end=9308 + _globals['_SIGNALTRAJECTORY']._serialized_start=9310 + _globals['_SIGNALTRAJECTORY']._serialized_end=9362 + _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_start=9364 + _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_end=9489 + _globals['_POINTCLOUDREQUEST']._serialized_start=9491 + _globals['_POINTCLOUDREQUEST']._serialized_end=9580 + _globals['_POINTCLOUDCHUNK']._serialized_start=9583 + _globals['_POINTCLOUDCHUNK']._serialized_end=9774 + _globals['_MEDIAREQUEST']._serialized_start=9776 + _globals['_MEDIAREQUEST']._serialized_end=9874 + _globals['_MEDIACHUNK']._serialized_start=9877 + _globals['_MEDIACHUNK']._serialized_end=10151 + _globals['_DATAEDITSREQUEST']._serialized_start=10154 + _globals['_DATAEDITSREQUEST']._serialized_end=10422 + _globals['_DATAEDITSRESPONSE']._serialized_start=10424 + _globals['_DATAEDITSRESPONSE']._serialized_end=10477 + _globals['_DATASPLITSRESPONSE']._serialized_start=10479 + _globals['_DATASPLITSRESPONSE']._serialized_end=10537 + _globals['_AGENTHEALTHRESPONSE']._serialized_start=10539 + _globals['_AGENTHEALTHRESPONSE']._serialized_end=10596 + _globals['_INITIALIZEAGENTREQUEST']._serialized_start=10598 + _globals['_INITIALIZEAGENTREQUEST']._serialized_end=10692 + _globals['_INITIALIZEAGENTRESPONSE']._serialized_start=10694 + _globals['_INITIALIZEAGENTRESPONSE']._serialized_end=10753 + _globals['_CHANGEAGENTMODELREQUEST']._serialized_start=10755 + _globals['_CHANGEAGENTMODELREQUEST']._serialized_end=10795 + _globals['_CHANGEAGENTMODELRESPONSE']._serialized_start=10797 + _globals['_CHANGEAGENTMODELRESPONSE']._serialized_end=10857 + _globals['_GETAGENTMODELSREQUEST']._serialized_start=10859 + _globals['_GETAGENTMODELSREQUEST']._serialized_end=10882 + _globals['_GETAGENTMODELSRESPONSE']._serialized_start=10884 + _globals['_GETAGENTMODELSRESPONSE']._serialized_end=10958 + _globals['_RESETAGENTRESPONSE']._serialized_start=10960 + _globals['_RESETAGENTRESPONSE']._serialized_end=11014 + _globals['_CLEARAGENTHISTORYRESPONSE']._serialized_start=11016 + _globals['_CLEARAGENTHISTORYRESPONSE']._serialized_end=11077 + _globals['_COMPACTAGENTHISTORYRESPONSE']._serialized_start=11079 + _globals['_COMPACTAGENTHISTORYRESPONSE']._serialized_end=11142 + _globals['_GETAGENTCONTEXTUSAGERESPONSE']._serialized_start=11145 + _globals['_GETAGENTCONTEXTUSAGERESPONSE']._serialized_end=11374 + _globals['_RESTORECHECKPOINTREQUEST']._serialized_start=11376 + _globals['_RESTORECHECKPOINTREQUEST']._serialized_end=11427 + _globals['_RESTORECHECKPOINTRESPONSE']._serialized_start=11429 + _globals['_RESTORECHECKPOINTRESPONSE']._serialized_end=11490 + _globals['_EXPERIMENTRUNINFO']._serialized_start=11493 + _globals['_EXPERIMENTRUNINFO']._serialized_end=11661 + _globals['_LISTEXPERIMENTRUNSREQUEST']._serialized_start=11663 + _globals['_LISTEXPERIMENTRUNSREQUEST']._serialized_end=11690 + _globals['_LISTEXPERIMENTRUNSRESPONSE']._serialized_start=11692 + _globals['_LISTEXPERIMENTRUNSRESPONSE']._serialized_end=11754 + _globals['_RENAMEEXPERIMENTRUNREQUEST']._serialized_start=11756 + _globals['_RENAMEEXPERIMENTRUNREQUEST']._serialized_end=11827 + _globals['_RENAMEEXPERIMENTRUNRESPONSE']._serialized_start=11829 + _globals['_RENAMEEXPERIMENTRUNRESPONSE']._serialized_end=11892 + _globals['_SETEXPERIMENTRUNNOTESREQUEST']._serialized_start=11894 + _globals['_SETEXPERIMENTRUNNOTESREQUEST']._serialized_end=11964 + _globals['_SETEXPERIMENTRUNNOTESRESPONSE']._serialized_start=11966 + _globals['_SETEXPERIMENTRUNNOTESRESPONSE']._serialized_end=12031 + _globals['_TRIGGEREVALUATIONREQUEST']._serialized_start=12033 + _globals['_TRIGGEREVALUATIONREQUEST']._serialized_end=12115 + _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_start=12117 + _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_end=12178 + _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_start=12180 + _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_end=12208 + _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_start=12211 + _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_end=12340 + _globals['_CANCELEVALUATIONREQUEST']._serialized_start=12342 + _globals['_CANCELEVALUATIONREQUEST']._serialized_end=12383 + _globals['_CANCELEVALUATIONRESPONSE']._serialized_start=12385 + _globals['_CANCELEVALUATIONRESPONSE']._serialized_end=12445 + _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_start=12447 + _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_end=12502 + _globals['_NOTEBOOKCELLDONE']._serialized_start=12504 + _globals['_NOTEBOOKCELLDONE']._serialized_end=12554 + _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_start=12556 + _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_end=12586 + _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_start=12588 + _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_end=12646 + _globals['_NOTEBOOKCELLCHUNK']._serialized_start=12649 + _globals['_NOTEBOOKCELLCHUNK']._serialized_end=12838 + _globals['_NOTEBOOKRESPONSE']._serialized_start=12840 + _globals['_NOTEBOOKRESPONSE']._serialized_end=12923 + _globals['_SAVENOTEBOOKREQUEST']._serialized_start=12925 + _globals['_SAVENOTEBOOKREQUEST']._serialized_end=12980 + _globals['_SAVENOTEBOOKRESPONSE']._serialized_start=12982 + _globals['_SAVENOTEBOOKRESPONSE']._serialized_end=13059 + _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_start=13061 + _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_end=13128 + _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_start=13130 + _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_end=13222 + _globals['_EXPORTANNOTATIONSREQUEST']._serialized_start=13224 + _globals['_EXPORTANNOTATIONSREQUEST']._serialized_end=13350 + _globals['_EXPORTANNOTATIONSRESPONSE']._serialized_start=13353 + _globals['_EXPORTANNOTATIONSRESPONSE']._serialized_end=13489 + _globals['_EXPERIMENTSERVICE']._serialized_start=14041 + _globals['_EXPERIMENTSERVICE']._serialized_end=16471 # @@protoc_insertion_point(module_scope) diff --git a/weightslab/reporting.py b/weightslab/reporting.py index 11983cab..3c0505f0 100644 --- a/weightslab/reporting.py +++ b/weightslab/reporting.py @@ -316,6 +316,14 @@ def compute_distribution_entries( if col is None: entries.append({"name": str(requested), "resolved": False}) continue + # A media column (e.g. media:pred_video on a video-generation run) holds + # descriptor JSON, not numbers -- pd.to_numeric would coerce it all to NaN + # and the card would wrongly claim "no numeric values". Flag it as media so + # the card can say what it actually is. (See _distribution_card_html.) + _ms = _media_store() + if _ms is not None and _ms.is_media_column(col): + entries.append({"name": str(requested), "column": col, "resolved": True, "is_media": True}) + continue try: values = pd.to_numeric(df[col], errors="coerce").dropna() except Exception: @@ -496,6 +504,85 @@ def _resolve_runs_map(checkpoint_manager) -> dict: return {} +def _media_store(): + """Lazy handle to weightslab.data.media_store (None if unavailable), so this + module needn't hard-depend on it and never fails to import when it's absent.""" + try: + from weightslab.data import media_store + return media_store + except Exception: + return None + + +def _poster_data_uri(poster: bytes) -> Optional[str]: + """Wrap poster bytes as a data: URI, sniffing PNG vs JPEG (posters are always + a still image regardless of the underlying media kind). None when empty.""" + if not poster: + return None + if poster[:8] == b"\x89PNG\r\n\x1a\n": + mime = "image/png" + elif poster[:2] == b"\xff\xd8": + mime = "image/jpeg" + elif poster[:6] in (b"GIF87a", b"GIF89a"): + mime = "image/gif" + else: + mime = "image/png" # sensible default; browsers sniff anyway + return f"data:{mime};base64,{base64.b64encode(poster).decode('ascii')}" + + +def compute_media_examples(df: Optional[pd.DataFrame], max_fields: int = 8, + max_examples: int = 6) -> list: + """Discover media columns (``media:``) in the sample dataframe and pull + a few poster frames per field from the in-process media_store, so a + video/image/audio-generation run's actual artifacts show up in the report + instead of being invisible. Returns ``[]`` for a run with no media (every + non-media use case is unchanged). Bounded by ``max_fields``/``max_examples`` + so it never scales with dataset size.""" + if df is None or getattr(df, "empty", True): + return [] + ms = _media_store() + if ms is None: + return [] + try: + media_cols = [c for c in df.columns if isinstance(c, str) and ms.is_media_column(c)] + except Exception: + return [] + examples: list = [] + for col in media_cols[:max_fields]: + field = ms.field_from_column(col) + try: + present = df[col].notna() + count = int(present.sum()) + except Exception: + continue + if count == 0: + continue + try: + ids = _sample_ids_for_mask(df, present, max_examples) + except Exception: + ids = [] + kind = "" + thumbnails = [] + for sid in ids: + try: + entry = ms.get(field, sid) + except Exception: + entry = None + if not entry: + continue + kind = kind or str(entry.get("kind") or "") + uri = _poster_data_uri(entry.get("poster") or b"") + if uri: + thumbnails.append({"sample_id": str(sid), "poster_uri": uri}) + examples.append({ + "field": field, + "kind": kind or "media", + "count": count, + "thumbnails": thumbnails, + }) + return examples + + def collect_report_context( root_log_dir, logger_q, @@ -575,6 +662,7 @@ def collect_report_context( "distributions": compute_distribution_entries(df, distributions, plt), "dataframe": compute_dataframe_stats(df), "loss_shape_tags": summarize_loss_shape_tags(df), + "media": compute_media_examples(df), "plotting_available": plt is not None, "runs": list(runs_map.values()), } @@ -726,6 +814,13 @@ def _distribution_card_html(entry: dict, block_id: str) -> str: f'
No column matching "{name}" ' 'was found in the dataset.
' ) + if entry.get("is_media"): + col = html.escape(str(entry.get("column") or name)) + return head + ( + f'
"{col}" is a media column ' + '(images/video/audio), not a numeric signal — see the Generated Media ' + 'section for its samples.
' + ) if not entry.get("n"): return head + ( '
No numeric values logged for this column yet.
' @@ -747,6 +842,54 @@ def _distribution_card_html(entry: dict, block_id: str) -> str: return body +def _media_section_html(media: list) -> str: + """The Generated Media section: poster thumbnails per media field (video/ + image/audio/...). Returns "" when there is no media, so non-media reports are + byte-for-byte unchanged. Uses inline styles with neutral (light/dark-safe) + colors so it needs no additions to the report's stylesheet.""" + if not media: + return "" + card_style = ("border:1px solid rgba(128,128,128,0.3);border-radius:10px;" + "padding:14px 16px;background:rgba(128,128,128,0.06);min-width:240px") + thumb_style = ("width:104px;height:104px;object-fit:cover;border-radius:8px;" + "background:rgba(128,128,128,0.15);border:1px solid rgba(128,128,128,0.25)") + cards = [] + for m in media: + field = html.escape(str(m.get("field") or "")) + kind = html.escape(str(m.get("kind") or "media")) + count = int(m.get("count") or 0) + thumbs = m.get("thumbnails") or [] + if thumbs: + thumbs_html = "".join( + f'
' + f'' + f'
' + f'#{html.escape(str(t["sample_id"]))}
' + for t in thumbs + ) + else: + thumbs_html = ('

Media attached, but no poster ' + 'frames are cached in this process to preview.

') + cards.append( + f'
' + f'
' + f'{field}' + f'{kind}' + f'{count:,} sample(s)' + f'
' + f'
{thumbs_html}
' + f'
' + ) + return ( + '
' + '

Generated Media

' + f'
{"".join(cards)}
' + '
' + ) + + def _distributions_section_html(distributions: list) -> str: """The optional Distributions section, e.g. "add a histogram of train_loss" (action_params={"distributions": ["train_loss"]}) -- omitted @@ -1633,6 +1776,8 @@ def _chartjs_script_tag() -> str: {signals_html} + {media_section_html} + {distributions_section_html}
@@ -1737,6 +1882,7 @@ def render_report(context: dict, output_path, narrative: Optional[str] = None) - root_log_dir=html.escape(context.get("root_log_dir", "")), narrative=narrative_html, signals_html=signals_html, + media_section_html=_media_section_html(context.get("media") or []), distributions_section_html=_distributions_section_html(context.get("distributions") or []), loss_shape_html=_loss_shape_section_html(context.get("loss_shape_tags") or []), dataframe_html=_dataframe_section_html(context.get("dataframe") or {}), diff --git a/weightslab/src.py b/weightslab/src.py index 928c149b..498b329c 100644 --- a/weightslab/src.py +++ b/weightslab/src.py @@ -5570,7 +5570,13 @@ def _ai_report_generation_result( _dm = get_dataframe() df = _dm.get_combined_df() if _dm is not None else None except Exception as _e: - logger.debug("ai_report_generation: no sample dataframe available (%s).", _e) + # Warn (not debug): if get_combined_df() raises — e.g. array/media proxy + # conversion choking on a video-generation dataframe — the report would + # otherwise silently degrade to "No sample dataframe data available yet" + # and read as broken for no visible reason. Surface it, keep going. + logger.warning( + "ai_report_generation: sample dataframe unavailable (%s); the report's " + "Dataset/Media sections will be empty.", _e, exc_info=True) df = None # The narrative comes from the live agent — the same LLM call the Studio diff --git a/weightslab/trainer/services/data_service.py b/weightslab/trainer/services/data_service.py index 0a436ec5..dd065d33 100755 --- a/weightslab/trainer/services/data_service.py +++ b/weightslab/trainer/services/data_service.py @@ -584,6 +584,10 @@ def __init__(self, ctx): ) self._is_filtered = False # Track if the current view is filtered/modified by user + # Last known FULL (unfiltered) row count, cached whenever GetDataSamples + # serves the unfiltered view, so the subview ribbon can report an accurate + # "X of Y" total even while a filter is active. See GetDataSamples. + self._last_full_count = 0 # logger.info("[DataService] Skipping expensive startup computations (aspect ratio, natural sort, signals).") # These should be triggered on-demand or run in background to avoid blocking training start. @@ -4990,7 +4994,31 @@ def GetDataSamples(self, request, context): try: # Process the request directly without deduplication logicj - return self._process_get_data_samples(request, context) + resp = self._process_get_data_samples(request, context) + # Stamp the server-authoritative subview state onto EVERY response so + # a fresh client (private window, no cached UI state) can render the + # "you are viewing a subview" warning ribbon + reset in both grid and + # list mode. Mirrors self._is_filtered, which is set on agent + # masks/filters/sorts and cleared on @reset. + try: + is_sub = bool(getattr(self, "_is_filtered", False)) + resp.is_subview = is_sub + view_df = getattr(self, "_all_datasets_df", None) + if view_df is not None: + resp.view_count = int(len(view_df)) + # Remember the full-dataset size whenever we serve the + # UNfiltered view, so we can still report an accurate total + # (for the "X of Y samples" ribbon) once a filter is applied + # and _all_datasets_df holds only the subview. Survives across + # a fresh client connecting to this same backend, since the + # backend serves the full view at least once at startup. + if not is_sub: + self._last_full_count = int(len(view_df)) + full = int(getattr(self, "_last_full_count", 0) or 0) + resp.total_count = full if full else int(len(view_df)) + except Exception: + logger.debug("GetDataSamples: could not stamp subview state", exc_info=True) + return resp except Exception as e: logger.error("Error in GetDataSamples: %s", str(e), exc_info=True) From 40ad072d1e525f58e8aceab6d35986a01b79ac64 Mon Sep 17 00:00:00 2001 From: GuillaumePELLUET Date: Wed, 26 Aug 2026 12:38:46 +0200 Subject: [PATCH 3/6] fix CI core dumped --- weightslab/opencode_binary.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/weightslab/opencode_binary.py b/weightslab/opencode_binary.py index 35c2cc9c..9b9b4b42 100644 --- a/weightslab/opencode_binary.py +++ b/weightslab/opencode_binary.py @@ -25,6 +25,7 @@ from __future__ import annotations +import atexit import logging import os import platform @@ -338,11 +339,20 @@ def ensure_managed_binary(version: Optional[str] = None, def ensure_managed_binary_in_background(reason: str = "", logger: Optional[logging.Logger] = None) -> None: """Install OpenCode in a daemon thread if it isn't already present, logging - the install. Idempotent, best-effort, and non-blocking -- the caller (a CLI - launch or ``import weightslab``) never waits on the ~180 MB download. - - Respects WEIGHTSLAB_OPENCODE_AUTODOWNLOAD. Only the FIRST call per process - does anything; the rest return immediately. + the install. Idempotent, best-effort, and non-blocking -- a long-running + caller (a CLI launch or ``import weightslab`` in an app) never waits on the + ~180 MB download. + + A short-lived caller that exits right after import IS made to wait (see the + ``atexit`` hook below): a daemon thread still doing network/ssl I/O when + CPython starts tearing down interpreter state on exit is a known segfault + vector (use-after-free in the ssl/socket C extensions, not a catchable + Python exception) -- observed as `python -c "import weightslab"` dying with + "Segmentation fault (core dumped)" right after the import finished. Joining + at atexit -- which runs in the main thread before ``Py_Finalize`` begins + tearing down module/C-extension state -- closes that race. This only costs + time on the very first import on a machine; once the binary is cached, + ``find_managed_binary()`` above short-circuits and no thread is spawned. """ global _bg_started log = logger or _LOGGER @@ -369,4 +379,8 @@ def _run(): except Exception as exc: # pragma: no cover - best-effort log.debug("OpenCode background install failed: %s", exc) - threading.Thread(target=_run, name="opencode-install", daemon=True).start() + t = threading.Thread(target=_run, name="opencode-install", daemon=True) + t.start() + # Bounded by download_managed_binary()'s own per-candidate _DOWNLOAD_TIMEOUT, + # so this can't hang process exit indefinitely -- see the docstring above. + atexit.register(t.join) From 0ed0500b00ccf570a5d77ad9f89e481ee4c1bcf4 Mon Sep 17 00:00:00 2001 From: GuillaumePELLUET Date: Wed, 26 Aug 2026 13:10:38 +0200 Subject: [PATCH 4/6] fix stucked CI --- .github/workflows/ci.yml | 11 ++++++ .../trainer/services/notebook_service.py | 35 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8526450b..3a90f58a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,6 +200,11 @@ jobs: # Depends on the fast 3.11 install gate; `gate` is also a direct need so this # job can read run_ci (the matrix and main-only jobs run independently). needs: [ gate, install ] + # Safety net: without this, a hang falls back to GitHub's 360-minute + # default. pytest's own --timeout=600 below should already catch a stuck + # test, but this bounds the whole job even if some hang manages to dodge + # that (e.g. a shared resource -- see WEIGHTSLAB_NOTEBOOK_EXEC_TIMEOUT). + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -221,6 +226,12 @@ jobs: - name: Run unit tests - General run: | export WEIGHTSLAB_LOG_LEVEL="DEBUG" + # Bounds EmbeddedKernelBridge's per-cell wait (default: unbounded -- + # see notebook_service.py) so a dead/unresponsive embedded kernel in + # TestNotebookKernelEmbedded fails that one test fast instead of + # holding the kernel's lock forever and wedging every later test in + # the same contract-test class behind it. + export WEIGHTSLAB_NOTEBOOK_EXEC_TIMEOUT="60" # A per-test timeout guards against any regression that hangs a test. python -m pytest ./tests -v --timeout=600 diff --git a/weightslab/trainer/services/notebook_service.py b/weightslab/trainer/services/notebook_service.py index 3026ff69..de0d2e54 100644 --- a/weightslab/trainer/services/notebook_service.py +++ b/weightslab/trainer/services/notebook_service.py @@ -312,6 +312,24 @@ def configure_embedded_kernel(enabled: bool) -> None: _EMBED_ENABLED = bool(enabled) +def _embedded_exec_timeout() -> float | None: + """Seconds EmbeddedKernelBridge waits for a single cell to finish, or None + for unbounded (the product default -- see EmbeddedKernelBridge.__init__). + + Override via WEIGHTSLAB_NOTEBOOK_EXEC_TIMEOUT for environments (CI) where a + dead/unresponsive kernel failing fast matters more than letting a + legitimately long-running cell finish -- an unbounded wait there wedges + every later test against the same shared kernel behind the first hang. + """ + raw = os.environ.get("WEIGHTSLAB_NOTEBOOK_EXEC_TIMEOUT", "").strip() + if not raw: + return None + try: + return float(raw) + except ValueError: + return None + + def _ipykernel_available() -> bool: try: import ipykernel # noqa: F401 @@ -545,10 +563,20 @@ class EmbeddedKernelBridge: NotebookService's kernel-construction lock. """ - def __init__(self, connection_file: Path, startup_timeout: float = 30.0): + def __init__(self, connection_file: Path, startup_timeout: float = 30.0, + exec_timeout: float | None = None): from jupyter_client import BlockingKernelClient self._lock = threading.Lock() self._busy = False + # None (the default) preserves the product's actual contract: a cell + # may legitimately run for a long time (e.g. training) and is meant to + # be stopped via interrupt(), never by a hidden deadline. Only pass a + # finite value where a dead/unresponsive kernel failing fast matters + # more than that contract -- e.g. WEIGHTSLAB_NOTEBOOK_EXEC_TIMEOUT in + # CI (see _get_kernel()): an unbounded wait here would otherwise hold + # `self._lock` forever, wedging every later cell run against the same + # shared kernel behind it. + self._exec_timeout = exec_timeout self._client = BlockingKernelClient(connection_file=str(connection_file)) self._client.load_connection_file() self._client.start_channels() @@ -564,7 +592,7 @@ def _work(): self._busy = True try: reply = self._client.execute_interactive( - code, allow_stdin=False, timeout=None, + code, allow_stdin=False, timeout=self._exec_timeout, output_hook=lambda msg: _append_iopub_output( lambda kind, payload: q.put((kind, payload)), msg), ) @@ -894,7 +922,8 @@ def _get_kernel(self): connection_file = get_embedded_kernel_connection_file() if connection_file is not None: try: - self._kernel = EmbeddedKernelBridge(connection_file) + self._kernel = EmbeddedKernelBridge( + connection_file, exec_timeout=_embedded_exec_timeout()) logger.info( "NotebookService: attached to embedded Jupyter kernel (%s)", connection_file) From 9e9820a75ff70777848090606873a63a96cd7f21 Mon Sep 17 00:00:00 2001 From: GuillaumePELLUET Date: Wed, 26 Aug 2026 13:50:09 +0200 Subject: [PATCH 5/6] Deselect scale tests from the fast CI job; cap test job at 15 minutes test_logger_scale.py deliberately builds multi-million-row DuckDB fixtures to stress-test large-scale queries -- real, by-design heavy work that easily exceeds the per-test timeout on a shared runner. Its own marker registration already said "deselect with -m 'not scale'" but the CI job never actually did, so each run burned its 600s per-test timeout on these instead of skipping them, reading as a hang stuck at the same progress percentage across unrelated pushes. --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a90f58a..c6bafd1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,7 +204,7 @@ jobs: # default. pytest's own --timeout=600 below should already catch a stuck # test, but this bounds the whole job even if some hang manages to dodge # that (e.g. a shared resource -- see WEIGHTSLAB_NOTEBOOK_EXEC_TIMEOUT). - timeout-minutes: 30 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -233,7 +233,16 @@ jobs: # the same contract-test class behind it. export WEIGHTSLAB_NOTEBOOK_EXEC_TIMEOUT="60" # A per-test timeout guards against any regression that hangs a test. - python -m pytest ./tests -v --timeout=600 + # -m "not scale": tests/backend/test_logger_scale.py deliberately + # builds a multi-million-row DuckDB fixture to stress-test large-scale + # queries (see its module docstring) -- real, by-design heavy work, + # not a hang, but easily 600s+ on a shared/throttled runner. Its own + # marker registration (pyproject.toml) already says "deselect with + # -m 'not scale'"; this job just never actually did. Each `scale` + # test hitting the per-test timeout instead of being deselected burns + # 10 minutes AND leaves the job stuck at the same progress % across + # unrelated pushes, which read as a hang. + python -m pytest ./tests -v --timeout=600 -m "not scale" # ── Agent smoke test on a pip-installed package ─────────────────────────── # Proves the Option-2 promise end-to-end: install weightslab into a CLEAN From 4728db246a22f54c6a5f7fa3c000b915f0f5ced6 Mon Sep 17 00:00:00 2001 From: GuillaumePELLUET Date: Wed, 26 Aug 2026 17:13:24 +0200 Subject: [PATCH 6/6] Update CHANGELOG - v2.0.1.dev0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 845b7d87..c995e940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -# Changelog - 2026-10-05 v1.5.1 (0) +# Changelog - 2026-08-26 v2.0.1.dev0