diff --git a/.release-please-manifest.json b/.release-please-manifest.json index da42fbd..f9e5f5f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "0.3.0", - "desktop": "0.3.0" + ".": "0.4.0-b", + "desktop": "0.4.0-b" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 273dfd2..885fbe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +## [0.4.0-b](https://github.com/grayhatdevelopers/vidxp/compare/v0.3.0...v0.4.0-b) (2026-07-31) + + +### Features + +* **api:** add explicit local network sharing ([#39](https://github.com/grayhatdevelopers/vidxp/issues/39)) ([37f3a84](https://github.com/grayhatdevelopers/vidxp/commit/37f3a84f03dcfc9022d723bac91d970352c3c1e3)) + + +### Bug Fixes + +* **models:** harden preparation downloads ([62cb7b1](https://github.com/grayhatdevelopers/vidxp/commit/62cb7b1b2dbdfbc968435bb76101b3199a88c1f8)) +* **models:** harden preparation downloads ([69c5e51](https://github.com/grayhatdevelopers/vidxp/commit/69c5e51588c496699fe3a49b8c61a374f1058a98)) + ## [0.3.0](https://github.com/grayhatdevelopers/vidxp/compare/v0.2.0...v0.3.0) (2026-07-31) diff --git a/Dockerfile b/Dockerfile index 75c0d46..b27524f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PATH="/opt/vidxp/bin:${PATH}" \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + VIDXP_HTTP_PORT=8000 USER vidxp WORKDIR /var/lib/vidxp diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index d8ddca4..f0f50c7 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -179,10 +179,19 @@ vidxp doctor --modalities dialogue,actor ### 5. Start the selected surface - CLI: `vidxp --help` -- Browser UI: `vidxp ui` -- Local HTTP API and remote MCP: `vidxp-api` +- Loopback browser UI: `vidxp ui` +- LAN-shared unauthenticated browser UI: `vidxp ui --share` +- Local HTTP API and MCP: `vidxp-api` +- LAN-shared authenticated HTTP API and MCP: `vidxp-api --share` - Local stdio MCP: `vidxp-mcp` +The browser UI binds to loopback unless `--share` is present. In share mode, +VidXP gives Streamlit an explicit wildcard bind and Streamlit prints both the +Local and Network URLs. The UI has no authentication, so share it only on a +trusted network. +VidXP suppresses Streamlit's first-run email prompt and disables Streamlit +usage-statistics collection for this managed launch. + ## First CLI index ```bash @@ -263,15 +272,31 @@ Install `local-worker,server`, prepare models, then run: vidxp-api ``` +The default is reachable only from the same machine. To deliberately share it +on the machine's detected LAN address, run `vidxp-api --share`. Share mode: + +- generates and then reuses an app-owned bearer token; +- binds Uvicorn to the detected LAN address; +- configures the HTTP and MCP Host-header policies for that address; and +- prints the exact health URL, Streamable HTTP MCP URL, and bearer token. + +The managed token is stored as `api-share-token` in VidXP's platform-native +configuration directory. Share mode uses plain HTTP and is intended for a +trusted local network; use the supported reverse-proxy deployment when TLS is +required. + The unauthenticated local default is deliberately loopback-only: | Endpoint | Purpose | |---|---| -| `http://127.0.0.1:8000/docs` | Interactive OpenAPI | -| `http://127.0.0.1:8000/openapi.json` | Machine-readable contract | -| `http://127.0.0.1:8000/health` | Process liveness | -| `http://127.0.0.1:8000/ready` | Aggregate runtime readiness | -| `http://127.0.0.1:8000/mcp` | Streamable HTTP MCP | +| `http://127.0.0.1:32191/docs` | Interactive OpenAPI | +| `http://127.0.0.1:32191/openapi.json` | Machine-readable contract | +| `http://127.0.0.1:32191/health` | Process liveness | +| `http://127.0.0.1:32191/ready` | Aggregate runtime readiness | +| `http://127.0.0.1:32191/mcp` | Streamable HTTP MCP | + +Native installs default to port `32191` to avoid the heavily reused development +port `8000`. Use `vidxp-api --port ` when a specific port is required. Do not bind an unauthenticated API to a non-loopback address. Public deployments require static bearer or OIDC authentication and should use the diff --git a/README.md b/README.md index fff63e4..69daa56 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,11 @@ uv tool install --python 3.14 --torch-backend cpu \ vidxp ui ``` +`vidxp ui` binds to loopback by default. Use `vidxp ui --share` only when you +intend to expose the unauthenticated browser interface on the local network. +Streamlit prints its Local and Network URLs when it starts. VidXP disables +Streamlit's first-run email prompt and usage-statistics collection. + If the `vidxp` command is not found, run `uv tool update-shell` once and reopen the terminal. @@ -166,6 +171,12 @@ scenes, ask questions about a library, and create clips or actor overlays. Local agents can connect over stdio; remote agents can connect to a self-hosted VidXP server. +Agents can call `get_workspace` before acting to inspect registered media, +active-index coverage, model readiness, and the searchable, queryable, +inspectable, or renderable roles available for each video. Invalid capability +or media selections are rejected before a durable job is queued and include an +actionable next step. + - [Python, HTTP, and MCP installation](INSTALLATION_GUIDE.md) - [Optional capability packages](INSTALLATION_GUIDE.md#optional-dependency-extras) - [Coolify server setup](docs/deployment/coolify.md) @@ -228,6 +239,13 @@ Contributions are welcome. Read the ## Credits Built by Grayhat Developers PVT Ltd. and maintained by the community. +Originally researched by students: +- [Abdullah Mansoor](https://github.com/abdullahmansoor321) +- [Muhammad Haroon](https://github.com/haroon10725) +- [Sarah Jawaid](https://github.com/sarr266) +- [Talha Ahmed](https://github.com/talhaahmed1234) + +Working with [Dr Shahab Tahzeeb](https://scholar.google.com/citations?user=cryeRB0AAAAJ&hl=en) ([NED University of Engineering and Technology](https://www.neduet.edu.pk/)) and [Saad Bazaz](https://scholar.google.com/citations?user=mrJo09oAAAAJ&hl=en) ([Grayhat](https://grayhat.studio). Email: info@grayhat.studio diff --git a/desktop/VERSION b/desktop/VERSION index 0d91a54..8d1a53c 100644 --- a/desktop/VERSION +++ b/desktop/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0-b diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 962ca19..b039307 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "vidxp-desktop", - "version": "0.3.0", + "version": "0.4.0-b", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vidxp-desktop", - "version": "0.3.0", + "version": "0.4.0-b", "devDependencies": { "@tauri-apps/cli": "2.11.4" } diff --git a/desktop/package.json b/desktop/package.json index ec701f5..d300dab 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "vidxp-desktop", "private": true, - "version": "0.3.0", + "version": "0.4.0-b", "type": "module", "scripts": { "tauri": "tauri", diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json index 0478be3..9951b4c 100644 --- a/desktop/runtime-manifest.json +++ b/desktop/runtime-manifest.json @@ -1,8 +1,8 @@ { "schema_version": 1, - "desktop_version": "0.3.0", + "desktop_version": "0.4.0-b", "package_name": "vidxp", - "package_version": "0.3.0", + "package_version": "0.4.0-b", "dependency_index": "https://pypi.org/simple", "dependency_constraints_sha256": "d3ed16906841952017f112903356bfe433a8515a37aa3f56e4af4dfa1f985835", "python_version": "3.14.6", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 1ca8142..06cf314 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4373,7 +4373,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vidxp-desktop" -version = "0.3.0" # x-release-please-version +version = "0.4.0-b" # x-release-please-version dependencies = [ "atomic-write-file", "hex", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d6f17a7..0280c49 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vidxp-desktop" -version = "0.3.0" # x-release-please-version +version = "0.4.0-b" # x-release-please-version description = "The VidXP desktop launcher and local runtime supervisor" edition = "2024" rust-version = "1.97" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index c270063..663588c 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VidXP", - "version": "0.3.0", + "version": "0.4.0-b", "identifier": "dev.grayhat.vidxp", "build": { "frontendDist": "../web" diff --git a/pyproject.toml b/pyproject.toml index e2e1f3d..c99385a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vidxp" -version = "0.3.0" +version = "0.4.0-b" authors = [ { name = "Muhammad Haroon" }, { name = "Talha Momin" }, diff --git a/release-please-config.json b/release-please-config.json index fc92393..e74a9f2 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,7 +5,6 @@ "bump-patch-for-minor-pre-major": false, "draft": true, "force-tag-creation": true, - "group-pull-request-title-pattern": "chore(release): prepare ${branch}", "include-v-in-tag": true, "prerelease": true, "separate-pull-requests": false, @@ -15,14 +14,13 @@ "type": "linked-versions", "groupName": "VidXP", "components": [ - "vidxp", + "", "desktop" ] } ], "packages": { ".": { - "component": "vidxp", "exclude-paths": [ "desktop" ], diff --git a/release-please-config.stable.json b/release-please-config.stable.json index dfb9d38..ae6f200 100644 --- a/release-please-config.stable.json +++ b/release-please-config.stable.json @@ -5,7 +5,6 @@ "bump-patch-for-minor-pre-major": false, "draft": true, "force-tag-creation": true, - "group-pull-request-title-pattern": "chore(release): prepare ${branch}", "include-v-in-tag": true, "separate-pull-requests": false, "plugins": [ @@ -13,14 +12,13 @@ "type": "linked-versions", "groupName": "VidXP", "components": [ - "vidxp", + "", "desktop" ] } ], "packages": { ".": { - "component": "vidxp", "exclude-paths": [ "desktop" ], diff --git a/src/vidxp/api.py b/src/vidxp/api.py index 8431d5c..7f4d86d 100644 --- a/src/vidxp/api.py +++ b/src/vidxp/api.py @@ -91,6 +91,14 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: def health() -> HealthResponse: return HealthResponse() + @app.get( + "/favicon.ico", + include_in_schema=False, + status_code=204, + ) + def favicon() -> Response: + return Response(status_code=204) + @app.get( "/ready", response_model=ReadinessResponse, diff --git a/src/vidxp/api_cli.py b/src/vidxp/api_cli.py index 4b49c32..e482041 100644 --- a/src/vidxp/api_cli.py +++ b/src/vidxp/api_cli.py @@ -4,6 +4,21 @@ from collections.abc import Sequence from pathlib import Path +from vidxp.network_share import ( + load_or_create_api_share_token, + primary_lan_address, +) + + +def _port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("port must be an integer") from exc + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("port must be between 1 and 65535") + return port + def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -17,9 +32,65 @@ def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: type=Path, help="Store VidXP models and the default repository here.", ) + parser.add_argument( + "--port", + type=_port, + metavar="PORT", + help="Listen on this port instead of the VidXP default.", + ) + parser.add_argument( + "--share", + action="store_true", + help=( + "Share the API and MCP on this machine's LAN address using an " + "app-managed bearer token." + ), + ) return parser.parse_args(values) +def _shared_settings(settings, *, host: str, token: str): + from vidxp.settings import HttpAuthMode, VidXPSettings + + if settings.http_auth_mode not in {HttpAuthMode.none, HttpAuthMode.static}: + raise ValueError( + "--share supports the app-managed static bearer mode, not OIDC." + ) + active_token = ( + settings.http_static_bearer_token.get_secret_value() + if settings.http_auth_mode == HttpAuthMode.static + and settings.http_static_bearer_token is not None + else token + ) + payload = settings.model_dump(mode="python") + payload.update( + { + "http_bind_host": host, + "http_auth_mode": HttpAuthMode.static, + "http_static_bearer_token": active_token, + "http_trusted_hosts": tuple( + dict.fromkeys((*settings.http_trusted_hosts, host)) + ), + "mcp_allowed_hosts": tuple( + dict.fromkeys((*settings.mcp_allowed_hosts, f"{host}:*")) + ), + } + ) + return VidXPSettings.model_validate(payload), active_token + + +def _print_share_details(settings, token: str) -> None: + origin = f"http://{settings.http_bind_host}:{settings.http_port}" + print("VidXP network sharing is enabled.", flush=True) + print(f"Health: {origin}/health", flush=True) + print(f"MCP: {origin}/mcp", flush=True) + print(f"Bearer token: {token}", flush=True) + print( + "Keep this token private. LAN traffic uses HTTP and is not encrypted.", + flush=True, + ) + + def main(arguments: Sequence[str] | None = None) -> None: options = _arguments(arguments) @@ -33,7 +104,30 @@ def main(arguments: Sequence[str] | None = None) -> None: if options.data_dir is not None else VidXPSettings() ) + if options.port is not None: + payload = settings.model_dump(mode="python") + payload["http_port"] = options.port + settings = VidXPSettings.model_validate(payload) + share_token = None + if options.share: + if settings.http_auth_mode.value not in {"none", "static"}: + raise ValueError( + "--share supports the app-managed static bearer mode, not OIDC." + ) + configured_token = ( + settings.http_static_bearer_token.get_secret_value() + if settings.http_auth_mode.value == "static" + and settings.http_static_bearer_token is not None + else None + ) + settings, share_token = _shared_settings( + settings, + host=primary_lan_address(), + token=configured_token or load_or_create_api_share_token(), + ) settings.validate_http_server() + if share_token is not None: + _print_share_details(settings, share_token) uvicorn.run( create_app(settings), host=settings.http_bind_host, diff --git a/src/vidxp/api_middleware.py b/src/vidxp/api_middleware.py index c13ae4d..9ae5175 100644 --- a/src/vidxp/api_middleware.py +++ b/src/vidxp/api_middleware.py @@ -16,7 +16,7 @@ from vidxp.authentication import Authenticator -PUBLIC_HTTP_PATHS = frozenset({"/health", "/ready"}) +PUBLIC_HTTP_PATHS = frozenset({"/favicon.ico", "/health", "/ready"}) UPLOAD_PATH = "/api/v1/media" diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py index 72e85c6..22cc186 100644 --- a/src/vidxp/api_routes/jobs.py +++ b/src/vidxp/api_routes/jobs.py @@ -49,7 +49,6 @@ def submit_index( actor: Annotated[Principal, Depends(write_principal)], idempotency_key: HttpIdempotencyKey, ) -> Job: - service.application.require_models(command.modalities) return accepted( response, service.jobs.submit_index( diff --git a/src/vidxp/api_routes/platform.py b/src/vidxp/api_routes/platform.py index 59fc8fa..1e3be99 100644 --- a/src/vidxp/api_routes/platform.py +++ b/src/vidxp/api_routes/platform.py @@ -1,12 +1,14 @@ from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from vidxp.api_routes.dependencies import context, read_principal from vidxp.application_models import ( CapabilityInfo, CapabilityList, + ListMediaCommand, RuntimeReadiness, + WorkspaceOverview, ) from vidxp.composition import HttpApplicationContext @@ -17,6 +19,25 @@ ) +@router.get( + "/workspace", + response_model=WorkspaceOverview, + operation_id="getWorkspace", + summary="Inspect media and usable capability roles", +) +def workspace( + service: Annotated[HttpApplicationContext, Depends(context)], + page_size: Annotated[int, Query(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Query(min_length=1, max_length=512), + ] = None, +) -> WorkspaceOverview: + return service.application.workspace( + ListMediaCommand(page_size=page_size, cursor=cursor) + ) + + @router.get( "/runtime/readiness", response_model=RuntimeReadiness, diff --git a/src/vidxp/application.py b/src/vidxp/application.py index fcbd1cf..56bbb1e 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -27,6 +27,7 @@ ImportMediaCommand, IndexSnapshotReference, MediaAsset, + ModelDownloadError, ModelUnavailableError, PrepareModelsCommand, PrepareModelsResult, @@ -55,11 +56,15 @@ from vidxp.core.contracts import ( IndexConfig, ) +from vidxp.core.snapshots import IndexSnapshot from vidxp.execution import ExecutionContext, execution_context from vidxp.ports import IndexBackend, ModelRuntimePort, QueryModelPort from vidxp.query_service import GroundedQueryService from vidxp.search_fusion import fuse_search_results -from vidxp.model_contracts import ModelArtifactUnavailableError +from vidxp.model_contracts import ( + ModelArtifactDownloadError, + ModelArtifactUnavailableError, +) from vidxp.repository_layout import RepositoryLayout from vidxp.settings import VidXPSettings from vidxp.control_plane import ControlPlaneApplication @@ -85,6 +90,7 @@ def __init__( media: MediaService, artifacts: ArtifactService, index_status: Callable[[], dict[str, Any] | None], + active_snapshot: Callable[[], IndexSnapshot | None] | None = None, completed_upload_importer: Callable[[str], MediaAsset] | None = None, query_model: QueryModelPort | None = None, ) -> None: @@ -100,6 +106,7 @@ def __init__( artifacts=artifacts, index_status=index_status, model_cache=settings.model_cache, + active_snapshot=active_snapshot, ) self.settings = settings @@ -119,6 +126,15 @@ def _capability_dependencies( capabilities, self.registry.install_hint(capabilities), ) from exc + except ModelArtifactDownloadError as exc: + raise ModelDownloadError( + exc.capability, + exc.model_id, + attempts=exc.attempts, + reason=exc.reason, + resumable=exc.resumable, + retryable=exc.retryable, + ) from exc except ModelArtifactUnavailableError as exc: raise ModelUnavailableError(exc.capability) from exc except (ModuleNotFoundError, CapabilityDependencyError) as exc: diff --git a/src/vidxp/application_boundary.py b/src/vidxp/application_boundary.py index 33caa4a..51c0ea1 100644 --- a/src/vidxp/application_boundary.py +++ b/src/vidxp/application_boundary.py @@ -43,6 +43,8 @@ MediaIdempotencyConflictError, MediaImportNotAllowedError, ) + + def _validation_details( exc: ValidationError, ) -> list[dict[str, JsonValue]]: @@ -113,7 +115,13 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: errors=_validation_details(exc), ) from exc except CapabilityRequestError as exc: - raise InvalidRequestError() from exc + error = { + "type": "capability_request", + "location": [exc.field], + "message": str(exc), + **exc.details(), + } + raise InvalidRequestError(errors=[error]) from exc except InvalidMediaError as exc: raise ApplicationError( "media_invalid", diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py index 9975e3a..be6ab3e 100644 --- a/src/vidxp/application_models.py +++ b/src/vidxp/application_models.py @@ -199,6 +199,43 @@ def __init__(self, capability: str) -> None: ) +class ModelDownloadError(ApplicationError): + def __init__( + self, + capability: str, + model_id: str, + *, + attempts: int, + reason: str, + resumable: bool, + retryable: bool, + ) -> None: + modality = capability.split(".", 1)[0] + remediation = f"vidxp prepare --modalities {modality}" + retry_message = ( + "Partial files were kept and the next preparation attempt will " + "resume them." + if resumable + else "The next preparation attempt will restart this file." + ) + super().__init__( + "model_download_failed", + ErrorCategory.unavailable, + f"Downloading {model_id} failed after {attempts} attempt(s) " + f"({reason}). {retry_message} Run " + f"`{remediation}` again when the connection is available.", + details={ + "capability": capability, + "model": model_id, + "attempts": attempts, + "reason": reason, + "partial_files_preserved": resumable, + "remediation": remediation, + }, + retryable=retryable, + ) + + class InvalidRequestError(ApplicationError): def __init__( self, @@ -254,12 +291,29 @@ class CapabilityOperationInfo(ApplicationModel): output_schema: dict[str, JsonValue] +class CapabilityRole(StrEnum): + searchable = "searchable" + queryable = "queryable" + inspectable = "inspectable" + renderable = "renderable" + + +class CapabilityIdentityMode(StrEnum): + not_applicable = "not_applicable" + anonymous_clusters = "anonymous_clusters" + registered_entities = "registered_entities" + + class CapabilitySummary(ApplicationModel): name: str = Field(min_length=1) description: str = Field(min_length=1) install_extra: str = Field(min_length=1) supports_indexing: bool prepares_models: bool + roles: tuple[CapabilityRole, ...] = () + identity_mode: CapabilityIdentityMode = ( + CapabilityIdentityMode.not_applicable + ) provenance: CapabilityProvenance | None = None @@ -551,6 +605,47 @@ def _validate_media_id_window(self) -> "IndexStatusSummary": return self +class WorkspaceCapability(CapabilitySummary): + models_ready: bool | None = Field( + default=None, + description=( + "Whether required model artifacts are prepared. Null means the " + "capability does not prepare model artifacts." + ), + ) + + +class WorkspaceMediaCapability(ApplicationModel): + name: Identifier + indexed: bool + record_count: NonNegativeInt | None = None + roles: tuple[CapabilityRole, ...] = Field( + default=(), + description="Capability roles currently usable for this media item.", + ) + identity_mode: CapabilityIdentityMode = ( + CapabilityIdentityMode.not_applicable + ) + + +class WorkspaceMedia(ApplicationModel): + media_id: MediaId + original_filename: str = Field(min_length=1) + duration_seconds: float = Field(gt=0) + state: MediaState + in_active_snapshot: bool + capabilities: tuple[WorkspaceMediaCapability, ...] = () + + +class WorkspaceOverview(ApplicationModel): + capabilities: tuple[WorkspaceCapability, ...] = () + media: tuple[WorkspaceMedia, ...] = () + media_total: NonNegativeInt + next_cursor: str | None = None + index: IndexStatus + next_actions: tuple[str, ...] = () + + class FusionProfile(StrEnum): reciprocal_rank = "rrf_v1" diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py index f8e1e1c..c9ba378 100644 --- a/src/vidxp/capabilities/actor/definition.py +++ b/src/vidxp/capabilities/actor/definition.py @@ -2,6 +2,7 @@ from typing import Any, Mapping +from vidxp.application_models import CapabilityIdentityMode, CapabilityRole from vidxp.capabilities.actor.config import ActorConfig, actor_config from vidxp.capabilities.actor.indexing import VISUAL_PROCESSOR from vidxp.capabilities.actor.models import ( @@ -77,6 +78,12 @@ def model_manifest( index_stage="visual_indexing", execution_group="visual", prepares_models=True, + roles=( + CapabilityRole.queryable, + CapabilityRole.inspectable, + CapabilityRole.renderable, + ), + identity_mode=CapabilityIdentityMode.anonymous_clusters, model_specs=(YUNET_MODEL, SFACE_MODEL), operations={ "cluster": OperationDefinition( diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py index 9448077..ddb9ef4 100644 --- a/src/vidxp/capabilities/contracts.py +++ b/src/vidxp/capabilities/contracts.py @@ -18,7 +18,11 @@ from vidxp.core.indexing_common import ProgressCallback from vidxp.model_contracts import ArtifactSpec, ModelSpec from vidxp.ports import IndexReader, ModelRuntimePort -from vidxp.application_models import CapabilityProvenance +from vidxp.application_models import ( + CapabilityIdentityMode, + CapabilityProvenance, + CapabilityRole, +) CAPABILITY_CONTRACT_VERSION = 1 @@ -224,6 +228,10 @@ class CapabilityDefinition(_ContractModel): index_stage: str | None = None execution_group: str | None = None operations: Mapping[str, OperationDefinition] = Field(default_factory=dict) + roles: tuple[CapabilityRole, ...] = () + identity_mode: CapabilityIdentityMode = ( + CapabilityIdentityMode.not_applicable + ) model_specs: tuple[ModelSpec | ArtifactSpec, ...] = () prepares_models: bool = False @@ -250,6 +258,16 @@ def _freeze_operations( ) -> Mapping[str, OperationDefinition]: return MappingProxyType(dict(value)) + @field_validator("roles") + @classmethod + def _unique_roles( + cls, + value: tuple[CapabilityRole, ...], + ) -> tuple[CapabilityRole, ...]: + if len(value) != len(set(value)): + raise ValueError("Capability roles must be unique.") + return value + @model_validator(mode="after") def _require_complete_metadata(self) -> CapabilityDefinition: indexing_fields = ( @@ -341,6 +359,37 @@ def capability_install_hint(name: str) -> str: class CapabilityRequestError(ValueError): """Expected invalid capability selection or options.""" + def __init__( + self, + message: str, + *, + field: str = "capabilities", + reason: str = "capability_request_invalid", + requested: tuple[str, ...] = (), + available: tuple[str, ...] = (), + indexed: tuple[str, ...] = (), + next_action: str | None = None, + ) -> None: + self.field = field + self.reason = reason + self.requested = requested + self.available = available + self.indexed = indexed + self.next_action = next_action + super().__init__(message) + + def details(self) -> dict[str, Any]: + result: dict[str, Any] = {"reason": self.reason} + if self.requested: + result["requested"] = list(self.requested) + if self.available: + result["available"] = list(self.available) + if self.indexed: + result["indexed"] = list(self.indexed) + if self.next_action is not None: + result["next_action"] = self.next_action + return result + class CapabilityDependencyError(RuntimeError): def __init__( diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py index 2d6b1c7..950bd8c 100644 --- a/src/vidxp/capabilities/dialogue/definition.py +++ b/src/vidxp/capabilities/dialogue/definition.py @@ -5,6 +5,7 @@ from packaging.requirements import Requirement from packaging.utils import canonicalize_name +from vidxp.application_models import CapabilityRole from vidxp.capabilities.contracts import ( CapabilityDefinition, CapabilityExecutor, @@ -96,6 +97,7 @@ def model_manifest( index_stage="dialogue_indexing", execution_group="dialogue", prepares_models=True, + roles=(CapabilityRole.searchable, CapabilityRole.queryable), model_specs=(QWEN3_EMBEDDING_MODEL, FASTER_WHISPER_MODEL), operations={ "search": OperationDefinition( diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index b18d9e9..5e82f82 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -187,7 +187,12 @@ def get(self, name: str) -> CapabilityDefinition: available = ", ".join(self.names()) raise CapabilityRequestError( f"Unknown capability {name!r}. " - f"Available capabilities: {available}." + f"Available capabilities: {available}.", + field="modalities", + reason="capability_unknown", + requested=(name,), + available=self.names(), + next_action="Choose a capability returned by get_workspace.", ) from exc def executor(self, name: str) -> CapabilityExecutor: @@ -222,7 +227,13 @@ def executor(self, name: str) -> CapabilityExecutor: def validate_names(self, names: Iterable[str]) -> tuple[str, ...]: selected = tuple(dict.fromkeys(str(name).strip() for name in names)) if not selected: - raise CapabilityRequestError("At least one capability is required.") + raise CapabilityRequestError( + "At least one capability is required.", + field="modalities", + reason="capability_required", + available=self.names(), + next_action="Choose a capability returned by get_workspace.", + ) for name in selected: self.get(name) return selected @@ -249,7 +260,15 @@ def validate_options( if unknown: raise CapabilityRequestError( "Options were supplied for disabled capabilities: " - + ", ".join(unknown) + + ", ".join(unknown), + field="capability_options", + reason="capability_options_disabled", + requested=tuple(unknown), + available=selected, + next_action=( + "Remove options for disabled capabilities or enable those " + "capabilities in modalities." + ), ) return { name: self.get(name) diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py index aee444c..366ad4e 100644 --- a/src/vidxp/capabilities/scene/definition.py +++ b/src/vidxp/capabilities/scene/definition.py @@ -2,6 +2,7 @@ from typing import Any, Mapping +from vidxp.application_models import CapabilityRole from vidxp.capabilities.contracts import ( CapabilityDefinition, CapabilityExecutor, @@ -59,6 +60,7 @@ def model_manifest( index_stage="visual_indexing", execution_group="visual", prepares_models=True, + roles=(CapabilityRole.searchable, CapabilityRole.queryable), model_specs=(SIGLIP2_MODEL,), operations={ "search": OperationDefinition( diff --git a/src/vidxp/capability_service.py b/src/vidxp/capability_service.py index 9b3844b..7b0c64c 100644 --- a/src/vidxp/capability_service.py +++ b/src/vidxp/capability_service.py @@ -28,6 +28,8 @@ def _summary(self, name: str) -> CapabilitySummary: install_extra=definition.extra, supports_indexing=definition.collection_name is not None, prepares_models=definition.prepares_models, + roles=definition.roles, + identity_mode=definition.identity_mode, provenance=self.registry.provenance(name), ) diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index 8d0036a..14eb192 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -40,7 +40,6 @@ def create_index( not state.quiet and state.output_format == OutputFormat.rich ) selected = tuple(modalities) - state.service.require_models(selected) with IndexProgress(show_progress) as progress: job = state.jobs.submit_index( CreateIndexCommand( diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index f5992bc..49503b4 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -33,6 +33,7 @@ media_runtime_config_path, save_media_runtime_configuration, ) +from vidxp.network_share import is_loopback_host def _format_bytes(size: int) -> str: @@ -471,9 +472,8 @@ def prepare( typer.confirm("Download these models?", abort=True) show_progress = not state.quiet and output_format == OutputFormat.rich if show_progress: - emit_progress( - "Starting model preparation for " + ", ".join(selected) + "." - ) + action = "Downloading and validating" if missing else "Validating cached" + emit_progress(f"{action} models for " + ", ".join(selected) + ".") job = state.jobs.submit_prepare_models( PrepareModelsCommand( modalities=selected, @@ -514,6 +514,16 @@ def ui( help="Streamlit server port.", ), ] = None, + share: Annotated[ + bool, + typer.Option( + "--share", + help=( + "Share the unauthenticated browser interface on this " + "machine's LAN address." + ), + ), + ] = False, ) -> None: """Launch Streamlit with the selected repository configuration.""" @@ -538,11 +548,23 @@ def ui( ) from exc raise - streamlit_arguments = [] - if host is not None: - streamlit_arguments.append(f"--server.address={host}") + if share and host is not None: + raise typer.BadParameter("--share cannot be combined with --host.") + active_host = "0.0.0.0" if share else (host or "127.0.0.1") + streamlit_arguments = [ + "--server.showEmailPrompt=false", + "--browser.gatherUsageStats=false", + f"--server.address={active_host}", + ] if port is not None: streamlit_arguments.append(f"--server.port={port}") + if share or not is_loopback_host(active_host): + typer.secho( + "WARNING: The browser interface has no authentication and is " + "reachable from the network.", + fg=typer.colors.YELLOW, + err=True, + ) if show_progress: emit_progress("Starting the browser interface...") try: diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py index f0c514b..856c2af 100644 --- a/src/vidxp/composition.py +++ b/src/vidxp/composition.py @@ -3,11 +3,16 @@ from dataclasses import dataclass from functools import cached_property from pathlib import Path +from typing import Callable from pydantic import ValidationError from vidxp.application import VidXPApplication -from vidxp.application_models import ApplicationError, ErrorCategory +from vidxp.application_models import ( + ApplicationError, + CreateIndexCommand, + ErrorCategory, +) from vidxp.artifact_service import ArtifactQueryService, ArtifactService from vidxp.authentication import Authenticator, create_authenticator from vidxp.authorization import AuthorizationPolicy @@ -94,7 +99,10 @@ def settings(self) -> VidXPSettings: @cached_property def jobs(self) -> JobService: assert self._settings is not None - return create_job_service(self._settings) + return create_job_service( + self._settings, + index_preflight=self.application.preflight_index, + ) def close(self) -> None: jobs = self.__dict__.get("jobs") @@ -159,12 +167,9 @@ def _server_chroma_url(settings: VidXPSettings) -> str | None: return BUNDLED_CHROMA_SERVER_URL -def _create_control_plane_components( - settings: VidXPSettings, -) -> _ControlPlaneComponents: - settings.layout.ensure_local_directories() +def _capability_registry(settings: VidXPSettings) -> CapabilityRegistry: server_mode = settings.mode == ApplicationMode.server - registry = create_capability_registry( + return create_capability_registry( external=settings.external_capabilities, allowlist=settings.capability_allowlist, platform_runtime_checks=( @@ -183,6 +188,13 @@ def _create_control_plane_components( else None ), ) + + +def _create_control_plane_components( + settings: VidXPSettings, +) -> _ControlPlaneComponents: + settings.layout.ensure_local_directories() + registry = _capability_registry(settings) catalog = ( SQLCatalog( workflow_database_url(settings), @@ -305,6 +317,7 @@ def create_application( media=components.media, artifacts=artifacts, index_status=backend.repository.status, + active_snapshot=components.snapshots.read_active, completed_upload_importer=( upload_service.import_completed if upload_service is not None @@ -319,6 +332,8 @@ def create_job_service( *, catalog: SQLCatalog | None = None, snapshots: LocalSnapshotRepository | None = None, + registry: CapabilityRegistry | None = None, + index_preflight: Callable[[CreateIndexCommand], None] | None = None, include_read_planner: bool = True, ) -> JobService: settings.layout.ensure_local_directories() @@ -332,6 +347,7 @@ def create_job_service( stop_executor = supervisor.stop return JobService( settings=settings, + index_preflight=index_preflight, backend=DBOSJobBackend( system_database_url=( None @@ -353,6 +369,7 @@ def create_job_service( read_planner=( LocalReadJobPlanner( layout=settings.layout, + registry=registry or _capability_registry(settings), index=LocalIndexReader( settings.layout, chroma_server_url=_server_chroma_url(settings), @@ -380,11 +397,14 @@ def create_control_plane_application( ), index_status=components.snapshots.status, model_cache=active_settings.model_cache, + active_snapshot=components.snapshots.read_active, ) jobs = create_job_service( active_settings, catalog=components.catalog, snapshots=components.snapshots, + registry=components.registry, + index_preflight=application.preflight_index, ) uploads = ( RemoteUploadService( diff --git a/src/vidxp/control_plane.py b/src/vidxp/control_plane.py index 991f48c..1897080 100644 --- a/src/vidxp/control_plane.py +++ b/src/vidxp/control_plane.py @@ -7,8 +7,10 @@ from vidxp.application_models import ( Artifact, CapabilityInfo, + CapabilityRole, CapabilitySummary, ComponentReadiness, + CreateIndexCommand, DependencyCheckResult, IndexStatus, InvalidRequestError, @@ -18,11 +20,16 @@ ModelUnavailableError, ResourceNotFoundError, RuntimeReadiness, + WorkspaceCapability, + WorkspaceMedia, + WorkspaceMediaCapability, + WorkspaceOverview, ) from vidxp.artifact_service import ArtifactQueryService from vidxp.capabilities.contracts import CapabilityRequestError from vidxp.capability_service import CapabilityService from vidxp.core.media import QuarantinedMedia +from vidxp.core.snapshots import IndexSnapshot from vidxp.index_state import INDEX_STATUS_SCHEMA from vidxp.media_service import MediaService from vidxp.ports import LocalFileResource @@ -41,12 +48,14 @@ def __init__( artifacts: ArtifactQueryService, index_status: Callable[[], dict | None], model_cache: Path, + active_snapshot: Callable[[], IndexSnapshot | None] | None = None, ) -> None: self.layout = layout self.capabilities = capabilities self.media = media self.artifacts = artifacts self._read_index_status = index_status + self._read_active_snapshot = active_snapshot or (lambda: None) self.model_cache = model_cache @application_boundary @@ -105,6 +114,139 @@ def list_media(self, command: ListMediaCommand) -> MediaPage: except ValueError as exc: raise InvalidRequestError() from exc + @application_boundary + def workspace(self, command: ListMediaCommand) -> WorkspaceOverview: + page = self.list_media(command) + index = self.index_status() + snapshot = self._read_active_snapshot() + capabilities = self.list_capabilities() + readiness = self.model_readiness() + readiness_by_capability = { + capability.name: all( + check.ok + for check in readiness.checks + if check.capability == capability.name + ) + for capability in capabilities + if capability.prepares_models + } + projected_capabilities = tuple( + WorkspaceCapability( + **capability.model_dump(), + models_ready=readiness_by_capability.get(capability.name), + ) + for capability in capabilities + ) + media = tuple( + self._workspace_media( + asset, + capabilities=capabilities, + snapshot=snapshot, + ) + for asset in page.items + ) + indexed_media = ( + frozenset(snapshot.generations) if snapshot is not None else frozenset() + ) + indexed_capabilities = ( + { + name + for generation in snapshot.generations.values() + for name in generation.modalities + } + if snapshot is not None + else set() + ) + active_roles = { + role + for capability in capabilities + if capability.name in indexed_capabilities + for role in capability.roles + } + next_actions = [] + if page.total == 0: + next_actions.append("register_media") + if page.total > len(indexed_media) or any( + item.media_id not in indexed_media for item in page.items + ): + next_actions.append("index_media") + if CapabilityRole.searchable in active_roles: + next_actions.append("find_moments") + if CapabilityRole.queryable in active_roles: + next_actions.append("answer_video") + return WorkspaceOverview( + capabilities=projected_capabilities, + media=media, + media_total=page.total, + next_cursor=page.next_cursor, + index=index, + next_actions=tuple(next_actions), + ) + + @staticmethod + def _workspace_media( + asset: MediaAsset, + *, + capabilities: tuple[CapabilitySummary, ...], + snapshot: IndexSnapshot | None, + ) -> WorkspaceMedia: + generation = ( + snapshot.generations.get(asset.media_id) if snapshot is not None else None + ) + coverage = tuple( + WorkspaceMediaCapability( + name=capability.name, + indexed=( + generation is not None and capability.name in generation.modalities + ), + record_count=( + generation.record_counts.get(capability.name) + if generation is not None + and capability.name in generation.modalities + else None + ), + roles=( + capability.roles + if generation is not None + and capability.name in generation.modalities + else () + ), + identity_mode=capability.identity_mode, + ) + for capability in capabilities + ) + return WorkspaceMedia( + media_id=asset.media_id, + original_filename=asset.original_filename, + duration_seconds=asset.duration_seconds, + state=asset.state, + in_active_snapshot=generation is not None, + capabilities=coverage, + ) + + @application_boundary + def preflight_index(self, command: CreateIndexCommand) -> None: + selected = self.capabilities.registry.validate_names(command.modalities) + indexable = self.capabilities.registry.index_names() + unsupported = tuple(name for name in selected if name not in indexable) + if unsupported: + raise CapabilityRequestError( + "Indexing does not support these capabilities: " + + ", ".join(unsupported) + + ".", + field="modalities", + reason="capability_not_indexable", + requested=unsupported, + available=indexable, + next_action="Choose capabilities returned by get_workspace.", + ) + self.capabilities.registry.validate_options( + selected, + command.capability_options, + ) + self.get_media(command.media_id) + self.require_models(selected) + @application_boundary def open_media_content(self, media_id: str) -> LocalFileResource: return self.media.content(media_id) @@ -186,10 +328,7 @@ def runtime_readiness(self) -> RuntimeReadiness: components = self.control_plane_readiness() models = self.model_readiness() return RuntimeReadiness( - ready=( - all(component.ready for component in components) - and models.ok - ), + ready=(all(component.ready for component in components) and models.ok), runtime=None, components=components, dependencies=models, diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index ce15d84..dffa81c 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -43,7 +43,11 @@ def _configured_service( @lru_cache(maxsize=1) def _configured_jobs(settings: VidXPSettings | None = None) -> JobService: - return create_job_service(settings or _settings_from_arguments()) + active_settings = settings or _settings_from_arguments() + return create_job_service( + active_settings, + index_preflight=_configured_service(active_settings).preflight_index, + ) def _settings_from_arguments( @@ -340,7 +344,6 @@ def _run_indexing( media_id = media_ids[0] if len(media_ids) == 1 else None if media_id is None: raise ValueError("Select or import media before indexing.") - service.require_models(modalities) job = _configured_jobs().submit_index( CreateIndexCommand( media_id=media_id, diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py index e333d3e..982e419 100644 --- a/src/vidxp/infrastructure/local_index.py +++ b/src/vidxp/infrastructure/local_index.py @@ -20,6 +20,7 @@ from vidxp.core.indexing_common import ProgressCallback from vidxp.core.manifest import MANIFEST_FILE, ManifestStore from vidxp.core.runner import index_video +from vidxp.core.snapshots import IndexSnapshot from vidxp.core.storage import ( ChromaClientFactory, IndexStorage, @@ -89,6 +90,15 @@ def active_config( config, _snapshot = self.repository.active_config(device=device) return config + def active_snapshot( + self, + index_directory: Path, + *, + device: str, + ) -> tuple[IndexConfig, IndexSnapshot]: + self._require_index_directory(index_directory) + return self.repository.active_config(device=device) + def config_for_snapshot( self, index_directory: Path, diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py index dcad2df..0fac51f 100644 --- a/src/vidxp/job_service.py +++ b/src/vidxp/job_service.py @@ -89,10 +89,12 @@ def __init__( settings: VidXPSettings, backend: JobBackend, read_planner: ReadJobPlanner | None = None, + index_preflight: Callable[[CreateIndexCommand], None] | None = None, ) -> None: self.settings = settings self.backend = backend self.read_planner = read_planner + self.index_preflight = index_preflight def _read_job_planner(self) -> ReadJobPlanner: if self.read_planner is None: @@ -114,6 +116,8 @@ def submit_index( *, job_id: str | None = None, ) -> Job: + if self.index_preflight is not None: + self.index_preflight(command) return self.backend.submit( IndexJobRequest(command=command), queue=self._model_queue(), diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index e9a297f..89f9ae4 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -44,6 +44,7 @@ QueryVideoCommand, RuntimeReadiness, SearchCommand, + WorkspaceOverview, ) from vidxp.authentication import ( OIDCBearerAuthenticator, @@ -345,9 +346,11 @@ def create_mcp_server( ) ], instructions=( - "Call get_runtime_readiness before indexing. If selected model " + "Call get_workspace before planning index, search, query, or actor " + "work; it reports valid capability roles for each media item. Call " + "get_runtime_readiness before indexing. If selected model " "artifacts are missing, submit prepare_models and poll get_job " - "until it completes. Discover registered media with list_media. " + "until it completes. " "Register and upload new video through the HTTP/tus API, then use " "its media_id with start_indexing. get_index_status identifies the " "media included in the active index snapshot. For search_moments " @@ -418,6 +421,32 @@ async def read_matroska_artifact(artifact_id: ArtifactId) -> bytes: expected_mime_type="video/x-matroska", ) + @server.tool( + description=( + "Inspect registered media, the active index, installed capabilities, " + "and the searchable, queryable, inspectable, or renderable roles " + "currently usable for each media item. Call this before planning " + "index, search, query, or actor work." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_workspace( + page_size: Annotated[int, Field(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Field(min_length=1, max_length=512), + ] = None, + ) -> WorkspaceOverview: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.workspace( + ListMediaCommand(page_size=page_size, cursor=cursor) + ), + ) + @server.tool( description="List installed VidXP capabilities.", annotations=_READ_ONLY, @@ -532,7 +561,6 @@ async def start_indexing( idempotency_key: IdempotencyKey, ) -> Job: def submit(actor: Principal) -> Job: - context.application.require_models(command.modalities) return context.jobs.submit_index( command, job_id=scoped_job_id( diff --git a/src/vidxp/model_contracts.py b/src/vidxp/model_contracts.py index 6b6788e..b1ab521 100644 --- a/src/vidxp/model_contracts.py +++ b/src/vidxp/model_contracts.py @@ -24,6 +24,29 @@ def __init__(self, capability: str) -> None: super().__init__(f"Model artifacts for {capability} are unavailable.") +class ModelArtifactDownloadError(RuntimeError): + def __init__( + self, + capability: str, + model_id: str, + *, + attempts: int, + reason: str, + resumable: bool, + retryable: bool, + ) -> None: + self.capability = capability + self.model_id = model_id + self.attempts = attempts + self.reason = reason + self.resumable = resumable + self.retryable = retryable + super().__init__( + f"Downloading {model_id} failed after {attempts} attempt(s): " + f"{reason}." + ) + + @dataclass(frozen=True) class ModelKey: capability: str diff --git a/src/vidxp/network_share.py b/src/vidxp/network_share.py new file mode 100644 index 0000000..0cad6cc --- /dev/null +++ b/src/vidxp/network_share.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import ipaddress +import os +import socket +from pathlib import Path +from secrets import token_urlsafe + +from vidxp.app_paths import default_config_directory + + +API_SHARE_TOKEN_FILE = "api-share-token" + + +def _usable_ipv4(value: str) -> bool: + try: + address = ipaddress.ip_address(value) + except ValueError: + return False + return ( + address.version == 4 + and not address.is_loopback + and not address.is_unspecified + and not address.is_link_local + ) + + +def primary_lan_address() -> str: + """Resolve the IPv4 address selected by the host's default route.""" + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + # UDP connect selects a route without sending application data. + probe.connect(("192.0.2.1", 9)) + candidate = str(probe.getsockname()[0]) + if _usable_ipv4(candidate): + return candidate + except OSError: + pass + + try: + addresses = socket.getaddrinfo( + socket.gethostname(), + None, + family=socket.AF_INET, + type=socket.SOCK_STREAM, + ) + except OSError as exc: + raise RuntimeError( + "VidXP could not determine a LAN address for sharing." + ) from exc + for item in addresses: + candidate = str(item[4][0]) + if _usable_ipv4(candidate): + return candidate + raise RuntimeError("VidXP could not determine a LAN address for sharing.") + + +def api_share_token_path() -> Path: + return default_config_directory() / API_SHARE_TOKEN_FILE + + +def load_or_create_api_share_token(path: Path | None = None) -> str: + """Return the stable app-owned bearer token used by API share mode.""" + + target = path or api_share_token_path() + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + existing = target.read_text(encoding="utf-8").strip() + except FileNotFoundError: + existing = "" + if existing: + if len(existing) < 32: + raise RuntimeError(f"The managed VidXP API token is invalid: {target}") + return existing + + token = token_urlsafe(32) + try: + descriptor = os.open( + target, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + except FileExistsError: + existing = target.read_text(encoding="utf-8").strip() + if len(existing) < 32: + raise RuntimeError(f"The managed VidXP API token is invalid: {target}") + return existing + with os.fdopen(descriptor, "w", encoding="utf-8") as destination: + destination.write(token + "\n") + destination.flush() + os.fsync(destination.fileno()) + return token + + +def is_loopback_host(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False diff --git a/src/vidxp/read_job_planner.py b/src/vidxp/read_job_planner.py index 19079b8..6e70e95 100644 --- a/src/vidxp/read_job_planner.py +++ b/src/vidxp/read_job_planner.py @@ -9,8 +9,11 @@ QueryVideoCommand, SearchCommand, SearchJobRequest, + CapabilityRole, ) from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.capabilities.registry import CapabilityRegistry +from vidxp.core.snapshots import IndexSnapshot from vidxp.infrastructure.local_index import LocalIndexReader from vidxp.repository_layout import RepositoryLayout @@ -22,13 +25,15 @@ def __init__( self, *, layout: RepositoryLayout, + registry: CapabilityRegistry, index: LocalIndexReader | None = None, ) -> None: self.layout = layout + self.registry = registry self.index = index or LocalIndexReader(layout) def _active(self): - config = self.index.active_config( + config, snapshot = self.index.active_snapshot( self.layout.indexes, device="cpu", ) @@ -36,39 +41,138 @@ def _active(self): raise RuntimeError( "The active index did not provide an immutable reference." ) - return config, IndexSnapshotReference( - snapshot_id=config.snapshot_id, - snapshot_sha256=config.snapshot_sha256, + return ( + config, + IndexSnapshotReference( + snapshot_id=config.snapshot_id, + snapshot_sha256=config.snapshot_sha256, + ), + snapshot, ) - @staticmethod - def _require_capability(capability: str, config) -> None: - if capability not in config.enabled_modalities: + def _select_capabilities( + self, + requested: tuple[str, ...], + *, + indexed: tuple[str, ...], + role: CapabilityRole, + operation: str, + ) -> tuple[str, ...]: + explicit = bool(requested) + selected = self.registry.validate_names(requested) if explicit else indexed + absent = tuple(name for name in selected if name not in indexed) + if absent: + raise CapabilityRequestError( + f"{operation} capabilities are not present in the active index: " + + ", ".join(absent) + + ".", + field="modalities", + reason="capability_not_indexed", + requested=absent, + available=tuple( + name for name in indexed if role in self.registry.get(name).roles + ), + indexed=indexed, + next_action=( + "Choose an indexed capability returned by get_workspace or " + "index the media with the requested capability." + ), + ) + supported = tuple( + name for name in selected if role in self.registry.get(name).roles + ) + unsupported = tuple(name for name in selected if name not in supported) + if explicit and unsupported: + available = tuple( + name for name in indexed if role in self.registry.get(name).roles + ) raise CapabilityRequestError( - f"The {capability} capability is not present in this index." + f"{operation} does not support these indexed capabilities: " + + ", ".join(unsupported) + + ".", + field="modalities", + reason="capability_role_unsupported", + requested=unsupported, + available=available, + indexed=indexed, + next_action=( + f"Choose a {role.value} capability returned by get_workspace." + ), ) + if not supported: + raise CapabilityRequestError( + f"The active index has no {role.value} capabilities.", + field="modalities", + reason="capability_role_unavailable", + indexed=indexed, + next_action=( + f"Index media with a capability whose role is {role.value}." + ), + ) + return supported + + @staticmethod + def _require_media( + media_id: str | None, + snapshot: IndexSnapshot, + ) -> None: + if media_id is None or media_id in snapshot.generations: + return + raise CapabilityRequestError( + "The selected media item is not present in the active index snapshot.", + field="media_id", + reason="media_not_indexed", + requested=(media_id,), + available=tuple(sorted(snapshot.generations)[:100]), + next_action=( + "Choose indexed media returned by get_workspace or index this " + "media item first." + ), + ) @application_boundary def plan_search(self, command: SearchCommand) -> SearchJobRequest: - config, reference = self._active() - for capability in command.modalities: - self._require_capability(capability, config) - return SearchJobRequest(command=command, snapshot=reference) + config, reference, snapshot = self._active() + selected = self._select_capabilities( + command.modalities, + indexed=config.enabled_modalities, + role=CapabilityRole.searchable, + operation="Search", + ) + self._require_media(command.media_id, snapshot) + return SearchJobRequest( + command=command.model_copy(update={"modalities": selected}), + snapshot=reference, + ) @application_boundary def plan_query(self, command: QueryVideoCommand) -> QueryJobRequest: - config, reference = self._active() - for capability in command.modalities: - self._require_capability(capability, config) - return QueryJobRequest(command=command, snapshot=reference) + config, reference, snapshot = self._active() + selected = self._select_capabilities( + command.modalities, + indexed=config.enabled_modalities, + role=CapabilityRole.queryable, + operation="Query", + ) + self._require_media(command.media_id, snapshot) + return QueryJobRequest( + command=command.model_copy(update={"modalities": selected}), + snapshot=reference, + ) @application_boundary def plan_actor_overlay( self, command: CreateActorOverlayCommand, ) -> ActorOverlayJobRequest: - config, reference = self._active() - self._require_capability("actor", config) + config, reference, _snapshot = self._active() + selected = self._select_capabilities( + ("actor",), + indexed=config.enabled_modalities, + role=CapabilityRole.renderable, + operation="Actor overlay", + ) + assert selected == ("actor",) return ActorOverlayJobRequest( command=command, snapshot=reference, diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py index 4fe70d7..abda32c 100644 --- a/src/vidxp/runtime.py +++ b/src/vidxp/runtime.py @@ -6,13 +6,14 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout from contextlib import contextmanager from threading import BoundedSemaphore, Lock, RLock -from time import monotonic +from time import monotonic, sleep from typing import Any, Callable, Iterator from pathlib import Path from vidxp.application_models import RuntimeProfile from vidxp.model_contracts import ( ArtifactSpec, + ModelArtifactDownloadError, ModelArtifactUnavailableError, ModelKey, ModelSpec, @@ -24,6 +25,64 @@ class RuntimeBackendUnavailableError(RuntimeError): """Raised when an explicitly requested compute backend cannot be used.""" +class _ModelDownloadVerificationError(RuntimeError): + """Raised internally when a completed transfer is missing pinned weights.""" + + +_MODEL_DOWNLOAD_ATTEMPTS = 3 +_DOWNLOAD_HEARTBEAT_SECONDS = 5.0 +_MINIMUM_PROGRESS_BYTES = 1024 * 1024 + + +def _download_failure_reason(exc: Exception) -> str: + if isinstance(exc, _ModelDownloadVerificationError): + return "artifact verification failed" + response = getattr(exc, "response", None) + status = getattr(response, "status_code", None) + if isinstance(status, int): + return f"HTTP {status} {type(exc).__name__}" + return type(exc).__name__ + + +def _download_failure_retryable( + exc: Exception, + *, + hash_mismatch_is_retryable: bool = False, +) -> bool | None: + if isinstance(exc, _ModelDownloadVerificationError): + return True + response = getattr(exc, "response", None) + status = getattr(response, "status_code", None) + if isinstance(status, int): + return status in {408, 409, 425, 429} or status >= 500 + if isinstance(exc, (ConnectionError, TimeoutError)): + return True + try: + import httpx + except ModuleNotFoundError: + pass + else: + if isinstance(exc, httpx.TransportError): + return True + try: + from requests import exceptions as requests_exceptions + except ModuleNotFoundError: + pass + else: + if isinstance( + exc, + (requests_exceptions.ConnectionError, requests_exceptions.Timeout), + ): + return True + if hash_mismatch_is_retryable and isinstance(exc, ValueError): + return True + if isinstance(exc, OSError): + return False + if type(exc).__module__.startswith(("huggingface_hub", "pooch")): + return False + return None + + def _torch_accelerators() -> tuple[bool, bool]: try: import torch @@ -190,60 +249,121 @@ def update(self, n=1): result = super().update(n) if self.unit == "B": with state_lock: - state.update( - { - "current": int(self.n), - "total": ( - int(self.total) - if self.total - else None - ), - "message": f"Downloading {spec.model_id}.", - } + previous = getattr(self, "_vidxp_reported_bytes", 0) + current = int(self.n) + self._vidxp_reported_bytes = current + state["current"] = min( + spec.download_size_bytes, + int(state["current"]) + max(0, current - previous), ) + state["message"] = f"Downloading {spec.model_id}." return result def download() -> str: - return snapshot_download( - repo_id=spec.model_id, - revision=spec.revision, - cache_dir=str(cache), - local_files_only=False, - tqdm_class=ReportingTqdm, + snapshot = Path( + snapshot_download( + repo_id=spec.model_id, + revision=spec.revision, + cache_dir=str(cache), + local_files_only=False, + tqdm_class=ReportingTqdm, + ) ) + weights = snapshot / spec.weights_file + if ( + not weights.is_file() + or _sha256(weights) != spec.weights_sha256 + ): + raise _ModelDownloadVerificationError + return str(snapshot) reported_at = 0.0 - with ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(download) - while True: - try: - snapshot = future.result(timeout=0.5) - if progress is not None: - with state_lock: - event = dict(state) - progress( - { - "state": "preparing", - "stage": "downloading_model", - **event, - } - ) + reported_current = -1 + progress_step = max( + _MINIMUM_PROGRESS_BYTES, + spec.download_size_bytes // 100, + ) + + def report(*, force: bool = False): + nonlocal reported_at, reported_current + if progress is None: + return + now = monotonic() + with state_lock: + event = dict(state) + current = int(event["current"]) + advanced = current - reported_current >= progress_step + heartbeat = now - reported_at >= _DOWNLOAD_HEARTBEAT_SECONDS + if not force and reported_current >= 0 and not advanced and not heartbeat: + return + progress( + { + "state": "preparing", + "stage": "downloading_model", + **event, + } + ) + reported_at = now + reported_current = current + + last_error: Exception | None = None + last_retryable = False + attempts = 0 + for attempt in range(1, _MODEL_DOWNLOAD_ATTEMPTS + 1): + attempts = attempt + with state_lock: + state["message"] = ( + f"Connecting to download {spec.model_id}." + if attempt == 1 + else ( + f"Retrying download of {spec.model_id} " + f"(attempt {attempt} of {_MODEL_DOWNLOAD_ATTEMPTS})." + ) + ) + report(force=True) + try: + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(download) + while True: + try: + snapshot = future.result(timeout=0.5) + break + except FutureTimeout: + report() + except Exception as exc: + last_error = exc + retryable = _download_failure_retryable(exc) + if retryable is None: + raise + last_retryable = retryable + if ( + attempt >= _MODEL_DOWNLOAD_ATTEMPTS + or not retryable + ): break - except FutureTimeout: - now = monotonic() - if progress is None or now - reported_at < 1: - continue - with state_lock: - event = dict(state) - progress( - { - "state": "preparing", - "stage": "downloading_model", - **event, - } + with state_lock: + state["message"] = ( + f"Download interrupted for {spec.model_id}; cached " + "partial files will be resumed." ) - reported_at = now - return Path(snapshot) + report(force=True) + sleep(2 ** (attempt - 1)) + continue + with state_lock: + state["current"] = spec.download_size_bytes + state["message"] = f"Downloaded {spec.model_id}." + report(force=True) + return Path(snapshot) + + assert last_error is not None + raise ModelArtifactDownloadError( + spec.capability, + spec.model_id, + attempts=attempts, + reason=_download_failure_reason(last_error), + resumable=True, + retryable=last_retryable, + ) from last_error def resolve_model( self, @@ -257,8 +377,9 @@ def resolve_model( from huggingface_hub import snapshot_download try: + snapshot: Path | None try: - snapshot = Path( + local_snapshot = Path( snapshot_download( repo_id=spec.model_id, revision=spec.revision, @@ -266,7 +387,16 @@ def resolve_model( local_files_only=True, ) ) + local_weights = local_snapshot / spec.weights_file + snapshot = ( + local_snapshot + if local_weights.is_file() + and _sha256(local_weights) == spec.weights_sha256 + else None + ) except Exception: + snapshot = None + if snapshot is None: if not download or not self.settings.allow_model_downloads: raise ModelArtifactUnavailableError(spec.capability) snapshot = self._download_snapshot( @@ -274,10 +404,7 @@ def resolve_model( cache=self.settings.model_cache, progress=progress, ) - weights = snapshot / spec.weights_file - if not weights.is_file() or _sha256(weights) != spec.weights_sha256: - raise ModelArtifactUnavailableError(spec.capability) - except ModelArtifactUnavailableError: + except (ModelArtifactDownloadError, ModelArtifactUnavailableError): raise except Exception as exc: raise ModelArtifactUnavailableError(spec.capability) from exc @@ -306,24 +433,80 @@ def resolve_artifact( "state": "preparing", "stage": "downloading_model", "message": f"Downloading {spec.model_id}.", + "current": 0, + "total": spec.download_size_bytes, } ) import pooch - resolved = Path( - pooch.retrieve( - url=spec.url, - known_hash=f"sha256:{spec.sha256}", - fname=spec.filename, - path=destination, - progressbar=False, - ) - ) + last_error = None + for attempt in range(1, _MODEL_DOWNLOAD_ATTEMPTS + 1): + try: + resolved = Path( + pooch.retrieve( + url=spec.url, + known_hash=f"sha256:{spec.sha256}", + fname=spec.filename, + path=destination, + progressbar=False, + ) + ) + break + except Exception as exc: + last_error = exc + retryable = _download_failure_retryable( + exc, + hash_mismatch_is_retryable=True, + ) + if retryable is None: + raise + if ( + attempt >= _MODEL_DOWNLOAD_ATTEMPTS + or not retryable + ): + raise ModelArtifactDownloadError( + spec.capability, + spec.model_id, + attempts=attempt, + reason=_download_failure_reason(exc), + resumable=False, + retryable=retryable, + ) from exc + if progress is not None: + progress( + { + "state": "preparing", + "stage": "downloading_model", + "message": ( + f"Download interrupted for " + f"{spec.model_id}; retrying attempt " + f"{attempt + 1} of " + f"{_MODEL_DOWNLOAD_ATTEMPTS}. This " + "file will restart from zero." + ), + "current": 0, + "total": spec.download_size_bytes, + } + ) + sleep(2 ** (attempt - 1)) + else: + assert last_error is not None + raise last_error else: resolved = path if not resolved.is_file() or _sha256(resolved) != spec.sha256: raise ModelArtifactUnavailableError(spec.capability) - except ModelArtifactUnavailableError: + if progress is not None: + progress( + { + "state": "preparing", + "stage": "downloading_model", + "message": f"Verified {spec.model_id}.", + "current": spec.download_size_bytes, + "total": spec.download_size_bytes, + } + ) + except (ModelArtifactDownloadError, ModelArtifactUnavailableError): raise except Exception as exc: raise ModelArtifactUnavailableError(spec.capability) from exc diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 18a2f1c..8cc0922 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -26,6 +26,9 @@ from vidxp.repository_layout import RepositoryLayout +DEFAULT_HTTP_PORT = 32191 + + class ApplicationMode(StrEnum): local = "local" remote = "remote" @@ -79,7 +82,7 @@ class VidXPSettings(BaseSettings): le=3600, ) http_bind_host: str = Field(default="127.0.0.1", min_length=1) - http_port: int = Field(default=8000, gt=0, le=65535) + http_port: int = Field(default=DEFAULT_HTTP_PORT, gt=0, le=65535) http_auth_mode: HttpAuthMode = HttpAuthMode.none http_static_bearer_token: SecretStr | None = None http_oidc_issuer: str | None = Field( diff --git a/tests/test_api.py b/tests/test_api.py index b862c1e..dab53f5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -19,16 +19,19 @@ from vidxp.application_models import ( ApplicationError, ErrorCategory, + ErrorDetail, ComponentReadiness, Job, JobKind, JobQueue, JobState, + IndexStatus, MediaAsset, Principal, SearchCommand, QueryVideoCommand, UploadIntent, + WorkspaceOverview, ) from vidxp.composition import ( HttpApplicationContext, @@ -180,10 +183,13 @@ def test_health_and_minimal_readiness_are_public(self): ) with TestClient(create_app(context=context)) as client: health = client.get("/health") + favicon = client.get("/favicon.ico") ready = client.get("/ready") self.assertEqual(health.status_code, 200) self.assertEqual(health.json(), {"status": "ok"}) + self.assertEqual(favicon.status_code, 204) + self.assertEqual(favicon.content, b"") self.assertEqual(ready.status_code, 200) self.assertEqual(ready.json(), {"ready": True, "status": "ready"}) @@ -274,6 +280,28 @@ def test_static_bearer_is_enforced_before_dispatch(self): self.assertEqual(accepted.json(), {"items": []}) context.application.list_capabilities.assert_called_once_with() + def test_workspace_endpoint_returns_actionable_repository_state(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.workspace.return_value = WorkspaceOverview( + media_total=0, + index=IndexStatus( + schema_version=2, + state="missing", + stage="status", + message="No index.", + ), + next_actions=("register_media",), + ) + with TestClient(create_app(context=context)) as client: + response = client.get("/api/v1/workspace?page_size=25") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["media_total"], 0) + self.assertEqual(response.json()["next_actions"], ["register_media"]) + command = context.application.workspace.call_args.args[0] + self.assertEqual(command.page_size, 25) + def test_repository_scopes_are_enforced_per_operation(self): with TemporaryDirectory() as directory: context = self.context( @@ -374,7 +402,6 @@ def test_job_submission_is_thin_idempotent_delegation(self): self.assertEqual(command.media_id, MEDIA_ID) self.assertEqual(command.modalities, ("scene",)) self.assertEqual(command.scene_sample_fps, 0.5) - context.application.require_models.assert_called_once_with(("scene",)) expected_job_id = scoped_job_id( context, context.authenticator.authenticate(None), @@ -405,10 +432,39 @@ def test_job_submission_is_thin_idempotent_delegation(self): ), ) + def test_failed_model_preparation_job_is_structured_over_http(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.get.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.failed, + queue=JobQueue.cpu, + error=ErrorDetail( + code="model_download_failed", + category=ErrorCategory.unavailable, + message="The model download failed after three attempts.", + details={ + "model": "publisher/model", + "partial_files_preserved": True, + "remediation": "vidxp prepare --modalities dialogue", + }, + retryable=True, + ), + ) + with TestClient(create_app(context=context)) as client: + response = client.get(f"/api/v1/jobs/{JOB_ID}") + + self.assertEqual(response.status_code, 200) + error = response.json()["error"] + self.assertEqual(error["code"], "model_download_failed") + self.assertTrue(error["retryable"]) + self.assertTrue(error["details"]["partial_files_preserved"]) + def test_missing_models_fail_before_job_submission(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) - context.application.require_models.side_effect = ApplicationError( + context.jobs.submit_index.side_effect = ApplicationError( "model_unavailable", ErrorCategory.unavailable, "Run vidxp prepare --modalities scene.", @@ -433,7 +489,7 @@ def test_missing_models_fail_before_job_submission(self): response.json()["error"]["details"]["remediation"], "vidxp prepare --modalities scene", ) - context.jobs.submit_index.assert_not_called() + context.jobs.submit_index.assert_called_once() def test_job_submission_requires_an_idempotency_key(self): with TemporaryDirectory() as directory: diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 60682e6..996cfb0 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -1,8 +1,10 @@ import unittest from contextlib import redirect_stdout from io import StringIO +from unittest.mock import patch -from vidxp.api_cli import main +from vidxp.api_cli import _shared_settings, main +from vidxp.settings import HttpAuthMode, VidXPSettings class ApiCliTests(unittest.TestCase): @@ -14,6 +16,84 @@ def test_help_exits_without_starting_the_service(self): self.assertEqual(caught.exception.code, 0) self.assertIn("VIDXP_* environment variables", output.getvalue()) + self.assertIn("--port", output.getvalue()) + self.assertIn("--share", output.getvalue()) + + def test_share_mode_configures_managed_static_server(self): + settings, token = _shared_settings( + VidXPSettings(), + host="192.168.100.131", + token="x" * 43, + ) + + self.assertEqual(settings.http_bind_host, "192.168.100.131") + self.assertEqual(settings.http_auth_mode, HttpAuthMode.static) + self.assertEqual(token, "x" * 43) + self.assertIn("192.168.100.131", settings.http_trusted_hosts) + self.assertIn("192.168.100.131:*", settings.mcp_allowed_hosts) + settings.validate_http_server() + + def test_share_mode_reuses_an_explicit_static_token(self): + settings, token = _shared_settings( + VidXPSettings( + http_auth_mode=HttpAuthMode.static, + http_static_bearer_token="configured-token-1234567890123456", + ), + host="192.168.100.131", + token="managed-token-123456789012345678", + ) + + self.assertEqual(token, "configured-token-1234567890123456") + + def test_main_shares_on_the_detected_address(self): + import uvicorn + from vidxp import api + + output = StringIO() + with ( + patch.object(uvicorn, "run") as run, + patch.object( + api, + "create_app", + ) as create_app, + patch( + "vidxp.api_cli.primary_lan_address", + return_value="192.168.100.131", + ), + patch( + "vidxp.api_cli.load_or_create_api_share_token", + return_value="x" * 43, + ), + redirect_stdout(output), + ): + main(["--share"]) + + run.assert_called_once_with( + create_app.return_value, + host="192.168.100.131", + port=32191, + ) + self.assertIn( + "MCP: http://192.168.100.131:32191/mcp", + output.getvalue(), + ) + self.assertIn("Bearer token:", output.getvalue()) + + def test_main_accepts_an_explicit_port(self): + import uvicorn + from vidxp import api + + with ( + patch.object(uvicorn, "run") as run, + patch.object(api, "create_app") as create_app, + ): + main(["--port", "32192"]) + + run.assert_called_once_with( + create_app.return_value, + host="127.0.0.1", + port=32192, + ) if __name__ == "__main__": diff --git a/tests/test_application.py b/tests/test_application.py index f3bb2d4..ee55144 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -21,6 +21,7 @@ IndexResult, IndexSnapshotReference, ModelUnavailableError, + ModelDownloadError, PrepareModelsCommand, QueryAnswerMode, QueryVideoCommand, @@ -30,7 +31,10 @@ from vidxp.core.media import MediaUnavailableError from vidxp.core.contracts import IndexConfig, IndexSchemaError from vidxp.infrastructure.local_index import LocalIndexBackend -from vidxp.model_contracts import ModelArtifactUnavailableError +from vidxp.model_contracts import ( + ModelArtifactDownloadError, + ModelArtifactUnavailableError, +) from vidxp.capabilities.contracts import ( CapabilityDefinition, CapabilityExecutor, @@ -818,6 +822,54 @@ def test_missing_model_is_not_misclassified_as_a_package_dependency(self): json.dumps(raised.exception.to_dict()), ) + def test_model_download_failure_preserves_retry_details(self): + definition = CapabilityDefinition( + name="prepare-only", + description="Prepare a provider.", + extra="prepare-only", + operations={ + "noop": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + prepares_models=True, + ) + failure = ModelArtifactDownloadError( + "prepare-only.embedding", + "publisher/model", + attempts=3, + reason="ConnectionError", + resumable=True, + retryable=True, + ) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={"noop": Mock()}, + prepare=Mock(side_effect=failure), + ), + ) + registry = CapabilityRegistry((plugin,)) + registry.dependency_checks = Mock(return_value=()) + application, _ = self.application("unused", registry=registry) + + with self.assertRaises(ModelDownloadError) as raised: + application.prepare_models( + PrepareModelsCommand(modalities=("prepare-only",)) + ) + + payload = raised.exception.to_dict() + self.assertEqual(payload["code"], "model_download_failed") + self.assertTrue(payload["retryable"]) + self.assertEqual(payload["details"]["attempts"], 3) + self.assertTrue(payload["details"]["partial_files_preserved"]) + self.assertEqual( + payload["details"]["remediation"], + "vidxp prepare --modalities prepare-only", + ) + def test_unexpected_handler_error_is_not_misclassified(self): failure = RuntimeError("implementation bug") definition = CapabilityDefinition( diff --git a/tests/test_cli.py b/tests/test_cli.py index b3b4466..81fb3d6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,8 @@ CreateIndexCommand, DependencyCheckResult, DependencyKind, + ErrorCategory, + ErrorDetail, FusedSearchResult, FusionProvenance, IndexJobResult, @@ -145,6 +147,33 @@ def test_grouped_commands_are_exposed(self): ): self.assertIn(command, result.output) + def test_failed_model_preparation_job_is_structured_in_cli_json(self): + self.jobs.get.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.failed, + queue=JobQueue.cpu, + error=ErrorDetail( + code="model_download_failed", + category=ErrorCategory.unavailable, + message="The model download failed after three attempts.", + details={ + "model": "publisher/model", + "partial_files_preserved": True, + "remediation": "vidxp prepare --modalities dialogue", + }, + retryable=True, + ), + ) + + result = self.invoke(["jobs", "show", JOB_ID]) + + self.assertEqual(result.exit_code, 0, result.output) + error = json.loads(result.output)["error"] + self.assertEqual(error["code"], "model_download_failed") + self.assertTrue(error["retryable"]) + self.assertTrue(error["details"]["partial_files_preserved"]) + def test_mcp_config_is_copy_paste_json_without_opening_repository(self): result = self.invoke(["mcp-config"]) @@ -305,12 +334,39 @@ def test_ui_shutdown_stops_its_local_worker(self): with patch( "vidxp.frontend.main", side_effect=SystemExit(0), - ): + ) as frontend: result = self.invoke(["ui"]) self.assertEqual(result.exit_code, 0, result.output) + self.assertIn( + "--server.address=127.0.0.1", + frontend.call_args.args[0], + ) + self.assertIn( + "--server.showEmailPrompt=false", + frontend.call_args.args[0], + ) + self.assertIn( + "--browser.gatherUsageStats=false", + frontend.call_args.args[0], + ) self.jobs.stop_worker.assert_called_once_with() + def test_ui_share_uses_streamlit_wildcard_bind_and_warns(self): + with patch( + "vidxp.frontend.main", + side_effect=SystemExit(0), + ) as frontend: + result = self.invoke(["ui", "--share"]) + + self.assertEqual(result.exit_code, 0, result.output) + arguments = frontend.call_args.args[0] + self.assertIn("--server.address=0.0.0.0", arguments) + self.assertIn("--server.showEmailPrompt=false", arguments) + self.assertIn("--browser.gatherUsageStats=false", arguments) + self.assertIn("has no authentication", result.output) + self.assertNotIn("Browser UI:", result.output) + def test_snippet_rejects_an_inverted_time_range_before_submission(self): result = self.invoke( ["artifacts", "snippet", MEDIA_ID, "3", "2"] @@ -813,10 +869,48 @@ def test_prepare_announces_start_and_subscribes_to_job_progress(self): self.assertIn("1.43 GiB", result.output) self.assertRegex( result.output, - r"\[\d{2}:\d{2}:\d{2}\] Starting model preparation for scene\.", + r"\[\d{2}:\d{2}:\d{2}\] Downloading and validating models for " + r"scene\.", ) self.assertTrue(callable(self.jobs.wait.call_args.kwargs["progress"])) + def test_prepare_distinguishes_cached_model_verification(self): + self.service.model_readiness.return_value = DependencyCheckResult( + ok=True, + modalities=("scene",), + checks=(), + ) + prepared = PrepareModelsResult( + prepared=("scene-model",), + modalities=("scene",), + runtime={ + "requested": "cpu", + "torch_device": "cpu", + "transcription_device": "cpu", + }, + ) + self.jobs.submit_prepare_models.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=PrepareModelsJobResult(result=prepared), + ) + + result = self.invoke(["prepare", "--modalities", "scene"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertRegex( + result.output, + r"\[\d{2}:\d{2}:\d{2}\] Validating cached models for scene\.", + ) + def test_prepare_discloses_size_and_requires_confirmation(self): self.service.model_readiness.return_value = DependencyCheckResult( ok=False, diff --git a/tests/test_control_plane.py b/tests/test_control_plane.py new file mode 100644 index 0000000..a6ae69b --- /dev/null +++ b/tests/test_control_plane.py @@ -0,0 +1,152 @@ +import unittest +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock + +from vidxp.application_models import ( + ApplicationError, + CapabilityIdentityMode, + CapabilityRole, + CreateIndexCommand, + ListMediaCommand, + MediaAsset, + MediaPage, +) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capability_service import CapabilityService +from vidxp.control_plane import ControlPlaneApplication +from vidxp.core.media import MediaState, MediaStream +from vidxp.core.snapshots import GenerationReference, IndexSnapshot +from vidxp.repository_layout import RepositoryLayout + + +MEDIA_ID = "123456781234423481234567890abcde" +OTHER_MEDIA_ID = "223456781234423481234567890abcde" +GENERATION_ID = "323456781234423481234567890abcde" +SNAPSHOT_ID = "423456781234423481234567890abcde" + + +def media_asset(media_id: str, filename: str) -> MediaAsset: + return MediaAsset( + media_id=media_id, + video_id=media_id, + original_filename=filename, + sha256="a" * 64, + byte_size=10, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + +class ControlPlaneWorkspaceTests(unittest.TestCase): + def test_index_preflight_rejects_unknown_capability_with_next_action(self): + with TemporaryDirectory() as directory: + root = Path(directory) + media = Mock() + application = ControlPlaneApplication( + layout=RepositoryLayout(root=root), + capabilities=CapabilityService(create_capability_registry()), + media=media, + artifacts=Mock(), + index_status=lambda: None, + model_cache=root / "models", + ) + + with self.assertRaises(ApplicationError) as raised: + application.preflight_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("unknown",), + ) + ) + + error = raised.exception.to_dict()["details"]["errors"][0] + self.assertEqual(error["reason"], "capability_unknown") + self.assertEqual(error["requested"], ["unknown"]) + self.assertIn("get_workspace", error["next_action"]) + media.get.assert_not_called() + + def test_workspace_projects_index_coverage_roles_and_next_actions(self): + indexed = media_asset(MEDIA_ID, "indexed.mp4") + unindexed = media_asset(OTHER_MEDIA_ID, "new.mp4") + snapshot = IndexSnapshot( + snapshot_id=SNAPSHOT_ID, + created_at=datetime.now(timezone.utc), + config_fingerprint="b" * 64, + configuration={}, + generations={ + MEDIA_ID: GenerationReference( + generation_id=GENERATION_ID, + media_id=MEDIA_ID, + manifest_sha256="c" * 64, + input_sha256="d" * 64, + config_fingerprint="e" * 64, + modalities=("scene", "actor"), + record_counts={"scene": 12, "actor": 4}, + store_size_bytes_at_commit=100, + ) + }, + ) + media = Mock() + media.list.return_value = MediaPage( + items=(indexed, unindexed), + total=2, + ) + with TemporaryDirectory() as directory: + root = Path(directory) + application = ControlPlaneApplication( + layout=RepositoryLayout(root=root), + capabilities=CapabilityService(create_capability_registry()), + media=media, + artifacts=Mock(), + index_status=lambda: { + "schema_version": 2, + "state": "ready", + "stage": "status", + "message": "Index ready.", + }, + active_snapshot=lambda: snapshot, + model_cache=root / "models", + ) + + workspace = application.workspace(ListMediaCommand()) + + self.assertEqual(workspace.media_total, 2) + self.assertEqual( + workspace.next_actions, + ("index_media", "find_moments", "answer_video"), + ) + indexed_projection = workspace.media[0] + self.assertTrue(indexed_projection.in_active_snapshot) + by_name = { + capability.name: capability + for capability in indexed_projection.capabilities + } + self.assertEqual(by_name["scene"].record_count, 12) + self.assertEqual( + by_name["scene"].roles, + (CapabilityRole.searchable, CapabilityRole.queryable), + ) + self.assertEqual(by_name["actor"].record_count, 4) + self.assertEqual( + by_name["actor"].identity_mode, + CapabilityIdentityMode.anonymous_clusters, + ) + self.assertEqual(workspace.media[1].capabilities[0].roles, ()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index ee426c4..9991b0a 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -337,7 +337,7 @@ def test_indexing_submits_selected_scene_sample_rate(self): command = jobs.submit_index.call_args.args[0] self.assertEqual(command.scene_sample_fps, 2.0) - service.require_models.assert_called_once_with(("scene",)) + service.require_models.assert_not_called() def test_indexing_omits_scene_sample_rate_without_scene(self): jobs = Mock() diff --git a/tests/test_job_contracts.py b/tests/test_job_contracts.py index 817f05f..018e757 100644 --- a/tests/test_job_contracts.py +++ b/tests/test_job_contracts.py @@ -9,6 +9,8 @@ from vidxp.application_models import ( ActorOverlayJobRequest, ApplicationError, + ErrorCategory, + ErrorDetail, CreateActorOverlayCommand, CreateIndexCommand, Job, @@ -77,6 +79,7 @@ def test_public_job_contract_has_no_path_or_storage_fields(self): def test_job_service_routes_model_work_without_reimplementing_it(self): backend = Mock() + preflight = Mock() backend.submit.return_value = Job( job_id=JOB_ID, kind=JobKind.index, @@ -89,6 +92,7 @@ def test_job_service_routes_model_work_without_reimplementing_it(self): runtime_backend="cpu", ), backend=backend, + index_preflight=preflight, ) job = service.submit_index( @@ -100,6 +104,7 @@ def test_job_service_routes_model_work_without_reimplementing_it(self): self.assertEqual(job.job_id, JOB_ID) request = backend.submit.call_args.args[0] + preflight.assert_called_once_with(request.command) self.assertEqual(request.kind, JobKind.index) self.assertEqual(request.command.media_id, MEDIA_ID) self.assertEqual( @@ -107,6 +112,34 @@ def test_job_service_routes_model_work_without_reimplementing_it(self): JobQueue.cpu, ) + def test_index_preflight_failure_never_reaches_the_job_backend(self): + backend = Mock() + command = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + preflight = Mock( + side_effect=ApplicationError( + "invalid_request", + ErrorCategory.validation, + "The capability is not usable.", + ) + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + index_preflight=preflight, + ) + + with self.assertRaises(ApplicationError): + service.submit_index(command) + + preflight.assert_called_once_with(command) + backend.submit.assert_not_called() + def test_job_list_cursor_is_bounded(self): with self.assertRaises(ValidationError): ListJobsCommand(cursor="x" * 513) @@ -303,6 +336,45 @@ def test_job_backend_errors_are_normalized_for_every_adapter(self): service.get(JOB_ID) self.assertEqual(raised.exception.code, "job_backend_unavailable") + def test_failed_model_preparation_error_round_trips_through_job_service(self): + error = ErrorDetail( + code="model_download_failed", + category=ErrorCategory.unavailable, + message="The model download failed after three attempts.", + details={ + "capability": "dialogue.transcription", + "model": "publisher/model", + "attempts": 3, + "reason": "ConnectionError", + "partial_files_preserved": True, + "remediation": "vidxp prepare --modalities dialogue", + }, + retryable=True, + ) + backend = Mock() + backend.get.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.failed, + queue=JobQueue.cpu, + error=error, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + ) + + with self.assertRaises(ApplicationError) as raised: + service.result(JOB_ID) + + self.assertEqual( + raised.exception.to_dict(), + error.model_dump(mode="json"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index eda1d88..2639628 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -24,6 +24,7 @@ ApplicationError, Artifact, ErrorCategory, + ErrorDetail, IndexStatus, Job, JobKind, @@ -33,6 +34,7 @@ MediaPage, Principal, QueryVideoCommand, + WorkspaceOverview, ) from vidxp.authentication import ( AuthenticatedBearer, @@ -102,6 +104,11 @@ def context( stage="status", message="No index.", ) + application.workspace.return_value = WorkspaceOverview( + media_total=0, + index=application.index_status.return_value, + next_actions=("register_media",), + ) jobs = Mock(spec=JobService) readiness = Mock() readiness.ready.return_value = True @@ -131,6 +138,7 @@ async def test_curated_tools_have_structured_output_schemas(self): self.assertEqual( [tool.name for tool in discovered.tools], [ + "get_workspace", "list_capabilities", "get_capability", "get_runtime_readiness", @@ -165,6 +173,26 @@ async def test_curated_tools_have_structured_output_schemas(self): self.assertEqual(result.structured_content, {"items": []}) self.assertFalse(result.is_error) + async def test_workspace_tool_projects_actionable_repository_state(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + server = create_mcp_server( + context, + default_principal=Principal( + subject="local", + scopes=frozenset({"*"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool("get_workspace", {}) + + self.assertEqual(result.structured_content["media_total"], 0) + self.assertEqual( + result.structured_content["next_actions"], + ["register_media"], + ) + context.application.workspace.assert_called_once() + def test_stdio_help_and_config_are_ready_to_copy(self): config = stdio_client_config( command=r"C:\VidXP\vidxp-mcp.exe", @@ -216,7 +244,7 @@ def test_stdio_check_performs_handshake_and_tool_probe(self): rendered = output.getvalue() self.assertIn("OK VidXP MCP", rendered) self.assertIn("Index state: missing", rendered) - self.assertIn("Tools: 16", rendered) + self.assertIn("Tools: 17", rendered) self.assertIn("get_index_status", rendered) async def test_server_info_exposes_vidxp_branding(self): @@ -279,15 +307,12 @@ async def test_index_submission_uses_shared_stable_idempotency(self): calls[0].kwargs["job_id"], calls[1].kwargs["job_id"], ) - self.assertEqual( - context.application.require_models.call_args.args[0], - ("scene",), - ) + self.assertEqual(calls[0].args[0].modalities, ("scene",)) async def test_missing_models_fail_before_index_submission(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) - context.application.require_models.side_effect = ApplicationError( + context.jobs.submit_index.side_effect = ApplicationError( "model_unavailable", ErrorCategory.unavailable, "Run vidxp prepare --modalities scene.", @@ -320,7 +345,7 @@ async def test_missing_models_fail_before_index_submission(self): '"remediation":"vidxp prepare --modalities scene"', result.content[0].text, ) - context.jobs.submit_index.assert_not_called() + context.jobs.submit_index.assert_called_once() async def test_query_video_submits_the_shared_durable_command(self): with TemporaryDirectory() as directory: @@ -401,6 +426,42 @@ async def test_discovery_tools_use_shared_media_and_job_pages(self): ) self.assertEqual(context.jobs.list.call_args.args[0].page_size, 9) + async def test_failed_model_preparation_job_is_structured_over_mcp(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.get.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.failed, + queue=JobQueue.cpu, + error=ErrorDetail( + code="model_download_failed", + category=ErrorCategory.unavailable, + message="The model download failed after three attempts.", + details={ + "model": "publisher/model", + "partial_files_preserved": True, + "remediation": "vidxp prepare --modalities dialogue", + }, + retryable=True, + ), + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool("get_job", {"job_id": JOB_ID}) + + self.assertFalse(result.is_error) + error = result.structured_content["error"] + self.assertEqual(error["code"], "model_download_failed") + self.assertTrue(error["retryable"]) + self.assertTrue(error["details"]["partial_files_preserved"]) + async def test_retry_job_uses_shared_stable_idempotency(self): with TemporaryDirectory() as directory: context = self.context(Path(directory)) @@ -591,6 +652,7 @@ async def test_stdio_entrypoint_serves_the_same_curated_surface(self): self.assertEqual( [tool.name for tool in discovered.tools], [ + "get_workspace", "list_capabilities", "get_capability", "get_runtime_readiness", @@ -660,7 +722,7 @@ async def test_streamable_http_works_with_the_official_remote_client(self): server.should_exit = True await serving - self.assertEqual(len(discovered.tools), 16) + self.assertEqual(len(discovered.tools), 17) self.assertEqual(result.structured_content, {"items": []}) async def test_oidc_verifier_projects_the_shared_validated_token(self): diff --git a/tests/test_models.py b/tests/test_models.py index 74af819..c56923a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -37,6 +37,7 @@ SERVER_INDEX_RUNTIME_CHECKS, ) from vidxp.model_contracts import ( + ModelArtifactDownloadError, ModelArtifactUnavailableError, ModelKey, model_artifact_path, @@ -150,6 +151,39 @@ def test_actor_download_uses_pinned_media_object_not_lfs_pointer(self): "yunet", ) + def test_actor_download_retries_without_claiming_partial_resume(self): + with TemporaryDirectory() as directory: + spec = replace(YUNET_MODEL, filename="missing.onnx") + runtime = self.runtime( + directory, + allowed_specs=(spec,), + allow_model_downloads=True, + ) + retrieve = Mock(side_effect=ConnectionError("interrupted")) + events = [] + with patch.dict( + sys.modules, + {"pooch": types.SimpleNamespace(retrieve=retrieve)}, + ), patch("vidxp.runtime.sleep"), self.assertRaises( + ModelArtifactDownloadError + ) as raised: + runtime.resolve_artifact( + spec, + download=True, + progress=events.append, + ) + + self.assertEqual(retrieve.call_count, 3) + self.assertFalse(raised.exception.resumable) + self.assertTrue(raised.exception.retryable) + self.assertTrue( + any( + event["stage"] == "downloading_model" + and "restart from zero" in event["message"] + for event in events + ) + ) + def test_normal_model_resolution_never_downloads_implicitly(self): with TemporaryDirectory() as directory: spec = replace(YUNET_MODEL, filename="missing.onnx") @@ -203,6 +237,14 @@ def test_model_readiness_checks_the_pinned_cache_without_loading(self): def test_explicit_snapshot_download_reports_bytes(self): with TemporaryDirectory() as directory: snapshot = Path(directory) / "snapshot" + weights = snapshot / FASTER_WHISPER_MODEL.weights_file + weights.parent.mkdir(parents=True) + content = b"verified weights" + weights.write_bytes(content) + spec = replace( + FASTER_WHISPER_MODEL, + weights_sha256=hashlib.sha256(content).hexdigest(), + ) events = [] def download(**options): @@ -226,7 +268,7 @@ def download(**options): False, ): resolved = ModelRuntime._download_snapshot( - FASTER_WHISPER_MODEL, + spec, cache=Path(directory), progress=events.append, ) @@ -235,15 +277,197 @@ def download(**options): ) self.assertEqual(resolved, snapshot) + download_events = [ + event for event in events if event["stage"] == "downloading_model" + ] + self.assertTrue(download_events) + self.assertTrue( + all( + event["total"] == spec.download_size_bytes + for event in download_events + ) + ) self.assertTrue( any( event["stage"] == "downloading_model" - and event["current"] == 1024 - and event["total"] == 1024 + and event["current"] + == spec.download_size_bytes + and event["total"] + == spec.download_size_bytes for event in events ) ) + def test_snapshot_download_retries_and_resumes_partial_cache(self): + with TemporaryDirectory() as directory: + snapshot = Path(directory) / "snapshot" + weights = snapshot / FASTER_WHISPER_MODEL.weights_file + weights.parent.mkdir(parents=True) + content = b"verified weights" + weights.write_bytes(content) + spec = replace( + FASTER_WHISPER_MODEL, + weights_sha256=hashlib.sha256(content).hexdigest(), + ) + download = Mock( + side_effect=(ConnectionError("interrupted"), str(snapshot)) + ) + events = [] + + with patch( + "huggingface_hub.snapshot_download", + download, + ), patch("vidxp.runtime.sleep") as retry_sleep: + resolved = ModelRuntime._download_snapshot( + spec, + cache=Path(directory), + progress=events.append, + ) + + self.assertEqual(resolved, snapshot) + self.assertEqual(download.call_count, 2) + retry_sleep.assert_called_once_with(1) + self.assertTrue( + any( + event["stage"] == "downloading_model" + and "partial files will be resumed" in event["message"] + for event in events + ) + ) + + def test_snapshot_download_retries_an_incomplete_returned_snapshot(self): + with TemporaryDirectory() as directory: + snapshot = Path(directory) / "snapshot" + weights = snapshot / FASTER_WHISPER_MODEL.weights_file + content = b"verified weights" + spec = replace( + FASTER_WHISPER_MODEL, + weights_sha256=hashlib.sha256(content).hexdigest(), + ) + calls = 0 + + def download(**_options): + nonlocal calls + calls += 1 + if calls == 2: + weights.parent.mkdir(parents=True) + weights.write_bytes(content) + return str(snapshot) + + events = [] + with patch( + "huggingface_hub.snapshot_download", + side_effect=download, + ), patch("vidxp.runtime.sleep") as retry_sleep: + resolved = ModelRuntime._download_snapshot( + spec, + cache=Path(directory), + progress=events.append, + ) + + self.assertEqual(resolved, snapshot) + self.assertEqual(calls, 2) + retry_sleep.assert_called_once_with(1) + self.assertTrue( + any( + event["stage"] == "downloading_model" + and "partial files will be resumed" in event["message"] + for event in events + ) + ) + + def test_snapshot_download_reports_actionable_terminal_failure(self): + with TemporaryDirectory() as directory: + download = Mock(side_effect=ConnectionError("private detail")) + + with patch( + "huggingface_hub.snapshot_download", + download, + ), patch("vidxp.runtime.sleep"), self.assertRaises( + ModelArtifactDownloadError + ) as raised: + ModelRuntime._download_snapshot( + FASTER_WHISPER_MODEL, + cache=Path(directory), + progress=None, + ) + + self.assertEqual(download.call_count, 3) + self.assertEqual(raised.exception.attempts, 3) + self.assertEqual(raised.exception.reason, "ConnectionError") + self.assertTrue(raised.exception.resumable) + self.assertTrue(raised.exception.retryable) + self.assertNotIn("private detail", str(raised.exception)) + + def test_snapshot_download_does_not_mask_programming_errors(self): + with TemporaryDirectory() as directory: + download = Mock(side_effect=TypeError("implementation bug")) + + with patch( + "huggingface_hub.snapshot_download", + download, + ), self.assertRaisesRegex(TypeError, "implementation bug"): + ModelRuntime._download_snapshot( + FASTER_WHISPER_MODEL, + cache=Path(directory), + progress=None, + ) + + download.assert_called_once() + + def test_snapshot_download_does_not_retry_terminal_http_errors(self): + with TemporaryDirectory() as directory: + response = Mock(status_code=404) + failure = RuntimeError("not found") + failure.response = response + download = Mock(side_effect=failure) + + with patch( + "huggingface_hub.snapshot_download", + download, + ), self.assertRaises(ModelArtifactDownloadError) as raised: + ModelRuntime._download_snapshot( + FASTER_WHISPER_MODEL, + cache=Path(directory), + progress=None, + ) + + download.assert_called_once() + self.assertEqual(raised.exception.reason, "HTTP 404 RuntimeError") + self.assertFalse(raised.exception.retryable) + + def test_incomplete_cached_snapshot_is_resumed_during_prepare(self): + with TemporaryDirectory() as directory: + cache = Path(directory) / "models" + incomplete = Path(directory) / "incomplete" + complete = Path(directory) / "complete" + content = b"verified weights" + weights = complete / FASTER_WHISPER_MODEL.weights_file + weights.parent.mkdir(parents=True) + weights.write_bytes(content) + spec = replace( + FASTER_WHISPER_MODEL, + weights_sha256=hashlib.sha256(content).hexdigest(), + ) + runtime = self.runtime( + directory, + allowed_specs=(spec,), + allow_model_downloads=True, + ) + + with patch( + "huggingface_hub.snapshot_download", + return_value=str(incomplete), + ), patch.object( + ModelRuntime, + "_download_snapshot", + return_value=complete, + ) as resume: + resolved = runtime.resolve_model(spec, download=True) + + self.assertEqual(resolved, complete) + resume.assert_called_once_with(spec, cache=cache, progress=None) + def test_runtime_rejects_specs_not_declared_by_enabled_capabilities(self): with TemporaryDirectory() as directory: runtime = self.runtime(directory) diff --git a/tests/test_network_share.py b/tests/test_network_share.py new file mode 100644 index 0000000..4248451 --- /dev/null +++ b/tests/test_network_share.py @@ -0,0 +1,32 @@ +import os +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from vidxp.network_share import load_or_create_api_share_token + + +class NetworkShareTests(unittest.TestCase): + def test_managed_token_is_stable_and_private(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "api-share-token" + + first = load_or_create_api_share_token(path) + second = load_or_create_api_share_token(path) + + self.assertEqual(first, second) + self.assertGreaterEqual(len(first), 32) + if os.name != "nt": + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_invalid_existing_token_is_rejected(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "api-share-token" + path.write_text("short\n", encoding="utf-8") + + with self.assertRaisesRegex(RuntimeError, "token is invalid"): + load_or_create_api_share_token(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index bdc4c9f..ba7c0ce 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -337,15 +337,19 @@ def test_release_please_preserves_desktop_manifests_and_links_versions(self): config = json.loads( (ROOT / filename).read_text(encoding="utf-8") ) + self.assertNotIn("group-pull-request-title-pattern", config) linked_versions = [ plugin for plugin in config["plugins"] if plugin["type"] == "linked-versions" ] self.assertEqual(len(linked_versions), 1, filename) + root_package = config["packages"]["."] + self.assertFalse(root_package["include-component-in-tag"]) + self.assertNotIn("component", root_package) self.assertEqual( set(linked_versions[0]["components"]), - {"vidxp", "desktop"}, + {"", "desktop"}, filename, ) diff --git a/tests/test_read_job_planner.py b/tests/test_read_job_planner.py index bb12274..0aba9be 100644 --- a/tests/test_read_job_planner.py +++ b/tests/test_read_job_planner.py @@ -1,12 +1,16 @@ import unittest +from datetime import datetime, timezone from unittest.mock import Mock from vidxp.application_models import ( + ApplicationError, CreateActorOverlayCommand, QueryVideoCommand, SearchCommand, ) +from vidxp.capabilities.registry import create_capability_registry from vidxp.core.contracts import IndexConfig +from vidxp.core.snapshots import GenerationReference, IndexSnapshot from vidxp.read_job_planner import LocalReadJobPlanner from vidxp.repository_layout import RepositoryLayout @@ -27,10 +31,32 @@ def setUp(self): storage_directory=self.layout.index_store, collection_names={"scene": "scene", "actor": "actor"}, ) + self.snapshot = IndexSnapshot( + snapshot_id=SNAPSHOT_ID, + created_at=datetime.now(timezone.utc), + config_fingerprint="b" * 64, + configuration={}, + generations={ + MEDIA_ID: GenerationReference( + generation_id=GENERATION_ID, + media_id=MEDIA_ID, + manifest_sha256="c" * 64, + input_sha256="d" * 64, + config_fingerprint="e" * 64, + modalities=("scene", "actor"), + record_counts={"scene": 2, "actor": 1}, + store_size_bytes_at_commit=100, + ) + }, + ) self.index = Mock() - self.index.active_config.return_value = self.config + self.index.active_snapshot.return_value = ( + self.config, + self.snapshot, + ) self.planner = LocalReadJobPlanner( layout=self.layout, + registry=create_capability_registry(), index=self.index, ) @@ -68,6 +94,45 @@ def test_query_job_carries_the_same_logical_snapshot_reference(self): self.assertEqual(request.snapshot.snapshot_sha256, "a" * 64) self.index.open_store.assert_not_called() + def test_omitted_search_capabilities_exclude_non_searchable_actor(self): + request = self.planner.plan_search(SearchCommand(query="a taxi")) + + self.assertEqual(request.command.modalities, ("scene",)) + + def test_explicit_actor_search_fails_before_a_job_can_be_submitted(self): + with self.assertRaises(ApplicationError) as raised: + self.planner.plan_search( + SearchCommand(modalities=("actor",), query="Harry") + ) + + error = raised.exception.to_dict()["details"] + self.assertEqual(error["errors"][0]["reason"], "capability_role_unsupported") + self.assertEqual(error["errors"][0]["requested"], ["actor"]) + self.assertEqual(error["errors"][0]["available"], ["scene"]) + + def test_actor_remains_available_to_grounded_query(self): + request = self.planner.plan_query( + QueryVideoCommand( + question="When does this actor appear?", + modalities=("actor",), + ) + ) + + self.assertEqual(request.command.modalities, ("actor",)) + + def test_media_outside_active_snapshot_fails_during_planning(self): + with self.assertRaises(ApplicationError) as raised: + self.planner.plan_search( + SearchCommand( + media_id="423456781234423481234567890abcde", + query="a taxi", + ) + ) + + error = raised.exception.to_dict()["details"]["errors"][0] + self.assertEqual(error["reason"], "media_not_indexed") + self.assertEqual(error["available"], [MEDIA_ID]) + if __name__ == "__main__": unittest.main()