diff --git a/.gitignore b/.gitignore index abe29a63a..cad6e4583 100644 --- a/.gitignore +++ b/.gitignore @@ -187,6 +187,11 @@ webui/frontend/.react-router # file it asks for so a local 404-silencer never gets committed again. webui/frontend/public/.well-known/appspecific/com.chrome.devtools.json +# The traced runtime tree `pnpm build:image` assembles; `build/` above does not +# match it. The image builds its own copy inside the WebUI cache rather than here, +# but a local `pnpm build:image` lands one in the checkout. +webui/frontend/build-runtime/ + # Frontend CSS and dependency caches are generated during build and preparation. webui/frontend/public/antd/ webui/frontend/public/assets/ diff --git a/docker/webui.Dockerfile b/docker/webui.Dockerfile index 22e2e9763..97568a236 100644 --- a/docker/webui.Dockerfile +++ b/docker/webui.Dockerfile @@ -57,8 +57,17 @@ RUN mkdir -p /data /opt/ms-agent-webui-cache WORKDIR /app # This uses the installed wheel and the final runtime's Node version. No source # checkout or frontend compilation is performed inside the image. +# +# MS_AGENT_WEBUI_TRACE_RUNTIME keeps only the dependency closure the SSR entries +# can actually reach: `pnpm install --prod` lands ~430 MB in the cache against a +# real closure of ~51 MB, the waste sitting inside the packages rather than in a +# list of unneeded ones. Tracing imports `tsx` and `@vercel/nft`, both +# devDependencies, so the install it runs on is a full one -- transient, and +# discarded within this single RUN, so no layer retains it. The tracer boots the +# closure and renders a page before the full tree goes away, which is why a +# dependency it could not see fails HERE instead of a request in production. RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ - ms-agent ui --prepare-only --no-browser + MS_AGENT_WEBUI_TRACE_RUNTIME=1 ms-agent ui --prepare-only --no-browser # Runtime defaults also apply to agent subprocesses with filtered environments. # Mount replacement files at these paths to use another package index. RUN <<'EOF' diff --git a/ms_agent/cli/ui.py b/ms_agent/cli/ui.py index 97c0ba542..82864afe4 100644 --- a/ms_agent/cli/ui.py +++ b/ms_agent/cli/ui.py @@ -21,6 +21,13 @@ MIN_UV_VERSION = (0, 5, 0) IS_WINDOWS = os.name == 'nt' +#: Opt in to keeping only the traced SSR dependency closure instead of everything +#: `pnpm install` resolved. Deliberately not a CLI flag: the image sets it while +#: building, and the first preparation it triggers costs a full devDependency +#: install plus a tracing pass. Advertising it in `--help` would invite that onto +#: laptops, where the 430 MB it saves is a cache directory nobody ships. +TRACE_RUNTIME_ENV = 'MS_AGENT_WEBUI_TRACE_RUNTIME' + def _port_number(value): try: @@ -121,20 +128,38 @@ def install_node(frontend, production=False): def build_frontend(frontend): pnpm = _require_executable('pnpm') - _check_tool_versions({'node': node, 'pnpm': pnpm}, frontend) + _check_tool_versions({ + 'node': node, + 'pnpm': pnpm + }, frontend) print( '[setup] Building WebUI (including generated CSS)...', flush=True) _run_setup([pnpm, 'build'], frontend, 'frontend build') + def trace_runtime(frontend): + pnpm = _require_executable('pnpm') + _check_tool_versions({ + 'node': node, + 'pnpm': pnpm + }, frontend) + print( + '[setup] Tracing WebUI runtime dependencies...', + flush=True) + _run_setup( + [pnpm, 'exec', 'tsx', 'scripts/traceRuntime.ts'], + frontend, 'runtime dependency tracing') + if installed: + tracing = os.environ.get(TRACE_RUNTIME_ENV) == '1' webui = prepare_installed( webui, common, node_version, skip_install=self.args.skip_install, install_node=install_node, - build_frontend=build_frontend) + build_frontend=build_frontend, + trace_runtime=trace_runtime if tracing else None) python = Path(sys.executable) else: backend = webui / 'backend' diff --git a/ms_agent/cli/ui_resources.py b/ms_agent/cli/ui_resources.py index 8f8bd9f7f..848dfa101 100644 --- a/ms_agent/cli/ui_resources.py +++ b/ms_agent/cli/ui_resources.py @@ -128,7 +128,14 @@ def materialize(bundled, manifest, destination): shutil.rmtree(temporary) -def node_stamp(frontend, node_version, *, production=True): +def node_stamp(frontend, node_version, *, production=True, traced=False): + """Describe the prepared dependency tree, so a stale one is replaced. + + ``production`` describes the wheel's own mode, not the install that produced + the tree: tracing needs devDependencies, but that full install is scaffolding + for the tracer and is gone before this is written. ``traced`` is what tells a + closure apart from everything ``pnpm install`` resolved. + """ return { 'node': list(node_version), @@ -142,9 +149,51 @@ def node_stamp(frontend, node_version, *, production=True): json.loads((frontend / 'package.json').read_text())['packageManager'], 'production': production, + 'traced': + traced, } +def _stamp_matches(stamp, expected): + """Decide whether an already prepared tree satisfies this start. + + Tracing is a property of how the cache was built, not of how it is launched: + the image traces once while building and the container then starts from a + plain ``CMD`` that carries no such request. A traced tree therefore answers a + start that did not ask for one -- otherwise every container start would + reject the cache it was shipped with. The reverse must not hold: asking for + tracing when the cache holds the full tree has to re-prepare it, or a + regression in that step would quietly ship the whole 430 MB again. + """ + if stamp is None: + return False + if stamp.get('traced') and not expected['traced']: + stamp = {**stamp, 'traced': False} + return stamp == expected + + +def _prune_to_traced_closure(frontend): + """Replace the installed tree with the closure the tracer just verified. + + ``scripts/traceRuntime.ts`` assembles ``build-runtime/`` -- the build output, + the entries and only the ``node_modules`` files those entries can reach -- and + boots it to render a page before it returns. That order is the point: the full + tree is still intact while the closure proves itself, so a dependency the + tracer could not see fails before anything has been destroyed. + """ + traced = frontend / 'build-runtime' + closure = traced / 'node_modules' + if not closure.is_dir(): + raise UIError('Runtime tracing produced no dependency closure at ' + + str(closure)) + shutil.rmtree(frontend / 'node_modules') + # A rename, not a copy: pnpm's layout is relative symlinks into `.pnpm/` and + # the tracer recreated them as such, so moving the directory keeps every one + # of them resolving inside it. + closure.rename(frontend / 'node_modules') + shutil.rmtree(traced) + + def check_backend_dependencies(): modules = [ 'anthropic', 'exa_py', 'fastapi', 'httpx', @@ -164,8 +213,14 @@ def check_backend_dependencies(): ) -def prepare_installed(bundled, common, node_version, *, skip_install, - install_node, build_frontend=None): +def prepare_installed(bundled, + common, + node_version, + *, + skip_install, + install_node, + build_frontend=None, + trace_runtime=None): from ms_agent.version import __version__ manifest_file = bundled / 'RESOURCE-MANIFEST.json' @@ -186,20 +241,30 @@ def prepare_installed(bundled, common, node_version, *, skip_install, if prebuilt: common.validate_build(frontend) marker = frontend / '.node-dependencies.json' - expected = node_stamp(frontend, node_version, production=prebuilt) + expected = node_stamp( + frontend, + node_version, + production=prebuilt, + traced=bool(trace_runtime)) try: - ready = json.loads(marker.read_text()) == expected and ( - frontend / 'node_modules').is_dir() + stamp = json.loads(marker.read_text()) except (OSError, ValueError): - ready = False + stamp = None + ready = _stamp_matches( + stamp, expected) and (frontend / 'node_modules').is_dir() if not ready: if skip_install: raise UIError( 'WebUI Node dependencies need preparation. Run once without --skip-install.' ) marker.unlink(missing_ok=True) - install_node(frontend, production=prebuilt) - marker.write_text(json.dumps(expected, sort_keys=True) + '\n') + # The tracer runs out of this tree and imports `tsx` and + # `@vercel/nft` from it, so tracing cannot start from a production + # install. What it leaves behind is narrower than either. + install_node( + frontend, production=prebuilt and trace_runtime is None) + if trace_runtime is None: + marker.write_text(json.dumps(expected, sort_keys=True) + '\n') if not prebuilt: try: common.validate_build(frontend) @@ -212,4 +277,11 @@ def prepare_installed(bundled, common, node_version, *, skip_install, raise UIError('No WebUI frontend builder is available') build_frontend(frontend) common.validate_build(frontend) + if not ready and trace_runtime is not None: + # After any build, because the tracer follows what the SSR bundle + # imports. The stamp lands only once the closure is in place: an + # interrupted trace must not leave one claiming this tree is traced. + trace_runtime(frontend) + _prune_to_traced_closure(frontend) + marker.write_text(json.dumps(expected, sort_keys=True) + '\n') return destination diff --git a/ms_agent/config/model_settings.py b/ms_agent/config/model_settings.py index 7125a0459..ecd39980f 100644 --- a/ms_agent/config/model_settings.py +++ b/ms_agent/config/model_settings.py @@ -100,11 +100,7 @@ def remove_provider(self, provider_id: str) -> None: def add_model(self, provider_id: str, model: str) -> None: data = self._load_raw() providers = data.setdefault('providers', {}) - entry = providers.setdefault(provider_id, { - 'name': provider_id, - 'protocol': 'openai', - 'models': [] - }) + entry = providers.setdefault(provider_id, {}) models = entry.setdefault('models', []) if model not in models: models.append(model) diff --git a/tests/cli/test_ui.py b/tests/cli/test_ui.py index 913b97e4b..02759e33c 100644 --- a/tests/cli/test_ui.py +++ b/tests/cli/test_ui.py @@ -261,3 +261,68 @@ def test_reload_fails_with_development_instructions(capsys): ui.UICMD(_parse_ui_args('--reload')).execute() assert error.value.code == 1 assert 'pnpm dev' in capsys.readouterr().err + + +def _prepare_installed_kwargs(monkeypatch, trace_env=None): + """Drive execute() far enough to capture what it hands prepare_installed. + + `--prepare-only` returns before ports and the launcher, so the WebUI backend + never has to be importable here: a stub `app.processes` satisfies the single + import execute() makes for its own signal handling. + """ + import contextlib + import sys + import types + from pathlib import Path + + package = types.ModuleType('app') + package.__path__ = [] + + @contextlib.contextmanager + def interruptible(): + yield + + processes = types.ModuleType('app.processes') + processes.interruptible = interruptible + monkeypatch.setitem(sys.modules, 'app', package) + monkeypatch.setitem(sys.modules, 'app.processes', processes) + + if trace_env is None: + monkeypatch.delenv(ui.TRACE_RUNTIME_ENV, raising=False) + else: + monkeypatch.setenv(ui.TRACE_RUNTIME_ENV, trace_env) + + captured = {} + monkeypatch.setattr(ui, 'find_webui', lambda: (Path('/webui'), True)) + monkeypatch.setattr(ui, 'load_common', lambda webui: SimpleNamespace()) + monkeypatch.setattr(ui, '_require_executable', lambda name: '/tools/' + name) + monkeypatch.setattr(ui, '_check_tool_versions', + lambda tools, frontend=None: None) + monkeypatch.setattr(ui, '_read_semantic_version', + lambda *args, **kwargs: (22, 22, 0)) + + def fake_prepare(webui, common, node_version, **kwargs): + captured.update(kwargs) + return webui + + monkeypatch.setattr(ui, 'prepare_installed', fake_prepare) + ui.UICMD(_parse_ui_args('--prepare-only', '--no-browser')).execute() + return captured + + +def test_runtime_tracing_is_off_unless_the_environment_asks(monkeypatch): + assert _prepare_installed_kwargs(monkeypatch)['trace_runtime'] is None + + +def test_runtime_tracing_env_reaches_preparation(monkeypatch): + kwargs = _prepare_installed_kwargs(monkeypatch, trace_env='1') + assert callable(kwargs['trace_runtime']) + + +@pytest.mark.parametrize('value', ['0', '', 'true', 'yes', 'false']) +def test_only_an_exact_1_enables_runtime_tracing(monkeypatch, value): + """A truthiness check here would trace on `=0`, and the cost of that (a full + devDependency install plus a tracing pass) lands on whoever set the variable + specifically to turn the feature off.""" + kwargs = _prepare_installed_kwargs(monkeypatch, trace_env=value) + assert kwargs['trace_runtime'] is None diff --git a/tests/cli/test_ui_resources.py b/tests/cli/test_ui_resources.py index 392136a71..9429809ba 100644 --- a/tests/cli/test_ui_resources.py +++ b/tests/cli/test_ui_resources.py @@ -169,3 +169,94 @@ def fail(frontend): resources.prepare_installed(root, common, (22, 23, 2), skip_install=False, install_node=lambda *a, **kw: pytest.fail('must not install'), build_frontend=lambda *a: pytest.fail('must not rebuild')) + + +@pytest.fixture +def traced(bundle, tmp_path, monkeypatch): + """A prebuilt cache whose Node preparation can be traced down to its closure.""" + root, _ = bundle + monkeypatch.setenv('MS_AGENT_WEBUI_CACHE', str(tmp_path / 'runtime')) + monkeypatch.setattr(resources, 'check_backend_dependencies', lambda: None) + return SimpleNamespace( + bundled=root, + common=SimpleNamespace(validate_build=lambda directory: None), + cache=tmp_path / 'runtime') + + +def _install_everything(frontend, production): + """Stand in for `pnpm install`: a tree carrying a file no closure can reach.""" + modules = frontend / 'node_modules' + modules.mkdir() + (modules / 'unreachable.js').write_text('// the 430 MB nobody loads') + + +def _fake_trace(frontend): + """Stand in for traceRuntime.ts: assemble build-runtime/ the way it does, + including pnpm's relative symlink layout, which is what the move must keep.""" + closure = frontend / 'build-runtime/node_modules' + (closure / '.pnpm/antd@6/node_modules/antd').mkdir(parents=True) + (closure / '.pnpm/antd@6/node_modules/antd/index.js').write_text('// SSR') + (closure / 'antd').symlink_to('.pnpm/antd@6/node_modules/antd') + (frontend / 'build-runtime/server.js').write_text('// entry') + + +def test_tracing_replaces_the_installed_tree_with_its_closure(traced): + installs = [] + + def install(frontend, production): + # Tracing imports tsx and @vercel/nft out of this very tree, so it can + # never be a production install. + assert production is False + installs.append(frontend) + _install_everything(frontend, production) + + prepared = resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=install, + trace_runtime=_fake_trace) + modules = prepared / 'frontend/node_modules' + assert len(installs) == 1 + assert not (modules / 'unreachable.js').exists() + # Moved, not copied: the relative symlink still resolves inside the tree. + assert (modules / 'antd').is_symlink() + assert (modules / 'antd/index.js').read_text() == '// SSR' + assert not (prepared / 'frontend/build-runtime').exists() + + +def test_traced_cache_is_reused_by_a_plain_start(traced): + prepared = resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=_install_everything, + trace_runtime=_fake_trace) + # Exactly what the container does: CMD carries --skip-install and no tracing + # request, because tracing already happened while the image was built. This + # start must accept the tree it was shipped, not reject it as unprepared. + reused = resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=True, + install_node=lambda *a, **kw: pytest.fail('must not install')) + assert reused == prepared + + +def test_untraced_cache_is_re_prepared_when_tracing_is_requested(traced): + resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=_install_everything) + # The reverse of the case above must NOT hold: accepting the full tree here + # is how the image would quietly go back to shipping 430 MB. + with pytest.raises(resources.UIError, match='need preparation'): + resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=True, + install_node=lambda *a, **kw: pytest.fail('must not install'), + trace_runtime=lambda frontend: pytest.fail('must not trace')) + + +def test_tracing_that_produced_no_closure_keeps_the_installed_tree(traced): + def trace_without_closure(frontend): + (frontend / 'build-runtime').mkdir() + + with pytest.raises(resources.UIError, match='no dependency closure'): + resources.prepare_installed(traced.bundled, traced.common, (22, 23, 2), + skip_install=False, install_node=_install_everything, + trace_runtime=trace_without_closure) + frontend = next(traced.cache.glob('*/frontend')) + # Nothing half-pruned, and no stamp claiming this tree is a closure: the next + # attempt has to find it unprepared rather than serve an incomplete tree. + assert (frontend / 'node_modules/unreachable.js').exists() + assert not (frontend / '.node-dependencies.json').exists() diff --git a/tests/config/test_model_settings.py b/tests/config/test_model_settings.py index 953fd6b0d..2baabd3d2 100644 --- a/tests/config/test_model_settings.py +++ b/tests/config/test_model_settings.py @@ -29,6 +29,19 @@ def test_models_and_default(tmp_path): assert 'a-2' not in m.list_custom_providers()['acme']['models'] +def test_add_model_materializes_a_provider_without_inventing_metadata(tmp_path): + """The entry a first model creates must describe nothing but that model. + + Readers merge these entries over the built-in registry, so a stored field is + taken as the user's own override. Seeding a name and a protocol here made + adding the first model to a built-in provider an unasked-for rename (to its + id) and an unasked-for protocol change. + """ + m = ModelSettingsManager(global_dir=str(tmp_path)) + m.add_model('google', 'gemini-3-pro') + assert m.list_custom_providers()['google'] == {'models': ['gemini-3-pro']} + + def test_preserves_other_sections(tmp_path): p = tmp_path / 'settings.json' p.write_text(json.dumps({'theme': 'dark', 'llm': {'provider': 'x'}})) diff --git a/webui/AGENTS.md b/webui/AGENTS.md index 72ae60a11..5ceb9d890 100644 --- a/webui/AGENTS.md +++ b/webui/AGENTS.md @@ -67,9 +67,20 @@ cd frontend pnpm install --frozen-lockfile pnpm dev # http://localhost:5173, proxies /api/* to :8000 pnpm build # production build +pnpm build:image # the above, then assemble build-runtime/ — a traced runtime tree pnpm start # serve the build: SSR + /api proxy on one port (PORT, default: API port + 1) ``` +**Runtime image build:** `pnpm build:image` runs the normal build, then +`scripts/traceRuntime.ts` traces the SSR entries with `@vercel/nft` into +`build-runtime/` and verifies the assembled tree with a smoke render. + +`../docker/webui.Dockerfile` enables the same flow for installed packages with +`MS_AGENT_WEBUI_TRACE_RUNTIME=1`. The prepared cache records whether its runtime +is traced. To run the traced tree locally from `backend/`, use +`uv run webui --frontend-dir ../frontend/build-runtime`; the launcher rejects it +when its manifest does not match the checkout build. + **Frontend configuration** is declared in `backend/.env.example` and read by application code through `frontend/app/lib/env.ts`. Its `SERVER_*` exports are server-side values: the browser build replaces `process.env` with `{}`. Importing diff --git a/webui/backend/app/launcher.py b/webui/backend/app/launcher.py index b22988da5..b7360b010 100644 --- a/webui/backend/app/launcher.py +++ b/webui/backend/app/launcher.py @@ -167,6 +167,28 @@ def filter(self, record): ) +def _check_traced_tree(frontend: Path) -> None: + """Reject a `pnpm build:image` tree that no longer matches the checkout. + + Such a tree is a copy of `build/` next to a traced `node_modules`, and it has no + `app/` — so `validate_build` skips the source comparison and finds the copied + manifest agreeing with the copied outputs, which it always will. Editing a + component, rebuilding, and forgetting to re-trace would then serve the previous + UI with every check green. The checkout's manifest is the second opinion, and it + is only consulted when it exists: the image ships the traced tree alone. + """ + if frontend == FRONTEND_DIR or (frontend / "app").is_dir(): + return + manifest = FRONTEND_DIR / "build/webui-build.json" + traced = frontend / "build/webui-build.json" + if not manifest.is_file() or not traced.is_file(): + return + if sha256(manifest) != sha256(traced): + raise BuildError( + f"{frontend} was traced from a different build than {FRONTEND_DIR}/build" + ) + + def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser( description="Run the MS-Agent WebUI on one public port" @@ -183,6 +205,13 @@ def main(argv: list[str] | None = None) -> None: help="Internal loopback port; by default choose the next free port", ) parser.add_argument("--no-open", action="store_true") + parser.add_argument( + "--frontend-dir", + type=Path, + default=FRONTEND_DIR, + help="Frontend tree to serve; pass frontend/build-runtime to run what the " + "image ships instead of the checkout (see `pnpm build:image`)", + ) parser.add_argument("--startup-timeout", type=float, default=READY_TIMEOUT_S) args = parser.parse_args(argv) if not math.isfinite(args.startup_timeout) or args.startup_timeout <= 0: @@ -190,13 +219,20 @@ def main(argv: list[str] | None = None) -> None: host = args.host.strip().removeprefix("[").removesuffix("]") if not host: parser.error("--host cannot be empty") + frontend = Path(args.frontend_dir).expanduser().resolve() processes = [] exit_code = 0 with interruptible(): try: public, backend = select_ports(host, args.port, args.backend_port) + # A traced tree carries no `app/`, so validate_build cannot tell whether + # it still matches the sources — it only sees its own copied manifest and + # calls itself consistent. Compare the two manifests while the checkout is + # next door, or an edited component would be served from a stale copy with + # every check passing. + _check_traced_tree(frontend) css_href = validate_build( - FRONTEND_DIR, check_sources=(FRONTEND_DIR / "app").is_dir() + frontend, check_sources=(frontend / "app").is_dir() ) node = _require_node() # Load source dotenv before copying the child environment; installed @@ -239,8 +275,8 @@ def main(argv: list[str] | None = None) -> None: ( "frontend", _spawn( - [node, str(FRONTEND_DIR / "server.js")], - cwd=FRONTEND_DIR, + [node, str(frontend / "server.js")], + cwd=frontend, env=env, ), ) @@ -255,7 +291,7 @@ def main(argv: list[str] | None = None) -> None: processes, deadline, kind="css", - expected=sha256(FRONTEND_DIR / "build/client" / css_href.lstrip("/")), + expected=sha256(frontend / "build/client" / css_href.lstrip("/")), ) _check_children(processes) print(f"\nMS-Agent WebUI ready: {url}\n", flush=True) @@ -278,7 +314,8 @@ def main(argv: list[str] | None = None) -> None: ) as exc: print(f"Cannot start WebUI: {exc}", file=sys.stderr, flush=True) if isinstance(exc, BuildError): - print(f"Run `pnpm build` in {FRONTEND_DIR}", file=sys.stderr) + hint = "build:image" if frontend != FRONTEND_DIR else "build" + print(f"Run `pnpm {hint}` in {FRONTEND_DIR}", file=sys.stderr) exit_code = 1 finally: for _label, process in reversed(processes): diff --git a/webui/backend/tests/test_launcher.py b/webui/backend/tests/test_launcher.py index 9e194f60a..db72e803e 100644 --- a/webui/backend/tests/test_launcher.py +++ b/webui/backend/tests/test_launcher.py @@ -144,6 +144,39 @@ def test_changed_css_fails_even_when_sources_unchanged(built_frontend): validate_build(built_frontend) +def _traced_copy(frontend, tmp_path): + """What `pnpm build:image` assembles: build/ copied verbatim beside the server + entry, with no app/ — the sources stay behind in the checkout.""" + traced = tmp_path / "build-runtime" + shutil.copytree(frontend / "build", traced / "build") + shutil.copyfile(frontend / "server.js", traced / "server.js") + shutil.copyfile(frontend / "package.json", traced / "package.json") + return traced + + +def test_traced_tree_serves_without_the_sources_it_was_built_from( + built_frontend, tmp_path, monkeypatch +): + traced = _traced_copy(built_frontend, tmp_path) + monkeypatch.setattr(launcher, "FRONTEND_DIR", built_frontend) + launcher._check_traced_tree(traced) + assert validate_build(traced, check_sources=False) == "/assets/antd.test.css" + + +def test_traced_tree_from_an_older_build_is_rejected( + built_frontend, tmp_path, monkeypatch +): + traced = _traced_copy(built_frontend, tmp_path) + (built_frontend / "app/root.tsx").write_text("// changed") + _record_build_manifest(built_frontend) + monkeypatch.setattr(launcher, "FRONTEND_DIR", built_frontend) + # The blind spot this guard covers: on its own the stale copy passes, because + # the manifest it is checked against was copied along with the outputs. + assert validate_build(traced, check_sources=False) == "/assets/antd.test.css" + with pytest.raises(BuildError, match="traced from a different build"): + launcher._check_traced_tree(traced) + + def test_installed_settings_do_not_load_parent_dotenv(tmp_path): settings_file = Path(launcher.REPO_DIR) / "backend/app/core/settings.py" target = tmp_path / "webui/backend/app/core/settings.py" diff --git a/webui/backend/tests/test_providers.py b/webui/backend/tests/test_providers.py index ec5e35f3b..15fdff3cb 100644 --- a/webui/backend/tests/test_providers.py +++ b/webui/backend/tests/test_providers.py @@ -239,6 +239,38 @@ def test_clearing_a_builtin_display_name_restores_the_spec_default(): P.update_provider("openai", ProviderUpdate(api_key="", name="")) +def test_adding_a_model_does_not_relabel_a_builtin_provider(): + """Adding a model must not double as an edit of the provider itself. + + A built-in provider has no settings.json entry until something writes one, + and `add_model` writes the first one. That entry is merged over the registry + on read, so the SDK seeding it with `name: ` and + `protocol: "openai"` reached the UI as the user's own overrides: the first + model added to `google` renamed it to "google" for good (deleting the model + leaves the entry, and the label, behind) and `anthropic` was reported — and + configured — as an OpenAI-protocol endpoint. + """ + from app.backends.ms_agent import models as M + from app.backends.ms_agent import providers as P + from app.schemas.model import ModelCreate + + before = P.get_provider("anthropic") + assert before.name == "Anthropic" and before.protocol == "anthropic" + + created = M.create_model( + ModelCreate(provider_id="anthropic", name="claude-4-opus")) + try: + after = P.get_provider("anthropic") + assert after.name == before.name + assert after.protocol == before.protocol + assert after.base_url == before.base_url + finally: + M.delete_model(created.id) + # The materialized entry outlives the model it was created for; drop it + # so the built-in is back to "never configured" for later tests. + P.delete_provider("anthropic") + + def test_provider_id_accepts_letters_of_either_case(): """The id only has to be an identifier, not a lowercase slug.""" from app.schemas.provider import ProviderCreate diff --git a/webui/frontend/app/components/common/Composer.tsx b/webui/frontend/app/components/common/Composer.tsx index 4e05f7fa9..18de0bc7c 100644 --- a/webui/frontend/app/components/common/Composer.tsx +++ b/webui/frontend/app/components/common/Composer.tsx @@ -309,11 +309,7 @@ export function Composer({ // path; this only covers a Composer mounted outside that layout, where // there is no loader data to seed from. if (hasAppData) return - Promise.all([ - api.listProviders(), - api.listModels(), - api.getAgentSettings() - ]) + Promise.all([api.listProviders(), api.listModels(), api.getAgentSettings()]) .then(([ps, ms, s]) => { setProviders(ps) setModels(ms) @@ -417,6 +413,49 @@ export function Composer({ suggestOpenRef.current = false }, []) + // The panel scrolls (it is height-capped), so arrowing past its edge would + // move the highlighted row out of sight. Keep the active row in view — but + // only for keyboard moves: hover updates the index too, and nudging the list + // under a resting pointer would fight the mouse. + const suggestListRef = useRef(null) + const activeSuggestRef = useRef(null) + const suggestKeyNavRef = useRef(false) + + useEffect(() => { + if (!suggestKeyNavRef.current) return + suggestKeyNavRef.current = false + const list = suggestListRef.current + const item = activeSuggestRef.current + if (!list || !item) return + // Both ends snap all the way, so wrapping around lands on a clean edge with + // the panel's own padding visible instead of the row flush against it. + if (suggestIndex === 0) { + list.scrollTop = 0 + return + } + if (suggestIndex === filteredSuggestions.length - 1) { + list.scrollTop = list.scrollHeight + return + } + // Scrolled by hand rather than `scrollIntoView`: the panel lives in a body + // portal, so the browser would happily scroll the page behind it too. + const listBox = list.getBoundingClientRect() + const itemBox = item.getBoundingClientRect() + if (itemBox.top < listBox.top) { + list.scrollTop -= listBox.top - itemBox.top + } else if (itemBox.bottom > listBox.bottom) { + list.scrollTop += itemBox.bottom - listBox.bottom + } + }, [suggestIndex, filteredSuggestions.length]) + + // Editing the query reshuffles the list, so an index carried over from the + // previous set can point past its end (Enter would then pick nothing). Snap + // the selection — and the scroll position — back to the top. + useEffect(() => { + setSuggestIndex(0) + if (suggestListRef.current) suggestListRef.current.scrollTop = 0 + }, [filteredSuggestions]) + const selectSuggestion = useCallback( (item: { id: string; name: string; value: string }) => { // Replace the trailing `/query` the user was typing with an inline tag @@ -455,20 +494,25 @@ export function Composer({ switch (e.key) { case 'ArrowDown': e.preventDefault() + suggestKeyNavRef.current = true setSuggestIndex((i) => (i + 1) % filteredSuggestions.length) break case 'ArrowUp': e.preventDefault() + suggestKeyNavRef.current = true setSuggestIndex( (i) => (i - 1 + filteredSuggestions.length) % filteredSuggestions.length ) break - case 'Enter': + case 'Enter': { + const picked = filteredSuggestions[suggestIndex] + if (!picked) return e.preventDefault() e.stopPropagation() - selectSuggestion(filteredSuggestions[suggestIndex]) + selectSuggestion(picked) break + } case 'Escape': e.preventDefault() closeSuggestions() @@ -1033,12 +1077,15 @@ export function Composer({ 0} placement="top" - autoAdjustOverflow={false} popupRender={() => ( -
+
{filteredSuggestions.map((item, idx) => (
= { } const IMG_SIZE: Record = { + xs: 'h-[96px]', sm: 'h-[160px]', md: 'h-[200px]', lg: 'h-[240px]' } const PADDING: Record = { + xs: 'py-3', sm: 'py-6', md: 'py-10', lg: 'py-16' @@ -32,11 +36,21 @@ const PADDING: Record = { /** The description tracks the size variant: at `sm` (a sidebar group, a popover) * the body text sits next to 12px UI copy, where `text-sm` reads oversized. */ const TEXT_SIZE: Record = { + xs: 'text-xs', sm: 'text-xs', md: 'text-sm', lg: 'text-sm' } +/** The gap under the illustration shrinks with it — `xs` lives inside a dropdown + * panel, where 16px of air makes the two-line block look unanchored. */ +const TEXT_GAP: Record = { + xs: 'mt-1', + sm: 'mt-4', + md: 'mt-4', + lg: 'mt-4' +} + interface Props { /** Image & spacing size variant */ size?: EmptyStateSize @@ -72,7 +86,7 @@ export function EmptyState({ > {description && ( -

+

{description}

)} @@ -101,3 +115,43 @@ export function EmptyStateAction({ /> ) } + +/** The components antd asks `renderEmpty` about (Select, Table, Cascader, …). */ +type AntdEmptyComponent = Parameters< + NonNullable +>[0] + +/** + * The project empty state, in the shape antd's `ConfigProvider.renderEmpty` + * wants — wired once in `root.tsx`. + * + * antd's data components render their own empty state (its grey crate plus + * "No data") whenever a caller passes no `notFoundContent` / `locale.emptyText`, + * so the default kept surfacing in Select dropdowns and table bodies no matter + * how many call sites were converted by hand. Overriding it centrally is the + * only version of this that stays fixed as new Selects and Tables get written. + * + * Sizing follows antd's own split: list and table bodies have room for the + * illustration, popup panels get the compact one. + */ +export function AntdRenderEmpty({ + componentName +}: { + componentName?: AntdEmptyComponent +}) { + const { t } = useT() + // A filter dropdown supplies its own empty state and antd's default renders + // nothing here on purpose (the call site coalesces on nullish) — returning an + // illustration would stack a second one inside the filter panel. + if (componentName === 'Table.filter') return null + const inList = componentName === 'Table' || componentName === 'List' + return +} + +/** Ready to hand to `ConfigProvider.renderEmpty`. A module-level constant + * because antd keys its config context on this function's identity — an inline + * lambda would rebuild the context, and every consumer with it, on each render + * of the provider. */ +export const renderAntdEmpty: NonNullable< + ConfigProviderProps['renderEmpty'] +> = (componentName) => diff --git a/webui/frontend/app/components/common/ModelSelector.tsx b/webui/frontend/app/components/common/ModelSelector.tsx index efc247b8c..0490656e6 100644 --- a/webui/frontend/app/components/common/ModelSelector.tsx +++ b/webui/frontend/app/components/common/ModelSelector.tsx @@ -1,11 +1,12 @@ import { CheckOutlined } from '@ant-design/icons' import { Popover } from 'antd' import { Fragment, useMemo, useState } from 'react' +import { useNavigate } from 'react-router' import { ProviderTags } from '~/components/models/ProviderTags' import { useT } from '~/lib/i18n' import type { AgentSettings, Model, Provider } from '~/lib/types' import { PillButton } from './PillButton' -import { EmptyState } from './EmptyState' +import { EmptyState, EmptyStateAction } from './EmptyState' import { DeferredSkeleton } from './DeferredSkeleton' import './ModelSelector.css' import JumpIcon from '~/assets/icons/jump.svg?react' @@ -27,6 +28,7 @@ export function ModelSelector({ onSelectModel }: ModelSelectorProps) { const { t } = useT() + const navigate = useNavigate() const [open, setOpen] = useState(false) const [activeProviderId, setActiveProviderId] = useState(null) // Below `sm` the two panes cannot both fit: the panel is capped at the viewport @@ -70,6 +72,19 @@ export function ModelSelector({ setOpen(false) } + /** A provider with no models is a dead end here — models are added in global + * settings, never from this picker. The provider being browsed rides along in + * the URL so the page opens on it instead of on its own first one, which is + * rarely the one the user just found empty. */ + const goAddModels = () => { + setOpen(false) + navigate( + activeProvider + ? `/settings/models?provider=${encodeURIComponent(activeProvider.id)}` + : '/settings/models' + ) + } + return ( - {activeProvider.name} + + {activeProvider.name} +
@@ -161,7 +178,24 @@ export function ModelSelector({
+ {t.modelsAdmin.addModel} + + } />
) : ( diff --git a/webui/frontend/app/lib/locales/en.json b/webui/frontend/app/lib/locales/en.json index 9c57b9e69..5362f27e9 100644 --- a/webui/frontend/app/lib/locales/en.json +++ b/webui/frontend/app/lib/locales/en.json @@ -1,5 +1,8 @@ { "brand": "MS Agent", + "common": { + "noData": "No data" + }, "nav": { "newChat": "Start new chat", "newProject": "Start new project", diff --git a/webui/frontend/app/lib/locales/zh.json b/webui/frontend/app/lib/locales/zh.json index 6032e2a56..6e770f4e2 100644 --- a/webui/frontend/app/lib/locales/zh.json +++ b/webui/frontend/app/lib/locales/zh.json @@ -1,5 +1,8 @@ { "brand": "MS Agent", + "common": { + "noData": "暂无数据" + }, "nav": { "newChat": "开启新对话", "newProject": "开启新项目", diff --git a/webui/frontend/app/root.tsx b/webui/frontend/app/root.tsx index 37d5c25cd..ee18d8a65 100644 --- a/webui/frontend/app/root.tsx +++ b/webui/frontend/app/root.tsx @@ -15,6 +15,7 @@ import { import './app.css' import { NProgressHandler } from '~/components/common/NProgressHandler' +import { renderAntdEmpty } from '~/components/common/EmptyState' import { ErrorState } from '~/components/common/ErrorState' import { ApiError, registerApiErrorReporter } from '~/lib/api' import { getAntdCssHref } from '~/lib/antdStyle.server' @@ -197,6 +198,11 @@ function ThemedRoot({ children }: { children: React.ReactNode }) { locale={antdLocale} theme={getMsaAntdTheme(theme)} modal={msaModalProps} + // Every antd data component falls back to its own "No data" illustration + // when the call site names no empty content; this replaces all of them + // with the project's, so a new Select or Table is themed by default + // instead of by whoever remembers to pass `notFoundContent`. + renderEmpty={renderAntdEmpty} > diff --git a/webui/frontend/app/routes/settings/models.tsx b/webui/frontend/app/routes/settings/models.tsx index 7bc7d5327..48cd56755 100644 --- a/webui/frontend/app/routes/settings/models.tsx +++ b/webui/frontend/app/routes/settings/models.tsx @@ -1,9 +1,10 @@ import { Button, Popconfirm, Select, Tooltip } from 'antd' import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' import { AddProviderModal } from '~/components/models/AddProviderModal' import { ModelEditModal } from '~/components/models/ModelEditModal' import { ProviderTags } from '~/components/models/ProviderTags' -import { EmptyState } from '~/components/common/EmptyState' +import { EmptyState, EmptyStateAction } from '~/components/common/EmptyState' import { KeyStatusTag } from '~/components/common/KeyStatus' import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' import { api } from '~/lib/api' @@ -22,12 +23,18 @@ export function meta({ matches }: Route.MetaArgs) { export default function ModelsSettings() { const { t } = useT() + const [searchParams] = useSearchParams() // null = not loaded yet (skeleton), [] = genuinely no providers (empty // state). Collapsing the two would flash "no providers" on every visit. const [providers, setProviders] = useState(null) const [models, setModels] = useState([]) const [settings, setSettings] = useState(null) - const [activeProviderId, setActiveProviderId] = useState(null) + // `?provider=` lets a caller open this page on the provider it was talking + // about — the composer's model picker sends the one whose list it found empty. + // A seed only: the selection is the user's from here on. + const [activeProviderId, setActiveProviderId] = useState(() => + searchParams.get('provider') + ) // Provider add/edit share one modal: null = closed, { provider: null } = add, // { provider } = edit. @@ -37,7 +44,14 @@ export default function ModelsSettings() { const [modelEdit, setModelEdit] = useState<{ provider: Provider model: Model | null + /** Opened from the default-model picker: the model it creates is the one the + * user was trying to pick, so it becomes the selection on save. */ + asDefault?: boolean } | null>(null) + // Controlled so the empty state's "add model" button can close the panel: + // the select popup outranks the modal mask, and would otherwise float on top + // of the dialog it just opened. + const [defaultModelOpen, setDefaultModelOpen] = useState(false) const refresh = () => Promise.all([ @@ -49,8 +63,12 @@ export default function ModelsSettings() { setProviders(ps) setModels(ms) setSettings(s) - // Default-select the first provider when nothing is selected. - setActiveProviderId((prev) => prev ?? ps[0]?.id ?? null) + // Default-select the first provider when nothing is selected. A seed + // naming a provider this instance does not have is dropped here rather + // than left selected, which would render as a blank detail pane. + setActiveProviderId((prev) => + prev && ps.some((p) => p.id === prev) ? prev : (ps[0]?.id ?? null) + ) }) // `null` gates the skeletons on this page, so a failure has to settle the // lists to `[]` or they stay skeletons for good. `Promise.all` means any @@ -76,6 +94,11 @@ export default function ModelsSettings() { [models, activeProviderId] ) + const defaultProvider = useMemo( + () => providers?.find((p) => p.id === settings?.default_provider_id) ?? null, + [providers, settings?.default_provider_id] + ) + const updateSettings = async (patch: Partial) => { if (!settings) return const next = await api.putAgentSettings({ ...settings, ...patch }) @@ -94,6 +117,12 @@ export default function ModelsSettings() { ) } + const addDefaultModel = () => { + if (!defaultProvider) return + setDefaultModelOpen(false) + setModelEdit({ provider: defaultProvider, model: null, asDefault: true }) + } + const defaultModelOptions = useMemo( () => models @@ -172,6 +201,27 @@ export default function ModelsSettings() { value={resolvedDefaultModelId} onChange={(v) => updateSettings({ default_model_id: v })} options={defaultModelOptions} + open={defaultModelOpen} + onOpenChange={setDefaultModelOpen} + // A provider with no models leaves this picker with nothing to + // offer, and the models list that fixes it is further down the + // page — behind a provider selection of its own. Adding from here + // opens the same modal that pane uses, on the provider this + // picker is already pointed at. + notFoundContent={ + + {t.modelsAdmin.addModel} + + } + /> + } className="w-full" placeholder="—" disabled={!settings?.default_provider_id} @@ -309,8 +359,25 @@ export default function ModelsSettings() { model={modelEdit?.model ?? null} providers={providers ?? []} onClose={() => setModelEdit(null)} - onSaved={() => { + onSaved={async (m) => { + const asDefault = modelEdit?.asDefault ?? false setModelEdit(null) + // Coming from the default-model picker, the new model is what the + // user was there to choose. The provider rides along because picking + // one is local state until a model is saved with it — the reload + // below would otherwise restore the previously persisted provider and + // drop the model out of sight. Awaited, since a concurrent GET can + // still answer with the pre-save settings. + if (asDefault) { + try { + await updateSettings({ + default_provider_id: m.provider_id, + default_model_id: m.id + }) + } catch { + // API errors surface via the global toast. + } + } refresh() }} /> diff --git a/webui/frontend/package.json b/webui/frontend/package.json index b8cf0ce20..bd3edadd1 100644 --- a/webui/frontend/package.json +++ b/webui/frontend/package.json @@ -12,6 +12,7 @@ "check:frames": "tsx scripts/checkThoughtFrames.ts", "dev": "pnpm gen:antd-css && react-router dev", "build": "pnpm gen:antd-css && react-router build && tsx scripts/buildManifest.ts", + "build:image": "pnpm build && tsx scripts/traceRuntime.ts", "start": "node ./server.js", "typecheck": "react-router typegen && node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit" }, @@ -23,7 +24,6 @@ "@ant-design/x-sdk": "^2.8.0", "@monaco-editor/react": "^4.7.0", "@react-router/node": "^8.1.0", - "@react-router/serve": "^8.1.0", "antd": "^6.5.0", "compression": "1.8.1", "express": "5.2.1", @@ -44,6 +44,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", + "@vercel/nft": "^1.11.0", "tailwindcss": "^4.3.2", "tsx": "^4.23.12", "typescript": "^6.0.3", diff --git a/webui/frontend/pnpm-lock.yaml b/webui/frontend/pnpm-lock.yaml index a791e54e3..2b6443bfa 100644 --- a/webui/frontend/pnpm-lock.yaml +++ b/webui/frontend/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: '@react-router/node': specifier: ^8.1.0 version: 8.1.0(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3) - '@react-router/serve': - specifier: ^8.1.0 - version: 8.1.0(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3) antd: specifier: ^6.5.0 version: 6.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -87,6 +84,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 + '@vercel/nft': + specifier: ^1.11.0 + version: 1.11.0(rollup@4.60.4) tailwindcss: specifier: ^4.3.2 version: 4.3.2 @@ -545,6 +545,10 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -561,6 +565,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} + engines: {node: '>=18'} + hasBin: true + '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} @@ -1442,10 +1451,33 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/nft@1.11.0': + resolution: {integrity: sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA==} + engines: {node: '>=20'} + hasBin: true + + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + antd@6.5.0: resolution: {integrity: sha512-9zbVc9UukfGuqCvIAov01nlpDQWfARNmZQyt21ZhqLX7ilXmi4cdkp12xA48WEmXRXwZvno8A03qQuGE9JG8fg==} peerDependencies: @@ -1455,9 +1487,16 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.31: resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} engines: {node: '>=6.0.0'} @@ -1467,10 +1506,17 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1515,6 +1561,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1544,6 +1594,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1897,6 +1951,9 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1933,6 +1990,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1982,6 +2043,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2172,6 +2237,10 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2216,6 +2285,18 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} @@ -2245,9 +2326,27 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.44: resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + nprogress@0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} @@ -2295,6 +2394,10 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -2399,6 +2502,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -2519,6 +2626,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + throttle-debounce@5.0.2: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} @@ -2539,6 +2650,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -2637,12 +2751,22 @@ packages: yaml: optional: true + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + snapshots: '@ant-design/colors@8.0.1': @@ -3131,6 +3255,10 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3150,6 +3278,19 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mapbox/node-pre-gyp@2.0.3': + dependencies: + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.8.5 + tar: 7.5.22 + transitivePeerDependencies: + - encoding + - supports-color + '@mermaid-js/parser@1.1.1': dependencies: '@chevrotain/types': 11.1.2 @@ -3562,6 +3703,7 @@ snapshots: react-router: 8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) optionalDependencies: typescript: 6.0.3 + optional: true '@react-router/node@8.1.0(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)': dependencies: @@ -3583,6 +3725,7 @@ snapshots: transitivePeerDependencies: - supports-color - typescript + optional: true '@remix-run/node-fetch-server@0.13.3': {} @@ -4020,11 +4163,40 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/nft@1.11.0(rollup@4.60.4)': + dependencies: + '@mapbox/node-pre-gyp': 2.0.3 + '@rollup/pluginutils': 5.4.0(rollup@4.60.4) + acorn: 8.18.0 + acorn-import-attributes: 1.9.5(acorn@8.18.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.8.4 + picomatch: 4.0.4 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + abbrev@3.0.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + antd@6.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@ant-design/colors': 8.0.1 @@ -4083,6 +4255,8 @@ snapshots: argparse@2.0.1: {} + async-sema@3.1.1: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7 @@ -4092,12 +4266,18 @@ snapshots: transitivePeerDependencies: - supports-color + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.31: {} basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -4112,6 +4292,10 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.31 @@ -4120,7 +4304,8 @@ snapshots: node-releases: 2.0.44 update-browserslist-db: 1.2.3(browserslist@4.28.2) - buffer-from@1.1.2: {} + buffer-from@1.1.2: + optional: true bytes@3.1.2: {} @@ -4150,6 +4335,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@3.0.0: {} + clsx@2.1.1: {} comma-separated-tokens@2.0.3: {} @@ -4178,6 +4365,8 @@ snapshots: confbox@0.2.4: {} + consola@3.4.2: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -4569,6 +4758,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-uri-to-path@1.0.0: {} + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -4611,6 +4802,12 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -4669,6 +4866,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -4809,6 +5013,8 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4859,6 +5065,16 @@ snapshots: dependencies: mime-db: 1.54.0 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + monaco-editor@0.55.1: dependencies: dompurify: 3.2.7 @@ -4889,8 +5105,18 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} + node-releases@2.0.44: {} + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + nprogress@0.2.0: {} object-inspect@1.13.4: {} @@ -4938,6 +5164,11 @@ snapshots: path-data-parser@0.1.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -5032,6 +5263,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + robust-predicates@3.0.3: {} rolldown@1.1.4: @@ -5188,8 +5421,10 @@ snapshots: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 + optional: true - source-map@0.6.1: {} + source-map@0.6.1: + optional: true space-separated-tokens@2.0.2: {} @@ -5215,6 +5450,14 @@ snapshots: tapable@2.3.3: {} + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + throttle-debounce@5.0.2: {} tinyexec@1.1.2: {} @@ -5231,6 +5474,8 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: {} + ts-dedent@2.2.0: {} tslib@2.8.1: {} @@ -5292,6 +5537,15 @@ snapshots: jiti: 2.7.0 tsx: 4.23.12 + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + wrappy@1.0.2: {} yallist@3.1.1: {} + + yallist@5.0.0: {} diff --git a/webui/frontend/scripts/traceRuntime.ts b/webui/frontend/scripts/traceRuntime.ts new file mode 100644 index 000000000..f066b2371 --- /dev/null +++ b/webui/frontend/scripts/traceRuntime.ts @@ -0,0 +1,224 @@ +/** + * Assemble `build-runtime/` — the exact tree the production image runs. + * + * It holds the build output, `server.js`, `package.json`, and ONLY the + * `node_modules` files reachable from the two entries Node actually loads in + * production: `build/server/index.js` (the SSR bundle) and `server.js`. The + * image used to ship `pnpm prune --prod`'s whole tree, 430 MB, because Vite + * leaves SSR dependencies external and they therefore have to exist at runtime. + * Almost none of those bytes are reachable, though — the waste is INSIDE the + * packages, not a list of packages nobody asked for: antd ships 58 MB of `es/` + * plus `lib/` plus every locale, of which SSR touches 2.3 MB; mermaid 75 MB of + * which 2.3 MB; `typescript` (24 MB) is only a peer of `@react-router/node` and + * is never imported. Tracing the real closure instead lands around 51 MB. + * + * `@vercel/nft` does the tracing (the same resolver Vercel packages functions + * with), so `require`/`import`/`import()` with a static specifier, subpath + * exports and pnpm's symlink layout are all handled. What it CANNOT see is a + * specifier computed at runtime, and a package missing for that reason would + * surface as a 500 on whichever page needs it. Hence `verify()`: this script + * boots the assembled tree and renders a page through it before returning, so + * that failure lands on whoever builds the image instead of on a user. + * + * Not part of `pnpm build`: it costs a copy of ~50 MB and 17s of tracing, and + * only the image consumes it. `pnpm build:image` is `pnpm build` plus this, and + * it is what the Dockerfile's frontend stage runs. + */ +import { nodeFileTrace } from '@vercel/nft' +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import net from 'node:net' +import path from 'node:path' + +const root = process.cwd() +const out = path.join(root, 'build-runtime') + +/** What Node loads at runtime. `server.js` is the process entry; it imports the + * SSR bundle dynamically (`await import(SERVER_BUILD.href)`) from a URL built + * at runtime, which is exactly the shape nft cannot follow — so the bundle is + * named here as a second entry rather than left to be discovered. */ +const ENTRIES = ['build/server/index.js', 'server.js'] + +/** Copied whole, not traced: `build/client` is served to browsers as opaque + * files (nft has no reason to know they exist), and `package.json` is what + * makes Node read `server.js` as ESM — `"type": "module"` lives there. `public/` + * is deliberately absent, as it is from today's image: `react-router build` + * already copied it into `build/client`, and server.js's own fallback for it + * passes `fallthrough: true`, so a missing directory is a no-op. */ +const VERBATIM = ['build', 'server.js', 'package.json'] + +/** Rendered by the smoke check. Chosen because it needs NO reachable backend: + * its loader degrades an API failure to empty lists (the project-wide + * convention), so the page still renders and a 200 means the whole antd + + * @ant-design/x + react-router import graph resolved and React ran. The home + * route would answer 500 here — it lets the API error through on purpose. */ +const SMOKE_PATH = '/settings/mcp-skills' + +const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1) + ' MB' + +/** Distinct free loopback ports. Every probe is held open until the last one is + * bound, because the OS happily hands back a port it released a millisecond + * ago: probing them one at a time returned the SAME number twice, which aimed + * the app's API base at the port it was serving on and had server.js proxy + * `/api` to itself. */ +function freePorts(count: number): Promise { + return new Promise((resolve, reject) => { + const probes: net.Server[] = [] + const ports: number[] = [] + const next = (): void => { + if (ports.length === count) { + let pending = probes.length + for (const probe of probes) probe.close(() => --pending || resolve(ports)) + return + } + const probe = net.createServer() + probes.push(probe) + probe.once('error', reject) + probe.listen(0, '127.0.0.1', () => { + ports.push((probe.address() as net.AddressInfo).port) + next() + }) + } + next() + }) +} + +function connects(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: '127.0.0.1' }) + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => { + socket.destroy() + resolve(false) + }) + }) +} + +/** Boot the assembled tree and render one page through it. */ +async function verify(): Promise { + // Two ports: one to serve on, one that is guaranteed to refuse connections so + // the API is *reachably absent*. Pointing the app at a port something else + // happens to hold would have it render whatever that answers. + const [port, closed] = await freePorts(2) + const child = spawn(process.execPath, ['./server.js'], { + cwd: out, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + NODE_ENV: 'production', + HOST: '127.0.0.1', + PORT: String(port), + MS_AGENT_API_BASE_URL: `http://127.0.0.1:${closed}`, + MS_AGENT_FRONTEND_API_BASE_URL: `http://127.0.0.1:${closed}`, + MS_AGENT_WEBUI_BANNER: '0' + } + }) + let log = '' + child.stdout.on('data', (chunk: Buffer) => (log += chunk)) + child.stderr.on('data', (chunk: Buffer) => (log += chunk)) + let exited: number | null = null + child.once('exit', (code) => (exited = code ?? 0)) + + const fail = (message: string): never => { + child.kill('SIGKILL') + throw new Error(`${message}\n--- server output ---\n${log.trim()}`) + } + + try { + // Importing the SSR bundle takes a second or two before the socket is bound; + // a container under load takes longer, so the budget is generous. + const deadline = Date.now() + 60_000 + while (!(await connects(port))) { + if (exited !== null) fail(`server.js exited with ${exited} before listening`) + if (Date.now() > deadline) fail('server.js never accepted a connection') + await new Promise((resolve) => setTimeout(resolve, 200)) + } + + const response = await fetch(`http://127.0.0.1:${port}${SMOKE_PATH}`) + const body = await response.text() + if (response.status === 404) { + fail(`${SMOKE_PATH} is gone — point SMOKE_PATH at a route that renders without a backend`) + } + if (response.status !== 200) { + fail(`${SMOKE_PATH} answered ${response.status}, expected 200`) + } + // A 200 carrying an empty shell would mean antd never rendered: this class + // is the css-var key `getMsaAntdTheme()` pins, so it only appears once the + // theme and the component tree actually made it into the HTML. + if (!/msa-theme-(?:light|dark)/.test(body)) { + fail(`${SMOKE_PATH} rendered without the antd theme class — the SSR output is not usable`) + } + // The two ways a dependency nft could not see reports itself. + const missing = log.match(/Cannot find (?:package|module) [^\n]+/) + if (missing) fail(`a dependency is missing from the traced tree: ${missing[0]}`) + + console.log(`[webui] Verified ${SMOKE_PATH} renders from build-runtime (${body.length} bytes)`) + } finally { + if (exited === null) { + child.kill('SIGTERM') + await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill('SIGKILL') + resolve(null) + }, 5_000) + child.once('exit', () => { + clearTimeout(timer) + resolve(null) + }) + }) + } + } +} + +for (const entry of ENTRIES) { + if (!fs.existsSync(path.join(root, entry))) { + throw new Error(`Missing ${entry} — run \`pnpm build\` first`) + } +} + +// Retries, because the default of none makes this a coin flip on macOS: a +// recursive remove scans a directory, unlinks what it saw, then rmdir's it, and +// Finder drops a `.DS_Store` into `build-runtime` the moment it is looked at — +// arriving after the scan, that turns the rmdir into ENOTEMPTY and fails the +// whole build on nothing. Node retries exactly this class when asked to. +fs.rmSync(out, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) +for (const rel of VERBATIM) { + fs.cpSync(path.join(root, rel), path.join(out, rel), { recursive: true }) +} + +const { fileList } = await nodeFileTrace( + ENTRIES.map((rel) => path.join(root, rel)), + { base: root } +) + +let files = 0 +let bytes = 0 +for (const rel of fileList) { + // Only dependencies: everything else the tree needs is in VERBATIM, and + // leaving the rest out keeps stray sources (`app/`, `public/`) from riding + // along just because something referenced their path. + if (!rel.startsWith('node_modules/')) continue + const src = path.join(root, rel) + const dst = path.join(out, rel) + fs.mkdirSync(path.dirname(dst), { recursive: true }) + // pnpm's layout is symlinks into `.pnpm/`, and nft lists both the link and + // its target. Recreating the link (rather than following it) keeps the + // resolution behaviour identical to the tree this was traced from — and keeps + // one physical copy of a package shared by several dependents. + const stat = fs.lstatSync(src) + if (stat.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dst) + } else if (stat.isFile()) { + fs.copyFileSync(src, dst) + bytes += stat.size + } + files += 1 +} +if (files === 0) throw new Error('Traced no dependencies at all — the entries cannot be right') + +await verify() + +console.log(`[webui] build-runtime carries ${files} dependency entries, ${mb(bytes)}`)