Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions services/convsim-core/convsim-core.spec
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ a = Analysis(
# These land at sys._MEIPASS/packs/official/ and are found by config.py
# when it detects the frozen (PyInstaller) environment.
(str(_REPO_ROOT / 'packs' / 'official'), 'packs/official'),
# Model registry catalogue (names, licenses, pinned URLs + SHA-256).
# Seeded into SQLite at startup; without it a fresh install has no
# models to offer and "Set me up" dead-ends with MODEL_NOT_FOUND.
(str(_REPO_ROOT / 'model-registry' / 'registry.yaml'), 'model-registry'),
],
hiddenimports=[
# uvicorn resolves protocol and loop implementations at runtime.
Expand Down
22 changes: 22 additions & 0 deletions services/convsim-core/convsim_core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ async def lifespan(app: FastAPI):
supervisor.register(kokoro_sidecar)
app.state.supervisor = supervisor
seed_official_packs(config, db.connection())
# Seed the model registry from the bundled catalogue so a fresh
# install has models to offer. Idempotent upsert; failures must not
# block startup (the models UI degrades to user-supplied GGUF only).
try:
from convsim_core.services.model_registry_service import (
load_and_persist_registry,
)

_registry_path = Path(config.model_registry_path)
if _registry_path.is_file():
load_and_persist_registry(db.connection(), _registry_path)
else:
import logging as _logging

_logging.getLogger(__name__).warning(
"Model registry file not found at %s — model catalogue will be empty",
_registry_path,
)
except Exception as _exc: # noqa: BLE001
import logging as _logging

_logging.getLogger(__name__).warning("Model registry seeding failed: %s", _exc)
# Re-drive any one-click install pipeline that a crash/kill left mid-flight
# so the download resumes from its .part offset instead of freezing.
setup_install_router.resume_orphaned_jobs(app)
Expand Down
16 changes: 16 additions & 0 deletions services/convsim-core/convsim_core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ def _default_official_packs_dir() -> str:
_DEFAULT_OFFICIAL_PACKS_DIR = _default_official_packs_dir()


def _default_model_registry_path() -> str:
"""Resolve the read-only bundled model registry YAML.

Frozen builds embed it at ``sys._MEIPASS/model-registry/registry.yaml``
(see convsim-core.spec); development resolves relative to the repo root.
"""
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
return str(Path(meipass) / "model-registry" / "registry.yaml")
return str(Path(__file__).resolve().parents[3] / "model-registry" / "registry.yaml")


_DEFAULT_MODEL_REGISTRY_PATH = _default_model_registry_path()


class ServiceConfig(BaseSettings):
"""Runtime configuration for the convsim-core process.

Expand All @@ -64,6 +79,7 @@ class ServiceConfig(BaseSettings):
# Read-only official packs served (browse-only) by the Creator Workbench.
# Defaults to the repo's bundled packs/official directory.
official_packs_dir: str = _DEFAULT_OFFICIAL_PACKS_DIR
model_registry_path: str = _DEFAULT_MODEL_REGISTRY_PATH
# Set CONVSIM_LOCAL_DEV_PACKS_DIR to a directory that contains in-progress
# pack folders. The /api/packs/import/folder endpoint only accepts source
# paths that fall within packs_dir or this directory; leaving it unset
Expand Down
26 changes: 22 additions & 4 deletions services/convsim-core/tests/test_model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,28 @@ def test_get_models_returns_200(client):
assert resp.status_code == 200


def test_get_models_empty_database_returns_empty_list(client):
body = client.get("/api/models").json()
assert body["registry"] == []
assert body["total"] == 0
def test_get_models_empty_database_returns_empty_list(tmp_path):
# Startup now seeds the registry from the bundled catalogue by default, so
# an empty registry requires pointing the config at a missing file. The
# endpoint must still degrade to an empty list rather than erroring.
from convsim_core.app import create_app
from convsim_core.config import ServiceConfig
from fastapi.testclient import TestClient

config = ServiceConfig(
host="127.0.0.1",
port=7355,
data_dir=str(tmp_path / "data"),
log_dir=str(tmp_path / "logs"),
db_dir=str(tmp_path / "db"),
packs_dir=str(tmp_path / "packs"),
model_registry_path=str(tmp_path / "missing" / "registry.yaml"),
)
app = create_app(config)
with TestClient(app) as empty_client:
body = empty_client.get("/api/models").json()
assert body["registry"] == []
assert body["total"] == 0


def test_get_models_after_registry_load_returns_sorted_entries(client):
Expand Down
74 changes: 74 additions & 0 deletions services/convsim-core/tests/test_model_registry_seeding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
"""Startup must seed the model registry from the bundled catalogue.

A fresh profile previously had an EMPTY model_registry table — nothing in the
app ever called ``load_and_persist_registry`` — so the Welcome screen had no
starter model to offer and "Set me up" dead-ended with MODEL_NOT_FOUND before
any download began. These tests pin the seeded startup state a new user's
first session depends on.
"""
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from convsim_core.app import create_app
from convsim_core.config import ServiceConfig

_REPO_ROOT = Path(__file__).resolve().parents[3]


@pytest.fixture()
def fresh_client(tmp_path):
config = ServiceConfig(
host="127.0.0.1",
port=7355,
data_dir=str(tmp_path / "data"),
log_dir=str(tmp_path / "logs"),
db_dir=str(tmp_path / "db"),
packs_dir=str(tmp_path / "packs"),
model_registry_path=str(_REPO_ROOT / "model-registry" / "registry.yaml"),
)
app = create_app(config)
with TestClient(app) as c:
yield c


def test_fresh_profile_has_seeded_model_registry(fresh_client):
resp = fresh_client.get("/api/models")
assert resp.status_code == 200
registry = resp.json()["registry"]
assert registry, "fresh profile must offer models from the bundled registry"
roles = {m["role"] for m in registry}
assert "starter" in roles, "the Set-me-up flow requires a starter-role model"


def test_setup_install_accepts_the_starter_model(fresh_client):
registry = fresh_client.get("/api/models").json()["registry"]
starter = next(m for m in registry if m["role"] == "starter")
resp = fresh_client.post("/api/setup/install", json={"registry_id": starter["id"]})
# 200 — the pipeline job is created (the engine stage may later skip or
# download; what must never happen on a fresh install is MODEL_NOT_FOUND).
assert resp.status_code == 200, resp.text
job = resp.json()
assert job["registry_id"] == starter["id"]
assert job["status"] in ("pending", "running")
# Cancel immediately: this test asserts admission, not the download.
fresh_client.delete(f"/api/setup/install/{job['id']}")


def test_missing_registry_file_does_not_break_startup(tmp_path):
config = ServiceConfig(
host="127.0.0.1",
port=7355,
data_dir=str(tmp_path / "data"),
log_dir=str(tmp_path / "logs"),
db_dir=str(tmp_path / "db"),
packs_dir=str(tmp_path / "packs"),
model_registry_path=str(tmp_path / "nope" / "registry.yaml"),
)
app = create_app(config)
with TestClient(app) as c:
resp = c.get("/api/models")
assert resp.status_code == 200
assert resp.json()["registry"] == []
Loading