From 8803eba2827fe1e05d0c19c00fcfddcb56c6c973 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 1 Apr 2026 12:27:22 +1300 Subject: [PATCH 1/2] prepare hosted runtime foundation --- README.md | 26 ++ apps/backend/ARCHITECTURE.md | 30 +- apps/backend/README.md | 27 ++ apps/backend/analysis_runtime.py | 229 +++++++--- apps/backend/artifact_storage.py | 108 +++++ apps/backend/auth_context.py | 44 ++ apps/backend/runtime_profile.py | 61 +++ apps/backend/server.py | 392 +++++++++++++----- apps/backend/tests/test_analysis_runtime.py | 83 ++++ apps/backend/tests/test_cleanup.py | 31 ++ apps/backend/tests/test_runtime_profile.py | 24 ++ apps/backend/tests/test_server.py | 90 ++++ apps/backend/worker.py | 29 ++ apps/ui/.env.example | 8 + apps/ui/src/components/SpectrogramViewer.tsx | 53 ++- apps/ui/src/config.ts | 91 +++- apps/ui/src/services/analysisRunsClient.ts | 8 +- apps/ui/src/services/backendPhase1Client.ts | 25 +- .../src/services/spectralArtifactsClient.ts | 39 +- apps/ui/src/types.ts | 1 - apps/ui/src/vite-env.d.ts | 3 + .../tests/services/analysisRunsClient.test.ts | 1 - apps/ui/tests/services/analyzer.test.ts | 1 - apps/ui/tests/services/config.test.ts | 83 +++- apps/ui/tests/services/diagnosticLogs.test.ts | 1 - .../services/spectralArtifactsClient.test.ts | 56 +++ apps/ui/tests/smoke/error-states.spec.ts | 1 - apps/ui/tests/smoke/file-validation.spec.ts | 2 - .../ui/tests/smoke/phase2-degradation.spec.ts | 2 - apps/ui/tests/smoke/responsive-layout.spec.ts | 2 - apps/ui/tests/smoke/ui-details.spec.ts | 4 - .../smoke/upload-estimate-phase1.spec.ts | 2 - .../ui/tests/smoke/upload-phase1-midi.spec.ts | 4 - apps/ui/tests/smoke/upload-phase1.spec.ts | 2 - docs/PUBLIC_HOSTING_FOUNDATION.md | 324 +++++++++++++++ 35 files changed, 1661 insertions(+), 226 deletions(-) create mode 100644 apps/backend/artifact_storage.py create mode 100644 apps/backend/auth_context.py create mode 100644 apps/backend/runtime_profile.py create mode 100644 apps/backend/tests/test_runtime_profile.py create mode 100644 apps/backend/worker.py create mode 100644 apps/ui/tests/services/spectralArtifactsClient.test.ts create mode 100644 docs/PUBLIC_HOSTING_FOUNDATION.md diff --git a/README.md b/README.md index b49e4b00..42d9490e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,19 @@ Migration note: - `GET /api/analysis-runs/{run_id}` - `GET /api/analysis-runs/{run_id}/artifacts...` +Runtime profiles: + +- `local`: current local/dev mode with SQLite + local artifact files + in-process workers. +- `hosted`: hosted-service mode with auth hooks and worker separation boundaries. + +In plain English: the analysis engine is still shared, but the repo now has an explicit split between local mode and hosted mode so public-hosting work does not have to change the local product path. + +Artifact storage now sits behind a backend storage service boundary. In plain English: ASA still writes files locally today, but the code is no longer hard-wired to assume that every stored artifact is just a disk path on the same machine. + +Implementation record: + +- see `docs/PUBLIC_HOSTING_FOUNDATION.md` for the full summary of the hosted-foundation work, the follow-up fixes, the verification that was run, and the remaining work before any true public deployment. + Legacy `POST /api/analyze`, `POST /api/analyze/estimate`, and `POST /api/phase2` remain available only as temporary compatibility wrappers during the migration window. ## Local Setup @@ -89,6 +102,12 @@ VITE_API_BASE_URL="http://127.0.0.1:8100" VITE_ENABLE_PHASE2_GEMINI="true" ``` +Optional hosted-mode request-header bootstrap for private beta testing: + +```bash +VITE_API_REQUEST_HEADERS_JSON='{"X-ASA-User-Id":"beta-user-123"}' +``` + Supported shell-based overrides: ```bash @@ -115,6 +134,13 @@ cd apps/backend SONIC_ANALYZER_PORT=8100 ./venv/bin/python server.py ``` +Hosted worker process: + +```bash +cd apps/backend +SONIC_ANALYZER_RUNTIME_PROFILE=hosted SONIC_ANALYZER_PROCESS_ROLE=worker ./venv/bin/python worker.py +``` + ```bash cd apps/ui VITE_API_BASE_URL=http://127.0.0.1:8100 npm run dev:local diff --git a/apps/backend/ARCHITECTURE.md b/apps/backend/ARCHITECTURE.md index 745af3e6..a983d03d 100644 --- a/apps/backend/ARCHITECTURE.md +++ b/apps/backend/ARCHITECTURE.md @@ -5,7 +5,12 @@ | Component | Role | | --- | --- | | `analyze.py` | Raw CLI analyzer. Loads audio, runs DSP, optionally separates stems and transcribes notes through torchcrepe, then prints JSON to `stdout`. | -| `server.py` | FastAPI wrapper. Accepts uploads, computes an estimate, shells out to `analyze.py`, normalizes the result into the HTTP `phase1` contract, and returns diagnostics or structured errors. | +| `server.py` | FastAPI wrapper. Accepts uploads, computes estimates, manages the canonical staged run API, normalizes measurement results, and serves artifact access. | +| `analysis_runtime.py` | Run-state persistence and staged-analysis orchestration. Owns run snapshots, stage status, artifact metadata, and ownership checks. | +| `artifact_storage.py` | Artifact storage boundary. The current implementation uses the local filesystem, but the runtime now talks to a storage service interface instead of assuming every artifact is a local disk path forever. | +| `runtime_profile.py` | Runtime/profile switchboard for `local` vs `hosted` behavior and `all` vs `api` vs `worker` process roles. | +| `auth_context.py` | Hosted-mode user-context resolution. Establishes the current run owner in the canonical API path. | +| `worker.py` | Dedicated worker-process entry point for hosted-style background stage execution. | | `tests/test_server.py` | Contract tests for estimate, timeout, and success envelopes. | | `spectral_viz.py` | Librosa-based spectrogram generation and spectral time-series extraction. Produces mel/chroma PNG spectrograms and per-frame spectral evolution JSON. Called after successful measurement; failures are non-critical. | | `tests/test_analyze.py` | Structural snapshot tests for the raw analyzer JSON output. | @@ -34,18 +39,20 @@ Interface: Responsibilities: - receive multipart uploads -- write the upload to a temporary file -- compute a backend estimate and timeout -- invoke `analyze.py` with `--yes` -- translate raw analyzer output into the HTTP `phase1` envelope -- return structured error diagnostics when the subprocess fails -- clean up temporary files +- write uploads to the runtime through the canonical run path +- compute backend estimates and timeouts +- invoke `analyze.py` with `--yes` through worker-owned stage execution +- translate raw analyzer output into the canonical measurement envelope +- enforce hosted-mode ownership on canonical run routes +- serve artifact metadata and artifact downloads without leaking internal paths +- return structured error diagnostics when subprocess execution fails Custom routes: - `POST /api/analysis-runs/estimate` - `POST /api/analysis-runs` - `GET /api/analysis-runs/{run_id}` +- `DELETE /api/analysis-runs/{run_id}` - `POST /api/analyze` (legacy compatibility) - `POST /api/analyze/estimate` (legacy compatibility) - `POST /api/phase2` (legacy compatibility) @@ -119,6 +126,15 @@ Important sections: 9. On success, normalize the raw payload into `phase1` and attach diagnostics. 10. Close the upload and delete the temporary file. +### Hosted foundation additions + +The backend now has an explicit local-versus-hosted runtime split. + +- `local` mode preserves the current local-first behavior. +- `hosted` mode enables hosted-only guardrails such as user ownership and API/worker separation. + +In plain English: the analysis engine is still the same, but the service wrapper around it can now behave like a hosted app without forcing the local app to work that way too. + ## HTTP Contract ### Shared Request Inputs diff --git a/apps/backend/README.md b/apps/backend/README.md index 6bfff4c8..74850fed 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -169,6 +169,21 @@ Override the port when needed: SONIC_ANALYZER_PORT=8456 ./venv/bin/python server.py ``` +Runtime profile and process role: + +- `SONIC_ANALYZER_RUNTIME_PROFILE=local` keeps the current local-first behavior. +- `SONIC_ANALYZER_RUNTIME_PROFILE=hosted` turns on hosted-only guardrails such as required user identity headers on the run APIs. +- `SONIC_ANALYZER_PROCESS_ROLE=all` is the local default and starts the API plus in-process workers together. +- `SONIC_ANALYZER_PROCESS_ROLE=api` is the hosted default and starts the API without in-process workers. +- `SONIC_ANALYZER_PROCESS_ROLE=worker` is reserved for dedicated worker processes. + +Hosted worker entry point: + +```bash +cd apps/backend +SONIC_ANALYZER_RUNTIME_PROFILE=hosted SONIC_ANALYZER_PROCESS_ROLE=worker ./venv/bin/python worker.py +``` + Current bind: - host: `0.0.0.0` @@ -183,6 +198,16 @@ Current CORS allow list: - `http://localhost:5173` - `http://127.0.0.1:5173` +Hosted auth hook: + +- In hosted mode, canonical run endpoints require `X-ASA-User-Id`. +- In plain English: the backend now expects the hosted platform to tell ASA which signed-in user owns the run before it will create, fetch, interrupt, or delete that run. + +Artifact storage boundary: + +- Run artifacts are now written through `artifact_storage.py` instead of being created inline directly from every runtime call site. +- In plain English: the backend still stores files on local disk today, but the read/write boundary is now isolated so hosted object storage can replace it later without rewriting every analysis path. + ## HTTP API ### `POST /api/analysis-runs/estimate` @@ -201,6 +226,7 @@ Multipart form fields: - `interpretation_mode` optional string - `interpretation_profile` optional string - `interpretation_model` optional string +- `X-ASA-User-Id` required in hosted mode Response shape: @@ -242,6 +268,7 @@ Multipart form fields: - `track` required file upload - `dsp_json_override` optional string, accepted but ignored - `transcribe` optional boolean-like form value; when true the server appends `--transcribe` +- `X-ASA-User-Id` required in hosted mode Query parameters: diff --git a/apps/backend/analysis_runtime.py b/apps/backend/analysis_runtime.py index 301ee1da..b51f159f 100644 --- a/apps/backend/analysis_runtime.py +++ b/apps/backend/analysis_runtime.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import sqlite3 from datetime import UTC, datetime @@ -8,8 +7,11 @@ from typing import Any from uuid import uuid4 +from artifact_storage import ArtifactStorage, FilesystemArtifactStorage + SQLITE_BUSY_TIMEOUT_MS = 5_000 MEASUREMENT_PIPELINE_PROGRESS_STATUSES = {"pending", "running", "completed"} +LOCAL_RUNTIME_OWNER_USER_ID = "local-dev" def _utc_now_iso() -> str: @@ -41,13 +43,19 @@ def __init__(self, pitch_note_backend: str): class AnalysisRuntime: - def __init__(self, runtime_dir: Path, max_pending_per_stage: int = 4): + def __init__( + self, + runtime_dir: Path, + max_pending_per_stage: int = 4, + artifact_storage: ArtifactStorage | None = None, + ): self.runtime_dir = Path(runtime_dir) self.max_pending_per_stage = max_pending_per_stage self.artifacts_dir = self.runtime_dir / "artifacts" self.db_path = self.runtime_dir / "analysis_runs.sqlite3" self.runtime_dir.mkdir(parents=True, exist_ok=True) self.artifacts_dir.mkdir(parents=True, exist_ok=True) + self.artifact_storage = artifact_storage or FilesystemArtifactStorage(self.artifacts_dir) self._ensure_schema() def _connect(self) -> sqlite3.Connection: @@ -139,6 +147,12 @@ def _ensure_schema(self) -> None: "requested_analysis_mode", "TEXT", ) + self._ensure_column( + conn, + "analysis_runs", + "owner_user_id", + "TEXT", + ) conn.execute( """ UPDATE analysis_runs @@ -146,6 +160,14 @@ def _ensure_schema(self) -> None: WHERE requested_analysis_mode IS NULL """ ) + conn.execute( + """ + UPDATE analysis_runs + SET owner_user_id = ? + WHERE owner_user_id IS NULL OR TRIM(owner_user_id) = '' + """, + (LOCAL_RUNTIME_OWNER_USER_ID,), + ) self._ensure_column( conn, "interpretation_attempts", @@ -180,6 +202,7 @@ def create_run( filename: str, content: bytes, mime_type: str, + owner_user_id: str = LOCAL_RUNTIME_OWNER_USER_ID, pitch_note_mode: str, pitch_note_backend: str, interpretation_mode: str, @@ -195,10 +218,11 @@ def create_run( run_id = str(uuid4()) artifact_id = str(uuid4()) created_at = _utc_now_iso() - content_sha256 = hashlib.sha256(content).hexdigest() - suffix = Path(filename).suffix or ".bin" - artifact_path = self.artifacts_dir / f"{artifact_id}{suffix}" - artifact_path.write_bytes(content) + stored_artifact = self.artifact_storage.store_bytes( + artifact_id=artifact_id, + filename=filename, + content=content, + ) with self._connect() as conn: conn.execute( @@ -207,6 +231,7 @@ def create_run( id, source_artifact_id, requested_analysis_mode, + owner_user_id, requested_pitch_note_mode, requested_pitch_note_backend, requested_interpretation_mode, @@ -215,12 +240,13 @@ def create_run( legacy_request_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( run_id, artifact_id, resolved_analysis_mode, + owner_user_id or LOCAL_RUNTIME_OWNER_USER_ID, pitch_note_mode, pitch_note_backend, interpretation_mode, @@ -252,9 +278,9 @@ def create_run( "source_audio", filename, mime_type, - len(content), - content_sha256, - str(artifact_path), + stored_artifact.size_bytes, + stored_artifact.content_sha256, + stored_artifact.storage_ref, None, created_at, ), @@ -288,26 +314,78 @@ def create_run( return {"runId": run_id} - def get_run_by_legacy_request_id(self, legacy_request_id: str) -> dict[str, Any]: + def get_run_by_legacy_request_id( + self, + legacy_request_id: str, + *, + owner_user_id: str | None = None, + ) -> dict[str, Any]: with self._connect() as conn: row = conn.execute( - "SELECT id FROM analysis_runs WHERE legacy_request_id = ?", + "SELECT id, owner_user_id FROM analysis_runs WHERE legacy_request_id = ?", (legacy_request_id,), ).fetchone() if row is None: raise KeyError(f"Unknown legacy request {legacy_request_id}") - return self.get_run(row["id"]) + self._assert_run_owner(row, owner_user_id) + return self.get_run(row["id"], owner_user_id=owner_user_id) - def get_run_id_by_legacy_request_id(self, legacy_request_id: str) -> str: + def get_run_id_by_legacy_request_id( + self, + legacy_request_id: str, + *, + owner_user_id: str | None = None, + ) -> str: with self._connect() as conn: row = conn.execute( - "SELECT id FROM analysis_runs WHERE legacy_request_id = ?", + "SELECT id, owner_user_id FROM analysis_runs WHERE legacy_request_id = ?", (legacy_request_id,), ).fetchone() if row is None: raise KeyError(f"Unknown legacy request {legacy_request_id}") + self._assert_run_owner(row, owner_user_id) return str(row["id"]) + @staticmethod + def _public_artifact_record(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + return { + "artifactId": row["id"] if isinstance(row, sqlite3.Row) else row["artifactId"], + "filename": row["filename"] if isinstance(row, sqlite3.Row) else row["filename"], + "mimeType": row["mime_type"] if isinstance(row, sqlite3.Row) else row["mimeType"], + "sizeBytes": row["size_bytes"] if isinstance(row, sqlite3.Row) else row["sizeBytes"], + "contentSha256": ( + row["content_sha256"] if isinstance(row, sqlite3.Row) else row["contentSha256"] + ), + **( + {"kind": row["kind"] if isinstance(row, sqlite3.Row) else row["kind"]} + if (row["kind"] if isinstance(row, sqlite3.Row) else row.get("kind")) is not None + else {} + ), + } + + @staticmethod + def _internal_artifact_record(row: sqlite3.Row) -> dict[str, Any]: + return { + "artifactId": row["id"], + "kind": row["kind"], + "filename": row["filename"], + "mimeType": row["mime_type"], + "sizeBytes": row["size_bytes"], + "contentSha256": row["content_sha256"], + "path": row["path"], + "provenance": _json_loads(row["provenance_json"]), + } + + @staticmethod + def _assert_run_owner(row: sqlite3.Row, owner_user_id: str | None) -> None: + if owner_user_id is None: + return + stored_owner = str(row["owner_user_id"] or LOCAL_RUNTIME_OWNER_USER_ID) + if stored_owner != owner_user_id: + raise PermissionError( + f"Run '{row['id']}' does not belong to user '{owner_user_id}'." + ) + def get_source_artifact(self, run_id: str) -> dict[str, Any]: with self._connect() as conn: run_row = conn.execute( @@ -352,6 +430,17 @@ def get_measurement_status(self, run_id: str) -> str: raise KeyError(f"Unknown run {run_id}") return str(row["status"]) + def resolve_artifact_local_path(self, storage_ref: str | None) -> Path | None: + if not isinstance(storage_ref, str) or not storage_ref: + return None + return self.artifact_storage.resolve_local_path(storage_ref) + + def require_local_artifact_path(self, storage_ref: str | None, *, purpose: str) -> str: + local_path = self.resolve_artifact_local_path(storage_ref) + if local_path is None or not local_path.is_file(): + raise FileNotFoundError(f"{purpose} is not available as a local file.") + return str(local_path) + def is_run_interrupted(self, run_id: str) -> bool: return self.get_measurement_status(run_id) == "interrupted" @@ -393,13 +482,14 @@ def get_interpretation_grounding(self, run_id: str) -> dict[str, Any]: ), } - def get_run(self, run_id: str) -> dict[str, Any]: + def get_run(self, run_id: str, *, owner_user_id: str | None = None) -> dict[str, Any]: with self._connect() as conn: run_row = conn.execute( "SELECT * FROM analysis_runs WHERE id = ?", (run_id,) ).fetchone() if run_row is None: raise KeyError(f"Unknown run {run_id}") + self._assert_run_owner(run_row, owner_user_id) artifact_row = conn.execute( "SELECT * FROM run_artifacts WHERE id = ?", (run_row["source_artifact_id"],) @@ -455,20 +545,8 @@ def get_run(self, run_id: str) -> dict[str, Any]: "mimeType": artifact_row["mime_type"], "sizeBytes": artifact_row["size_bytes"], "contentSha256": artifact_row["content_sha256"], - "path": artifact_row["path"], }, - "stems": [ - { - "artifactId": row["id"], - "kind": row["kind"], - "filename": row["filename"], - "mimeType": row["mime_type"], - "sizeBytes": row["size_bytes"], - "contentSha256": row["content_sha256"], - "path": row["path"], - } - for row in stem_rows - ], + "stems": [self._public_artifact_record(row) for row in stem_rows], }, "stages": { "measurement": { @@ -544,6 +622,16 @@ def reserve_next_measurement_run(self) -> dict[str, Any] | None: "requestedPitchNoteBackend": row["requested_pitch_note_backend"], } + def get_run_owner_user_id(self, run_id: str) -> str: + with self._connect() as conn: + row = conn.execute( + "SELECT owner_user_id FROM analysis_runs WHERE id = ?", + (run_id,), + ).fetchone() + if row is None: + raise KeyError(f"Unknown run {run_id}") + return str(row["owner_user_id"] or LOCAL_RUNTIME_OWNER_USER_ID) + def complete_measurement( self, run_id: str, @@ -927,19 +1015,11 @@ def record_artifact( ) -> dict[str, Any]: artifact_id = str(uuid4()) created_at = _utc_now_iso() - source = Path(source_path) - suffix = source.suffix or Path(filename).suffix or ".bin" - destination = self.artifacts_dir / f"{artifact_id}{suffix}" - digest = hashlib.sha256() - size_bytes = 0 - with source.open("rb") as src, destination.open("wb") as dest: - while True: - chunk = src.read(1024 * 1024) - if not chunk: - break - dest.write(chunk) - digest.update(chunk) - size_bytes += len(chunk) + stored_artifact = self.artifact_storage.store_file( + artifact_id=artifact_id, + filename=filename, + source_path=source_path, + ) with self._connect() as conn: conn.execute( """ @@ -962,23 +1042,29 @@ def record_artifact( kind, filename, mime_type, - size_bytes, - digest.hexdigest(), - str(destination), + stored_artifact.size_bytes, + stored_artifact.content_sha256, + stored_artifact.storage_ref, _json_dumps(provenance), created_at, ), ) return { "artifactId": artifact_id, - "path": str(destination), + "path": stored_artifact.storage_ref, "kind": kind, "filename": filename, "mimeType": mime_type, - "sizeBytes": size_bytes, + "sizeBytes": stored_artifact.size_bytes, } def get_artifacts_by_kind(self, run_id: str, kind_prefix: str) -> list[dict[str, Any]]: + return [ + self._public_artifact_record(record) + for record in self.get_internal_artifacts_by_kind(run_id, kind_prefix) + ] + + def get_internal_artifacts_by_kind(self, run_id: str, kind_prefix: str) -> list[dict[str, Any]]: with self._connect() as conn: rows = conn.execute( """ @@ -988,19 +1074,15 @@ def get_artifacts_by_kind(self, run_id: str, kind_prefix: str) -> list[dict[str, """, (run_id, f"{kind_prefix}%"), ).fetchall() - return [ - { - "artifactId": row["id"], - "kind": row["kind"], - "filename": row["filename"], - "mimeType": row["mime_type"], - "sizeBytes": row["size_bytes"], - "contentSha256": row["content_sha256"], - "path": row["path"], - "provenance": _json_loads(row["provenance_json"]), - } - for row in rows + return [self._internal_artifact_record(row) for row in rows] + + def get_internal_artifact(self, run_id: str, artifact_id: str) -> dict[str, Any] | None: + matches = [ + artifact + for artifact in self.get_internal_artifacts_by_kind(run_id, "") + if artifact["artifactId"] == artifact_id ] + return matches[0] if matches else None def recover_incomplete_attempts(self) -> None: now = _utc_now_iso() @@ -1101,13 +1183,34 @@ def interrupt_run(self, run_id: str) -> dict[str, Any]: for row in artifact_rows: artifact_path = row["path"] if isinstance(artifact_path, str) and artifact_path: - try: - Path(artifact_path).unlink(missing_ok=True) - except OSError: - pass + self.artifact_storage.delete(artifact_path) return self.get_run(run_id) + def delete_run(self, run_id: str) -> None: + with self._connect() as conn: + run_row = conn.execute( + "SELECT id FROM analysis_runs WHERE id = ?", + (run_id,), + ).fetchone() + if run_row is None: + raise KeyError(f"Unknown run {run_id}") + + artifact_rows = conn.execute( + "SELECT path FROM run_artifacts WHERE run_id = ?", + (run_id,), + ).fetchall() + conn.execute("DELETE FROM interpretation_attempts WHERE run_id = ?", (run_id,)) + conn.execute("DELETE FROM pitch_note_translation_attempts WHERE run_id = ?", (run_id,)) + conn.execute("DELETE FROM measurement_outputs WHERE run_id = ?", (run_id,)) + conn.execute("DELETE FROM run_artifacts WHERE run_id = ?", (run_id,)) + conn.execute("DELETE FROM analysis_runs WHERE id = ?", (run_id,)) + + for row in artifact_rows: + artifact_path = row["path"] + if isinstance(artifact_path, str) and artifact_path: + self.artifact_storage.delete(artifact_path) + def update_measurement_progress( self, run_id: str, diff --git a/apps/backend/artifact_storage.py b/apps/backend/artifact_storage.py new file mode 100644 index 00000000..c8e271cd --- /dev/null +++ b/apps/backend/artifact_storage.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + + +@dataclass(frozen=True) +class StoredArtifact: + storage_ref: str + size_bytes: int + content_sha256: str + + +class ArtifactStorage(Protocol): + def store_bytes( + self, + *, + artifact_id: str, + filename: str, + content: bytes, + ) -> StoredArtifact: ... + + def store_file( + self, + *, + artifact_id: str, + filename: str, + source_path: str, + ) -> StoredArtifact: ... + + def delete(self, storage_ref: str) -> None: ... + + def resolve_local_path(self, storage_ref: str) -> Path | None: ... + + +class FilesystemArtifactStorage: + def __init__(self, artifacts_dir: Path): + self.artifacts_dir = Path(artifacts_dir) + self.artifacts_dir.mkdir(parents=True, exist_ok=True) + + def store_bytes( + self, + *, + artifact_id: str, + filename: str, + content: bytes, + ) -> StoredArtifact: + destination = self._destination_for(artifact_id, filename) + destination.write_bytes(content) + return StoredArtifact( + storage_ref=str(destination), + size_bytes=len(content), + content_sha256=hashlib.sha256(content).hexdigest(), + ) + + def store_file( + self, + *, + artifact_id: str, + filename: str, + source_path: str, + ) -> StoredArtifact: + source = Path(source_path) + destination = self._destination_for(artifact_id, filename, source_path=source_path) + digest = hashlib.sha256() + size_bytes = 0 + with source.open("rb") as src, destination.open("wb") as dest: + while True: + chunk = src.read(1024 * 1024) + if not chunk: + break + dest.write(chunk) + digest.update(chunk) + size_bytes += len(chunk) + return StoredArtifact( + storage_ref=str(destination), + size_bytes=size_bytes, + content_sha256=digest.hexdigest(), + ) + + def delete(self, storage_ref: str) -> None: + if not storage_ref: + return + try: + Path(storage_ref).unlink(missing_ok=True) + except OSError: + pass + + def resolve_local_path(self, storage_ref: str) -> Path | None: + if not storage_ref: + return None + return Path(storage_ref) + + def _destination_for( + self, + artifact_id: str, + filename: str, + *, + source_path: str | None = None, + ) -> Path: + suffix = ( + Path(filename).suffix + or (Path(source_path).suffix if source_path else "") + or ".bin" + ) + return self.artifacts_dir / f"{artifact_id}{suffix}" diff --git a/apps/backend/auth_context.py b/apps/backend/auth_context.py new file mode 100644 index 00000000..bde2f4e0 --- /dev/null +++ b/apps/backend/auth_context.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from runtime_profile import resolve_runtime_profile, should_require_authenticated_user + +LOCAL_DEV_USER_ID = "local-dev" + + +class AuthenticationRequiredError(PermissionError): + pass + + +@dataclass(frozen=True) +class UserContext: + user_id: str + email: str | None + runtime_profile: str + + +def resolve_api_user_context( + header_user_id: str | None, + header_user_email: str | None, +) -> UserContext: + runtime_profile = resolve_runtime_profile() + if not should_require_authenticated_user(runtime_profile): + return UserContext( + user_id=LOCAL_DEV_USER_ID, + email=header_user_email.strip() if isinstance(header_user_email, str) and header_user_email.strip() else None, + runtime_profile=runtime_profile, + ) + + user_id = header_user_id.strip() if isinstance(header_user_id, str) else "" + if not user_id: + raise AuthenticationRequiredError( + "Hosted runtime requests must include the X-ASA-User-Id header." + ) + + email = header_user_email.strip() if isinstance(header_user_email, str) else "" + return UserContext( + user_id=user_id, + email=email or None, + runtime_profile=runtime_profile, + ) diff --git a/apps/backend/runtime_profile.py b/apps/backend/runtime_profile.py new file mode 100644 index 00000000..caea5373 --- /dev/null +++ b/apps/backend/runtime_profile.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import os +from typing import Literal + +RuntimeProfile = Literal["local", "hosted"] +ProcessRole = Literal["all", "api", "worker"] + +DEFAULT_RUNTIME_PROFILE: RuntimeProfile = "local" +DEFAULT_LOCAL_PROCESS_ROLE: ProcessRole = "all" +DEFAULT_HOSTED_PROCESS_ROLE: ProcessRole = "api" + + +def resolve_runtime_profile(raw_value: str | None = None) -> RuntimeProfile: + value = (raw_value or os.getenv("SONIC_ANALYZER_RUNTIME_PROFILE", "")).strip().lower() + if value in {"", "local"}: + return "local" + if value == "hosted": + return "hosted" + return DEFAULT_RUNTIME_PROFILE + + +def resolve_process_role( + raw_value: str | None = None, + *, + runtime_profile: RuntimeProfile | None = None, +) -> ProcessRole: + profile = runtime_profile or resolve_runtime_profile() + default_role = ( + DEFAULT_LOCAL_PROCESS_ROLE if profile == "local" else DEFAULT_HOSTED_PROCESS_ROLE + ) + value = (raw_value or os.getenv("SONIC_ANALYZER_PROCESS_ROLE", "")).strip().lower() + if value in {"all", "api", "worker"}: + return value # type: ignore[return-value] + return default_role + + +def should_require_authenticated_user(runtime_profile: RuntimeProfile | None = None) -> bool: + return (runtime_profile or resolve_runtime_profile()) == "hosted" + + +def should_start_in_process_workers( + runtime_profile: RuntimeProfile | None = None, + process_role: ProcessRole | None = None, +) -> bool: + profile = runtime_profile or resolve_runtime_profile() + role = process_role or resolve_process_role(runtime_profile=profile) + if profile == "local": + return role == "all" + return role == "worker" + + +def should_recover_incomplete_attempts( + runtime_profile: RuntimeProfile | None = None, + process_role: ProcessRole | None = None, +) -> bool: + profile = runtime_profile or resolve_runtime_profile() + role = process_role or resolve_process_role(runtime_profile=profile) + if profile == "local": + return role == "all" + return role == "worker" diff --git a/apps/backend/server.py b/apps/backend/server.py index 9825bbb8..68476d78 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -26,10 +26,11 @@ _genai_types = None # type: ignore[assignment] _GENAI_AVAILABLE = False -from fastapi import FastAPI, File, Form, Query, UploadFile +from fastapi import FastAPI, File, Form, Header, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse +from auth_context import AuthenticationRequiredError, UserContext, resolve_api_user_context from analysis_runtime import ( AnalysisRuntime, UnsupportedPitchNoteBackendError, @@ -39,6 +40,12 @@ build_analysis_estimate, get_audio_duration_seconds, ) +from runtime_profile import ( + resolve_process_role, + resolve_runtime_profile, + should_recover_incomplete_attempts, + should_start_in_process_workers, +) from utils.cleanup import cleanup_artifacts @@ -706,10 +713,38 @@ def _evict_expired_cache_entries() -> None: _cleanup_temp_path(path) +async def _evict_loop() -> None: + while True: + await asyncio.sleep(300) + _evict_expired_cache_entries() + + +def _create_background_tasks( + *, + include_cache_eviction: bool, + include_workers: bool, +) -> list[asyncio.Task[Any]]: + tasks: list[asyncio.Task[Any]] = [] + if include_cache_eviction: + tasks.append(asyncio.create_task(_evict_loop())) + if include_workers: + tasks.extend( + [ + asyncio.create_task(_measurement_worker_loop()), + asyncio.create_task(_pitch_note_worker_loop()), + asyncio.create_task(_interpretation_worker_loop()), + ] + ) + return tasks + + @app.on_event("startup") async def _start_cache_eviction() -> None: runtime = get_analysis_runtime() - runtime.recover_incomplete_attempts() + runtime_profile = resolve_runtime_profile() + process_role = resolve_runtime_process_role(runtime_profile=runtime_profile) + if should_recover_incomplete_attempts(runtime_profile, process_role): + runtime.recover_incomplete_attempts() def _run_artifact_cleanup() -> None: try: @@ -723,19 +758,15 @@ def _run_artifact_cleanup() -> None: daemon=True, ).start() - async def _evict_loop() -> None: - while True: - await asyncio.sleep(300) - _evict_expired_cache_entries() - if not _BACKGROUND_TASKS: _BACKGROUND_TASKS.extend( - [ - asyncio.create_task(_evict_loop()), - asyncio.create_task(_measurement_worker_loop()), - asyncio.create_task(_pitch_note_worker_loop()), - asyncio.create_task(_interpretation_worker_loop()), - ] + _create_background_tasks( + include_cache_eviction=True, + include_workers=should_start_in_process_workers( + runtime_profile, + process_role, + ), + ) ) @@ -814,6 +845,40 @@ def get_analysis_runtime() -> AnalysisRuntime: return _ANALYSIS_RUNTIME +def resolve_runtime_process_role(*, runtime_profile: str | None = None) -> str: + return resolve_process_role(runtime_profile=runtime_profile) + + +def _resolve_route_user_context( + x_asa_user_id: str | None, + x_asa_user_email: str | None, +) -> UserContext | JSONResponse: + try: + return resolve_api_user_context(x_asa_user_id, x_asa_user_email) + except AuthenticationRequiredError as exc: + return JSONResponse( + status_code=401, + content={ + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": str(exc), + } + }, + ) + + +def _run_not_found_response(run_id: str) -> JSONResponse: + return JSONResponse( + status_code=404, + content={ + "error": { + "code": "RUN_NOT_FOUND", + "message": f"Analysis run '{run_id}' was not found.", + } + }, + ) + + def resolve_server_port() -> int: raw_value = os.getenv("SONIC_ANALYZER_PORT", str(DEFAULT_SERVER_PORT)).strip() try: @@ -1130,6 +1195,7 @@ def _run_streamed_subprocess( async def _create_analysis_run_record( *, track: UploadFile, + owner_user_id: str, analysis_mode: str, pitch_note_mode: str, pitch_note_backend: str, @@ -1147,6 +1213,7 @@ async def _create_analysis_run_record( filename=track.filename or "upload.bin", content=content, mime_type=track.content_type or _get_audio_mime_type(track.filename or "upload.bin"), + owner_user_id=owner_user_id, analysis_mode=analysis_mode, pitch_note_mode=pitch_note_mode, pitch_note_backend=pitch_note_backend, @@ -1574,8 +1641,12 @@ def _generate_spectral_artifacts(runtime: AnalysisRuntime, run_id: str) -> None: from spectral_viz import generate_all_artifacts source = runtime.get_source_artifact(run_id) + source_local_path = runtime.require_local_artifact_path( + source.get("path"), + purpose="Source audio artifact for spectral generation", + ) with tempfile.TemporaryDirectory(prefix="spectral_viz_") as tmp_dir: - artifacts = generate_all_artifacts(source["path"], tmp_dir) + artifacts = generate_all_artifacts(source_local_path, tmp_dir) _MIME_TYPES = { "spectrogram_mel": "image/png", "spectral_time_series": "application/json", @@ -1611,10 +1682,14 @@ def _execute_measurement_run( run_fast: bool, ) -> dict[str, Any]: source_artifact = runtime.get_source_artifact(run_id) + source_local_path = runtime.require_local_artifact_path( + source_artifact.get("path"), + purpose="Source audio artifact for measurement", + ) execution = _run_measurement_subprocess( runtime=runtime, run_id=run_id, - audio_path=source_artifact["path"], + audio_path=source_local_path, file_size_bytes=source_artifact["sizeBytes"], request_id=request_id, request_started_at=_current_time(), @@ -1732,10 +1807,14 @@ def _execute_pitch_note_attempt( } stem_output_dir: str | None = None try: + source_local_path = runtime.require_local_artifact_path( + source_artifact.get("path"), + purpose="Source audio artifact for pitch/note translation", + ) # Build the subprocess command command = [ "./venv/bin/python", "analyze.py", - source_artifact["path"], + source_local_path, "--pitch-note-only", "--pitch-note-backend", attempt["backendId"], @@ -1746,12 +1825,12 @@ def _execute_pitch_note_attempt( # so the subprocess skips Demucs separation stem_dir = None if attempt["mode"] == "stem_notes": - existing = runtime.get_artifacts_by_kind(run_id, "stem_") + existing = runtime.get_internal_artifacts_by_kind(run_id, "stem_") stem_paths_map = { - artifact["kind"].removeprefix("stem_"): artifact["path"] + artifact["kind"].removeprefix("stem_"): str(local_path) for artifact in existing - if isinstance(artifact.get("path"), str) - and os.path.isfile(artifact["path"]) + if (local_path := runtime.resolve_artifact_local_path(artifact.get("path"))) is not None + and local_path.is_file() } if "bass" in stem_paths_map or "other" in stem_paths_map: # Find the common parent directory of existing stems @@ -1927,13 +2006,13 @@ def _run_combined_stem_summary_request( model_name: str, request_id: str, ) -> dict[str, Any]: - stem_artifacts = runtime.get_artifacts_by_kind(run_id, "stem_") + stem_artifacts = runtime.get_internal_artifacts_by_kind(run_id, "stem_") usable_stems = [ artifact for artifact in stem_artifacts if artifact.get("kind") in {"stem_bass", "stem_other"} - and isinstance(artifact.get("path"), str) - and os.path.isfile(str(artifact["path"])) + and (local_path := runtime.resolve_artifact_local_path(artifact.get("path"))) is not None + and local_path.is_file() ] usable_stems.sort(key=lambda artifact: artifact["kind"]) if not usable_stems: @@ -1950,13 +2029,17 @@ def _run_combined_stem_summary_request( for artifact in usable_stems: stem_kind = str(artifact["kind"]).removeprefix("stem_") + stem_local_path = runtime.require_local_artifact_path( + artifact.get("path"), + purpose=f"{stem_kind} stem artifact for interpretation", + ) stem_grounding_metadata = { **grounding_metadata, "stemKind": stem_kind, "stemArtifactId": artifact["artifactId"], } execution = _run_interpretation_request_with_profile_config( - source_path=str(artifact["path"]), + source_path=stem_local_path, filename=str(artifact["filename"]), file_size_bytes=int(artifact["sizeBytes"]), profile_id="stem_summary", @@ -2244,8 +2327,12 @@ def _execute_interpretation_attempt( ) else: source_artifact = runtime.get_source_artifact(run_id) + source_local_path = runtime.require_local_artifact_path( + source_artifact.get("path"), + purpose="Source audio artifact for interpretation", + ) execution = _run_interpretation_request( - source_path=source_artifact["path"], + source_path=source_local_path, filename=source_artifact["filename"], file_size_bytes=source_artifact["sizeBytes"], profile_id=profile_id, @@ -2310,12 +2397,16 @@ def _resolve_phase2_run_id( *, analysis_run_id: str | None, phase1_request_id: str | None, + owner_user_id: str | None = None, ) -> str: if analysis_run_id: - runtime.get_run(analysis_run_id) + runtime.get_run(analysis_run_id, owner_user_id=owner_user_id) return analysis_run_id if phase1_request_id: - return runtime.get_run_id_by_legacy_request_id(phase1_request_id) + return runtime.get_run_id_by_legacy_request_id( + phase1_request_id, + owner_user_id=owner_user_id, + ) raise KeyError("Missing analysis context") @@ -2385,10 +2476,17 @@ async def create_analysis_run( interpretation_mode: str = Form("off"), interpretation_profile: str = Form("producer_summary"), interpretation_model: str | None = Form(None), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + await track.close() + return user_context try: runtime, run_id = await _create_analysis_run_record( track=track, + owner_user_id=user_context.user_id, analysis_mode=analysis_mode, pitch_note_mode=pitch_note_mode, pitch_note_backend=pitch_note_backend, @@ -2396,7 +2494,12 @@ async def create_analysis_run( interpretation_profile=interpretation_profile, interpretation_model=interpretation_model, ) - return JSONResponse(content=_normalize_run_snapshot(runtime.get_run(run_id), runtime)) + return JSONResponse( + content=_normalize_run_snapshot( + runtime.get_run(run_id, owner_user_id=user_context.user_id), + runtime, + ) + ) except UnsupportedPitchNoteBackendError as exc: return JSONResponse( status_code=400, @@ -2440,7 +2543,13 @@ async def estimate_analysis_run( interpretation_mode: str = Form("off"), interpretation_profile: str = Form("producer_summary"), interpretation_model: str | None = Form(None), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + await track.close() + return user_context try: return await _estimate_analysis_run( track=track, @@ -2484,38 +2593,42 @@ async def estimate_analysis_run( @app.get("/api/analysis-runs/{run_id}") -async def get_analysis_run(run_id: str) -> JSONResponse: +async def get_analysis_run( + run_id: str, + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), +) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context runtime = get_analysis_runtime() try: - return JSONResponse(content=_normalize_run_snapshot(runtime.get_run(run_id), runtime)) - except KeyError: return JSONResponse( - status_code=404, - content={ - "error": { - "code": "RUN_NOT_FOUND", - "message": f"Analysis run '{run_id}' was not found.", - } - }, + content=_normalize_run_snapshot( + runtime.get_run(run_id, owner_user_id=user_context.user_id), + runtime, + ) ) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) @app.post("/api/analysis-runs/{run_id}/interrupt") -async def interrupt_analysis_run(run_id: str) -> JSONResponse: +async def interrupt_analysis_run( + run_id: str, + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), +) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context runtime = get_analysis_runtime() try: + runtime.get_run(run_id, owner_user_id=user_context.user_id) terminated_stages = _interrupt_active_child_processes(run_id) snapshot = runtime.interrupt_run(run_id) - except KeyError: - return JSONResponse( - status_code=404, - content={ - "error": { - "code": "RUN_NOT_FOUND", - "message": f"Analysis run '{run_id}' was not found.", - } - }, - ) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) payload = _normalize_run_snapshot(snapshot, runtime) payload["interrupt"] = { @@ -2524,46 +2637,70 @@ async def interrupt_analysis_run(run_id: str) -> JSONResponse: return JSONResponse(status_code=202, content=payload) +@app.delete("/api/analysis-runs/{run_id}") +async def delete_analysis_run( + run_id: str, + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), +) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context + runtime = get_analysis_runtime() + try: + runtime.get_run(run_id, owner_user_id=user_context.user_id) + terminated_stages = _interrupt_active_child_processes(run_id) + runtime.delete_run(run_id) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) + return JSONResponse( + status_code=202, + content={ + "runId": run_id, + "deleted": True, + "interrupt": { + "stagesTerminated": terminated_stages, + }, + }, + ) + + @app.get("/api/analysis-runs/{run_id}/artifacts") async def list_run_artifacts( run_id: str, kind: str = Query("", description="Filter by kind prefix"), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context runtime = get_analysis_runtime() try: - runtime.get_run(run_id) - except KeyError: - return JSONResponse( - status_code=404, - content={ - "error": { - "code": "RUN_NOT_FOUND", - "message": f"Analysis run '{run_id}' was not found.", - } - }, - ) + runtime.get_run(run_id, owner_user_id=user_context.user_id) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) prefix = kind if kind else "" artifacts = runtime.get_artifacts_by_kind(run_id, prefix) return JSONResponse(content=artifacts) @app.get("/api/analysis-runs/{run_id}/artifacts/{artifact_id}", response_model=None) -async def get_run_artifact(run_id: str, artifact_id: str) -> FileResponse | JSONResponse: +async def get_run_artifact( + run_id: str, + artifact_id: str, + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), +) -> FileResponse | JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context runtime = get_analysis_runtime() try: - runtime.get_run(run_id) - except KeyError: - return JSONResponse( - status_code=404, - content={ - "error": { - "code": "RUN_NOT_FOUND", - "message": f"Analysis run '{run_id}' was not found.", - } - }, - ) - artifacts = runtime.get_artifacts_by_kind(run_id, "") - match = next((a for a in artifacts if a["artifactId"] == artifact_id), None) + runtime.get_run(run_id, owner_user_id=user_context.user_id) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) + match = runtime.get_internal_artifact(run_id, artifact_id) if match is None: return JSONResponse( status_code=404, @@ -2574,8 +2711,8 @@ async def get_run_artifact(run_id: str, artifact_id: str) -> FileResponse | JSON } }, ) - artifact_path = match.get("path", "") - if not artifact_path or not Path(artifact_path).is_file(): + artifact_local_path = runtime.resolve_artifact_local_path(match.get("path")) + if artifact_local_path is None or not artifact_local_path.is_file(): return JSONResponse( status_code=404, content={ @@ -2586,9 +2723,9 @@ async def get_run_artifact(run_id: str, artifact_id: str) -> FileResponse | JSON }, ) return FileResponse( - path=artifact_path, + path=str(artifact_local_path), media_type=match.get("mimeType", "application/octet-stream"), - filename=match.get("filename", os.path.basename(artifact_path)), + filename=match.get("filename", artifact_local_path.name), ) @@ -2601,21 +2738,26 @@ async def get_run_artifact(run_id: str, artifact_id: str) -> FileResponse | JSON @app.post("/api/analysis-runs/{run_id}/spectral-enhancements/{kind}") -async def generate_spectral_enhancement(run_id: str, kind: str) -> JSONResponse: +async def generate_spectral_enhancement( + run_id: str, + kind: str, + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), +) -> JSONResponse: if kind not in _ENHANCEMENT_GENERATORS: return JSONResponse( status_code=400, content={"error": {"code": "INVALID_KIND", "message": f"Unknown enhancement kind: '{kind}'. Valid: {', '.join(_ENHANCEMENT_GENERATORS)}"}}, ) + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context runtime = get_analysis_runtime() try: - run = runtime.get_run(run_id) - except KeyError: - return JSONResponse( - status_code=404, - content={"error": {"code": "RUN_NOT_FOUND", "message": f"Analysis run '{run_id}' was not found."}}, - ) + run = runtime.get_run(run_id, owner_user_id=user_context.user_id) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) stages = run.get("stages", {}) meas_status = stages.get("measurement", {}).get("status") @@ -2630,7 +2772,7 @@ async def generate_spectral_enhancement(run_id: str, kind: str) -> JSONResponse: # Idempotent: skip if already generated existing = [] for ak in artifact_kinds: - existing.extend(runtime.get_artifacts_by_kind(run_id, ak)) + existing.extend(runtime.get_internal_artifacts_by_kind(run_id, ak)) if existing: strip = lambda a: {"artifactId": a["artifactId"], "kind": a["kind"], "filename": a["filename"], "mimeType": a["mimeType"], "sizeBytes": a["sizeBytes"]} return JSONResponse(content={"artifacts": [strip(a) for a in existing]}) @@ -2651,10 +2793,14 @@ async def generate_spectral_enhancement(run_id: str, kind: str) -> JSONResponse: } with tempfile.TemporaryDirectory(prefix="spectral_enh_") as tmp_dir: + source_local_path = runtime.require_local_artifact_path( + source.get("path"), + purpose="Source audio artifact for spectral enhancement generation", + ) if is_image: - result = gen_func(source["path"], tmp_dir) + result = gen_func(source_local_path, tmp_dir) else: - data = gen_func(source["path"]) + data = gen_func(source_local_path) # Write JSON to file for artifact storage import json as json_mod for ak in artifact_kinds: @@ -2693,9 +2839,15 @@ async def create_pitch_note_translation_attempt( run_id: str, pitch_note_mode: str = Form("stem_notes"), pitch_note_backend: str = Form("auto"), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context runtime = get_analysis_runtime() try: + runtime.get_run(run_id, owner_user_id=user_context.user_id) resolved_backend = runtime._resolve_pitch_note_backend(pitch_note_backend) if runtime.get_measurement_status(run_id) != "completed": return JSONResponse( @@ -2719,17 +2871,15 @@ async def create_pitch_note_translation_attempt( "requestedViaApi": True, }, ) - return JSONResponse(status_code=202, content=_normalize_run_snapshot(runtime.get_run(run_id), runtime)) - except KeyError: return JSONResponse( - status_code=404, - content={ - "error": { - "code": "RUN_NOT_FOUND", - "message": f"Analysis run '{run_id}' was not found.", - } - }, + status_code=202, + content=_normalize_run_snapshot( + runtime.get_run(run_id, owner_user_id=user_context.user_id), + runtime, + ), ) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) except UnsupportedPitchNoteBackendError as exc: return JSONResponse( status_code=400, @@ -2747,9 +2897,15 @@ async def create_interpretation_attempt( run_id: str, interpretation_profile: str = Form("producer_summary"), interpretation_model: str = Form("gemini-2.5-flash"), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ) -> JSONResponse: + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context runtime = get_analysis_runtime() try: + runtime.get_run(run_id, owner_user_id=user_context.user_id) _resolve_interpretation_profile_config(interpretation_profile) if runtime.get_measurement_status(run_id) != "completed": return JSONResponse( @@ -2773,7 +2929,13 @@ async def create_interpretation_attempt( "requestedViaApi": True, }, ) - return JSONResponse(status_code=202, content=_normalize_run_snapshot(runtime.get_run(run_id), runtime)) + return JSONResponse( + status_code=202, + content=_normalize_run_snapshot( + runtime.get_run(run_id, owner_user_id=user_context.user_id), + runtime, + ), + ) except ValueError as exc: return JSONResponse( status_code=400, @@ -2784,16 +2946,8 @@ async def create_interpretation_attempt( } }, ) - except KeyError: - return JSONResponse( - status_code=404, - content={ - "error": { - "code": "RUN_NOT_FOUND", - "message": f"Analysis run '{run_id}' was not found.", - } - }, - ) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) def _safe_snippet(value: Any) -> str | None: @@ -4779,8 +4933,14 @@ async def estimate_analysis( alias="--separate", description="Alias for separate; accepts query key --separate", ), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ): logger.warning("Legacy compatibility endpoint hit: /api/analyze/estimate") + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + await track.close() + return _mark_legacy_endpoint_response(user_context, endpoint="/api/analyze/estimate") _ = dsp_json_override response = await _estimate_analysis_run( track=track, @@ -4815,15 +4975,22 @@ async def analyze_audio( fast_query: bool = Query( False, alias="fast", description="Pass --fast to analyze.py when true" ), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ): request_id = str(uuid4()) logger.warning("Legacy compatibility endpoint hit: /api/analyze request_id=%s", request_id) + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + await track.close() + return _mark_legacy_endpoint_response(user_context, endpoint="/api/analyze") try: _ = dsp_json_override analysis_mode = _resolve_analysis_mode_value(analysis_mode) requested_pitch_note_mode = _resolve_pitch_note_mode_for_legacy(transcribe) runtime, run_id = await _create_analysis_run_record( track=track, + owner_user_id=user_context.user_id, analysis_mode=analysis_mode, pitch_note_mode=requested_pitch_note_mode, pitch_note_backend="auto", @@ -4906,6 +5073,8 @@ async def analyze_phase2( model_name: str = Form("gemini-2.5-flash"), phase1_request_id: str | None = Form(None), analysis_run_id: str | None = Form(None), + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), ) -> JSONResponse: """Run Gemini Phase 2 advisory reconstruction server-side. @@ -4917,6 +5086,10 @@ async def analyze_phase2( """ request_id = str(uuid4()) logger.warning("Legacy compatibility endpoint hit: /api/phase2 request_id=%s", request_id) + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + await track.close() + return _mark_legacy_endpoint_response(user_context, endpoint="/api/phase2") try: if not _GENAI_AVAILABLE: return _mark_legacy_endpoint_response(JSONResponse( @@ -4967,8 +5140,9 @@ async def analyze_phase2( runtime, analysis_run_id=analysis_run_id, phase1_request_id=phase1_request_id, + owner_user_id=user_context.user_id, ) - except KeyError: + except (KeyError, PermissionError): missing_context = not analysis_run_id and not phase1_request_id return _build_phase2_error_response( request_id=request_id, diff --git a/apps/backend/tests/test_analysis_runtime.py b/apps/backend/tests/test_analysis_runtime.py index ab88b3b4..998d6c5e 100644 --- a/apps/backend/tests/test_analysis_runtime.py +++ b/apps/backend/tests/test_analysis_runtime.py @@ -76,6 +76,7 @@ def test_create_run_persists_source_artifact_and_stage_requests(self) -> None: filename="track.mp3", content=b"fake-audio", mime_type="audio/mpeg", + owner_user_id="user_123", pitch_note_mode="stem_notes", pitch_note_backend="auto", interpretation_mode="async", @@ -87,6 +88,7 @@ def test_create_run_persists_source_artifact_and_stage_requests(self) -> None: snapshot = runtime.get_run(created["runId"]) self.assertEqual(snapshot["artifacts"]["sourceAudio"]["filename"], "track.mp3") + self.assertNotIn("path", snapshot["artifacts"]["sourceAudio"]) self.assertEqual(snapshot["stages"]["measurement"]["status"], "queued") self.assertTrue(snapshot["stages"]["measurement"]["authoritative"]) self.assertEqual(snapshot["stages"]["pitchNoteTranslation"]["status"], "blocked") @@ -94,6 +96,87 @@ def test_create_run_persists_source_artifact_and_stage_requests(self) -> None: self.assertEqual(snapshot["stages"]["interpretation"]["status"], "blocked") self.assertEqual(snapshot["requestedStages"]["pitchNoteMode"], "stem_notes") self.assertEqual(snapshot["requestedStages"]["interpretationMode"], "async") + self.assertEqual(runtime.get_run_owner_user_id(created["runId"]), "user_123") + + def test_get_run_rejects_wrong_owner(self) -> None: + runtime = self._runtime() + created = runtime.create_run( + filename="track.mp3", + content=b"fake-audio", + mime_type="audio/mpeg", + owner_user_id="user_123", + pitch_note_mode="off", + pitch_note_backend="auto", + interpretation_mode="off", + interpretation_profile="producer_summary", + interpretation_model=None, + ) + + with self.assertRaisesRegex(PermissionError, "does not belong to user"): + runtime.get_run(created["runId"], owner_user_id="user_456") + + def test_runtime_uses_injected_artifact_storage_for_create_and_delete(self) -> None: + from analysis_runtime import AnalysisRuntime + from artifact_storage import StoredArtifact + + class RecordingArtifactStorage: + def __init__(self) -> None: + self.deleted_refs: list[str] = [] + + def store_bytes( + self, + *, + artifact_id: str, + filename: str, + content: bytes, + ) -> StoredArtifact: + return StoredArtifact( + storage_ref=f"memory://{artifact_id}/{filename}", + size_bytes=len(content), + content_sha256="sha-from-storage", + ) + + def store_file( + self, + *, + artifact_id: str, + filename: str, + source_path: str, + ) -> StoredArtifact: + return StoredArtifact( + storage_ref=f"memory://{artifact_id}/{filename}", + size_bytes=0, + content_sha256="sha-from-storage", + ) + + def delete(self, storage_ref: str) -> None: + self.deleted_refs.append(storage_ref) + + def resolve_local_path(self, storage_ref: str) -> Path | None: + return None + + storage = RecordingArtifactStorage() + runtime = AnalysisRuntime( + Path(self.temp_dir.name) / "runtime", + artifact_storage=storage, + ) + created = runtime.create_run( + filename="track.mp3", + content=b"fake-audio", + mime_type="audio/mpeg", + pitch_note_mode="off", + pitch_note_backend="auto", + interpretation_mode="off", + interpretation_profile="producer_summary", + interpretation_model=None, + ) + + source = runtime.get_source_artifact(created["runId"]) + self.assertEqual(source["path"], f"memory://{source['artifactId']}/track.mp3") + self.assertEqual(source["contentSha256"], "sha-from-storage") + + runtime.delete_run(created["runId"]) + self.assertEqual(storage.deleted_refs, [f"memory://{source['artifactId']}/track.mp3"]) def test_measurement_completion_strips_transcription_and_enqueues_pitch_note_stage( self, diff --git a/apps/backend/tests/test_cleanup.py b/apps/backend/tests/test_cleanup.py index 5bee4a47..01ab9187 100644 --- a/apps/backend/tests/test_cleanup.py +++ b/apps/backend/tests/test_cleanup.py @@ -140,6 +140,37 @@ def build_thread(*args, **kwargs): warning_mock.assert_called_once() self.assertIn("artifact cleanup failed", warning_mock.call_args.args[0].lower()) + def test_hosted_startup_skips_in_process_worker_loops_by_default(self) -> None: + runtime = Mock() + runtime.runtime_dir = Path("/tmp/asa-runtime") + thread = Mock() + captured_target = {} + + def build_thread(*args, **kwargs): + captured_target["target"] = kwargs["target"] + return thread + + created_coroutines = [] + + def fake_create_task(coro): + created_coroutines.append(coro) + coro.close() + return Mock(name="task") + + with ( + patch.object(server, "_BACKGROUND_TASKS", []), + patch.object(server, "get_analysis_runtime", return_value=runtime), + patch.dict(server.os.environ, {"SONIC_ANALYZER_RUNTIME_PROFILE": "hosted"}, clear=False), + patch.object(server.threading, "Thread", side_effect=build_thread), + patch.object(server.asyncio, "create_task", side_effect=fake_create_task), + patch.object(server, "cleanup_artifacts", create=True), + ): + asyncio.run(server._start_cache_eviction()) + + runtime.recover_incomplete_attempts.assert_not_called() + self.assertEqual(len(created_coroutines), 1) + self.assertEqual(created_coroutines[0].cr_code.co_name, "_evict_loop") + if __name__ == "__main__": unittest.main() diff --git a/apps/backend/tests/test_runtime_profile.py b/apps/backend/tests/test_runtime_profile.py new file mode 100644 index 00000000..6d49bd6d --- /dev/null +++ b/apps/backend/tests/test_runtime_profile.py @@ -0,0 +1,24 @@ +import unittest + +from runtime_profile import should_recover_incomplete_attempts + + +class RuntimeProfileTests(unittest.TestCase): + def test_local_all_process_recovers_incomplete_attempts(self) -> None: + self.assertTrue( + should_recover_incomplete_attempts("local", "all") + ) + + def test_hosted_api_process_skips_recovery_sweep(self) -> None: + self.assertFalse( + should_recover_incomplete_attempts("hosted", "api") + ) + + def test_hosted_worker_process_recovers_incomplete_attempts(self) -> None: + self.assertTrue( + should_recover_incomplete_attempts("hosted", "worker") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index ac2d2b3f..99c81706 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -716,6 +716,56 @@ def test_analysis_runs_endpoint_returns_canonical_stage_snapshot(self) -> None: self.assertEqual(payload["stages"]["measurement"]["status"], "queued") self.assertEqual(payload["stages"]["pitchNoteTranslation"]["status"], "blocked") self.assertEqual(payload["stages"]["interpretation"]["status"], "blocked") + self.assertNotIn("path", payload["artifacts"]["sourceAudio"]) + + def test_analysis_runs_endpoint_requires_user_header_in_hosted_mode(self) -> None: + with patch.dict(server.os.environ, {"SONIC_ANALYZER_RUNTIME_PROFILE": "hosted"}, clear=False): + response = asyncio.run( + server.create_analysis_run( + track=self._upload_file(), + analysis_mode="standard", + pitch_note_mode="stem_notes", + pitch_note_backend="auto", + interpretation_mode="async", + interpretation_profile="producer_summary", + interpretation_model="gemini-2.5-flash", + ) + ) + + payload = self._decode_json_response(response) + self.assertEqual(response.status_code, 401) + self.assertEqual(payload["error"]["code"], "AUTHENTICATION_REQUIRED") + + def test_get_analysis_run_rejects_wrong_owner_in_hosted_mode(self) -> None: + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_server_runtime_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + created = runtime.create_run( + filename="track.mp3", + content=b"fake-audio", + mime_type="audio/mpeg", + owner_user_id="user_owner", + pitch_note_mode="off", + pitch_note_backend="auto", + interpretation_mode="off", + interpretation_profile="producer_summary", + interpretation_model=None, + ) + with ( + patch.object(server, "get_analysis_runtime", return_value=runtime), + patch.dict(server.os.environ, {"SONIC_ANALYZER_RUNTIME_PROFILE": "hosted"}, clear=False), + ): + response = asyncio.run( + server.get_analysis_run( + created["runId"], + x_asa_user_id="other_user", + ) + ) + + payload = self._decode_json_response(response) + self.assertEqual(response.status_code, 404) + self.assertEqual(payload["error"]["code"], "RUN_NOT_FOUND") @patch.object(server, "get_audio_duration_seconds", return_value=214.6, create=True) @patch.object( @@ -913,6 +963,46 @@ def test_interrupt_analysis_run_marks_stages_interrupted_and_reports_terminated_ ["measurement", "pitchNoteTranslation"], ) + def test_delete_analysis_run_removes_owned_run(self) -> None: + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_server_runtime_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + created = runtime.create_run( + filename="track.mp3", + content=b"fake-audio", + mime_type="audio/mpeg", + owner_user_id="user_owner", + analysis_mode="full", + pitch_note_mode="off", + pitch_note_backend="auto", + interpretation_mode="off", + interpretation_profile="producer_summary", + interpretation_model=None, + ) + with ( + patch.object(server, "get_analysis_runtime", return_value=runtime), + patch.object( + server, + "_interrupt_active_child_processes", + return_value=["measurement"], + ), + patch.dict(server.os.environ, {"SONIC_ANALYZER_RUNTIME_PROFILE": "hosted"}, clear=False), + ): + response = asyncio.run( + server.delete_analysis_run( + created["runId"], + x_asa_user_id="user_owner", + ) + ) + + with self.assertRaises(KeyError): + runtime.get_run(created["runId"]) + + payload = self._decode_json_response(response) + self.assertEqual(response.status_code, 202) + self.assertTrue(payload["deleted"]) + @patch.object(server, "get_audio_duration_seconds", return_value=214.6, create=True) @patch.object( server, diff --git a/apps/backend/worker.py b/apps/backend/worker.py new file mode 100644 index 00000000..d1016492 --- /dev/null +++ b/apps/backend/worker.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import asyncio + +import server +from runtime_profile import resolve_process_role, resolve_runtime_profile, should_recover_incomplete_attempts + + +async def _run_worker_service() -> None: + runtime = server.get_analysis_runtime() + runtime_profile = resolve_runtime_profile() + process_role = resolve_process_role(runtime_profile=runtime_profile) + if should_recover_incomplete_attempts(runtime_profile, process_role): + runtime.recover_incomplete_attempts() + tasks = server._create_background_tasks( + include_cache_eviction=False, + include_workers=True, + ) + try: + await asyncio.gather(*tasks) + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +if __name__ == "__main__": + asyncio.run(_run_worker_service()) diff --git a/apps/ui/.env.example b/apps/ui/.env.example index 9e8273c2..13bd7560 100644 --- a/apps/ui/.env.example +++ b/apps/ui/.env.example @@ -1,6 +1,14 @@ +# Runtime profile: local keeps the localhost fallback, hosted defaults the API base +# URL to the current web origin when VITE_API_BASE_URL is omitted. +VITE_RUNTIME_PROFILE="local" + # Base URL of the local DSP backend that exposes the canonical analysis-runs API VITE_API_BASE_URL="http://127.0.0.1:8100" +# Optional hosted-mode request headers as a JSON object. +# Example: {"X-ASA-User-Id":"beta-user-123"} +VITE_API_REQUEST_HEADERS_JSON="" + # Optional Gemini phase-2 reconstruction pass (backend handles the API key). # This stays on by default unless you use the env flag as a hard kill-switch. VITE_ENABLE_PHASE2_GEMINI="true" diff --git a/apps/ui/src/components/SpectrogramViewer.tsx b/apps/ui/src/components/SpectrogramViewer.tsx index 26076b6f..656fb6d9 100644 --- a/apps/ui/src/components/SpectrogramViewer.tsx +++ b/apps/ui/src/components/SpectrogramViewer.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { appConfig } from '../config'; import type { SpectralArtifactRef } from '../types'; -import { buildArtifactUrl } from '../services/spectralArtifactsClient'; +import { + buildArtifactUrl, + fetchArtifactImageObjectUrl, +} from '../services/spectralArtifactsClient'; import { useSpectralCursor } from '../hooks/useSpectralCursorBus'; import { useImageZoom } from '../hooks/useImageZoom'; import { @@ -69,10 +73,55 @@ export function SpectrogramViewer({ () => spectrograms.find((s) => s.kind === activeKind) ?? spectrograms[0] ?? null, [spectrograms, activeKind], ); - const imageUrl = useMemo( + const directImageUrl = useMemo( () => (activeSpec ? buildArtifactUrl(apiBaseUrl, runId, activeSpec.artifactId) : ''), [activeSpec, apiBaseUrl, runId], ); + const [imageUrl, setImageUrl] = useState(directImageUrl); + + useEffect(() => { + if (!activeSpec) { + setImageUrl(''); + return; + } + + if (Object.keys(appConfig.requestHeaders).length === 0) { + setImageUrl(directImageUrl); + return; + } + + const controller = new AbortController(); + let released = false; + let loadedImage: { url: string; revoke: () => void } | null = null; + + setImageUrl(''); + + fetchArtifactImageObjectUrl( + apiBaseUrl, + runId, + activeSpec.artifactId, + { signal: controller.signal }, + ) + .then((loaded) => { + if (released) { + loaded.revoke(); + return; + } + loadedImage = loaded; + setImageUrl(loaded.url); + }) + .catch(() => { + if (!released) { + setImageUrl(''); + } + }); + + return () => { + released = true; + controller.abort(); + loadedImage?.revoke(); + }; + }, [activeSpec, apiBaseUrl, directImageUrl, runId]); const drawOverlay = useCallback(() => { const canvas = overlayRef.current; diff --git a/apps/ui/src/config.ts b/apps/ui/src/config.ts index c42f8753..9d68cadc 100644 --- a/apps/ui/src/config.ts +++ b/apps/ui/src/config.ts @@ -3,19 +3,40 @@ import uiPackage from '../package.json'; export interface AppConfig { apiBaseUrl: string; enablePhase2Gemini: boolean; + runtimeProfile: RuntimeProfile; + requestHeaders: Record; } type AppConfigEnv = Partial< - Pick + Pick< + ImportMetaEnv, + | 'VITE_API_BASE_URL' + | 'VITE_ENABLE_PHASE2_GEMINI' + | 'VITE_RUNTIME_PROFILE' + | 'VITE_API_REQUEST_HEADERS_JSON' + > >; +type RuntimeProfile = 'local' | 'hosted'; + function parseBooleanFlag(value: string | undefined, defaultValue: boolean): boolean { if (value === undefined) return defaultValue; return value.trim().toLowerCase() === 'true'; } -function normalizeBaseUrl(value: string | undefined): string { - const fallback = 'http://127.0.0.1:8100'; +function resolveRuntimeProfile(value: string | undefined): RuntimeProfile { + return value?.trim().toLowerCase() === 'hosted' ? 'hosted' : 'local'; +} + +function normalizeBaseUrl( + value: string | undefined, + runtimeProfile: RuntimeProfile, + runtimeWindow?: Window, +): string { + const fallback = + runtimeProfile === 'hosted' + ? runtimeWindow?.location?.origin?.replace(/\/+$/, '') ?? '' + : 'http://127.0.0.1:8100'; const raw = value?.trim(); if (!raw) return fallback; return raw.replace(/\/+$/, ''); @@ -29,16 +50,56 @@ function readRuntimeEnvOverrides(runtimeWindow?: Window): AppConfigEnv { return { VITE_API_BASE_URL: runtimeWindow.__VITE_API_BASE_URL_OVERRIDE__, VITE_ENABLE_PHASE2_GEMINI: runtimeWindow.__VITE_ENABLE_PHASE2_GEMINI_OVERRIDE__, + ...(runtimeWindow.__ASA_REQUEST_HEADERS_OVERRIDE__ !== undefined + ? { + VITE_API_REQUEST_HEADERS_JSON: + typeof runtimeWindow.__ASA_REQUEST_HEADERS_OVERRIDE__ === 'string' + ? runtimeWindow.__ASA_REQUEST_HEADERS_OVERRIDE__ + : JSON.stringify(runtimeWindow.__ASA_REQUEST_HEADERS_OVERRIDE__), + } + : {}), }; } -export function resolveAppConfig(env: AppConfigEnv, overrides: AppConfigEnv = {}): AppConfig { +function parseRequestHeaders(value: string | undefined): Record { + if (!value?.trim()) { + return {}; + } + + try { + const parsed = JSON.parse(value) as Record; + return Object.fromEntries( + Object.entries(parsed).flatMap(([key, entry]) => + typeof entry === 'string' && key.trim() !== '' ? [[key, entry]] : [], + ), + ); + } catch { + return {}; + } +} + +export function resolveAppConfig( + env: AppConfigEnv, + overrides: AppConfigEnv = {}, + runtimeWindow?: Window, +): AppConfig { + const runtimeProfile = resolveRuntimeProfile( + overrides.VITE_RUNTIME_PROFILE ?? env.VITE_RUNTIME_PROFILE, + ); return { - apiBaseUrl: normalizeBaseUrl(overrides.VITE_API_BASE_URL ?? env.VITE_API_BASE_URL), + runtimeProfile, + apiBaseUrl: normalizeBaseUrl( + overrides.VITE_API_BASE_URL ?? env.VITE_API_BASE_URL, + runtimeProfile, + runtimeWindow, + ), enablePhase2Gemini: parseBooleanFlag( overrides.VITE_ENABLE_PHASE2_GEMINI ?? env.VITE_ENABLE_PHASE2_GEMINI, true, ), + requestHeaders: parseRequestHeaders( + overrides.VITE_API_REQUEST_HEADERS_JSON ?? env.VITE_API_REQUEST_HEADERS_JSON, + ), }; } @@ -49,8 +110,28 @@ export const appVersionLabel = `v${uiPackage.version}`; export const appConfig: AppConfig = resolveAppConfig( import.meta.env, readRuntimeEnvOverrides(runtimeWindow), + runtimeWindow, ); export function isGeminiPhase2ConfigEnabled(config: AppConfig = appConfig): boolean { return config.enablePhase2Gemini; } + +export function buildConfiguredRequestInit( + init: RequestInit = {}, + config: AppConfig = appConfig, +): RequestInit { + if (Object.keys(config.requestHeaders).length === 0) { + return init; + } + + const headers = new Headers(init.headers); + for (const [key, value] of Object.entries(config.requestHeaders)) { + headers.set(key, value); + } + + return { + ...init, + headers, + }; +} diff --git a/apps/ui/src/services/analysisRunsClient.ts b/apps/ui/src/services/analysisRunsClient.ts index c1bb2bff..934ff4c8 100644 --- a/apps/ui/src/services/analysisRunsClient.ts +++ b/apps/ui/src/services/analysisRunsClient.ts @@ -19,6 +19,7 @@ import { PitchNoteTranslationAttemptSummary, PitchNoteTranslationStageSnapshot, } from '../types'; +import { appConfig, buildConfiguredRequestInit } from '../config'; import { BackendClientError, createUserCancelledError, parsePhase1Result } from './backendPhase1Client'; import { requestBackendEstimate } from './backendPhase1Client'; @@ -270,7 +271,7 @@ export function projectStemSummaryFromRun(snapshot: AnalysisRunSnapshot): StemSu async function fetchJson(url: string, init: RequestInit): Promise { let response: Response; try { - response = await fetch(url, init); + response = await fetch(url, buildConfiguredRequestInit(init)); } catch (error) { if (init.signal?.aborted) { throw createUserCancelledError(); @@ -278,7 +279,9 @@ async function fetchJson(url: string, init: RequestInit): Promise { if (error instanceof TypeError) { throw new BackendClientError( 'NETWORK_UNREACHABLE', - 'Cannot reach the local DSP backend. Confirm it is running and the API base URL is correct.', + appConfig.runtimeProfile === 'hosted' + ? 'Cannot reach the ASA backend service. Confirm the hosted API URL and deployment are available.' + : 'Cannot reach the local DSP backend. Confirm it is running and the API base URL is correct.', { cause: error }, ); } @@ -386,7 +389,6 @@ function parseArtifact(value: Record): AnalysisRunArtifact { mimeType: expectString(value.mimeType, 'mimeType'), sizeBytes: expectNumber(value.sizeBytes, 'sizeBytes'), contentSha256: expectString(value.contentSha256, 'contentSha256'), - path: expectString(value.path, 'path'), }; } diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index 72ef1cc5..e07f5608 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -18,6 +18,7 @@ import { TextureCharacter, VocalDetail, } from "../types"; +import { buildConfiguredRequestInit } from "../config"; const ANALYZE_TIMEOUT_FLOOR_MS = 180_000; const ANALYZE_TIMEOUT_PADDING_MS = 60_000; @@ -290,10 +291,13 @@ async function probeBackendIdentity(apiBaseUrl: string): Promise controller.abort(), BACKEND_IDENTITY_TIMEOUT_MS); try { - const response = await fetch(`${apiBaseUrl}/openapi.json`, { - method: "GET", - signal: controller.signal, - }); + const response = await fetch( + `${apiBaseUrl}/openapi.json`, + buildConfiguredRequestInit({ + method: "GET", + signal: controller.signal, + }), + ); if (!response.ok) return null; @@ -355,11 +359,14 @@ async function postBackendMultipart( } try { - const response = await fetch(endpoint, { - method: "POST", - body: formData, - signal: controller.signal, - }); + const response = await fetch( + endpoint, + buildConfiguredRequestInit({ + method: "POST", + body: formData, + signal: controller.signal, + }), + ); if (!response.ok) { throw await toBackendHttpError(response); diff --git a/apps/ui/src/services/spectralArtifactsClient.ts b/apps/ui/src/services/spectralArtifactsClient.ts index 59246ed8..c37badc0 100644 --- a/apps/ui/src/services/spectralArtifactsClient.ts +++ b/apps/ui/src/services/spectralArtifactsClient.ts @@ -4,6 +4,7 @@ import type { SpectralArtifactRef, SpectralTimeSeriesData, } from '../types'; +import { buildConfiguredRequestInit } from '../config'; export function buildArtifactUrl( apiBaseUrl: string, @@ -13,6 +14,33 @@ export function buildArtifactUrl( return `${apiBaseUrl}/api/analysis-runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(artifactId)}`; } +export async function fetchArtifactImageObjectUrl( + apiBaseUrl: string, + runId: string, + artifactId: string, + init: RequestInit = {}, +): Promise<{ url: string; revoke: () => void }> { + const url = buildArtifactUrl(apiBaseUrl, runId, artifactId); + const response = await fetch(url, buildConfiguredRequestInit(init)); + if (!response.ok) { + throw new Error(`Failed to fetch artifact image: ${response.status}`); + } + + const objectUrl = URL.createObjectURL(await response.blob()); + let revoked = false; + + return { + url: objectUrl, + revoke: () => { + if (revoked) { + return; + } + revoked = true; + URL.revokeObjectURL(objectUrl); + }, + }; +} + export async function fetchSpectralTimeSeries( apiBaseUrl: string, runId: string, @@ -20,7 +48,7 @@ export async function fetchSpectralTimeSeries( options?: { signal?: AbortSignal }, ): Promise { const url = buildArtifactUrl(apiBaseUrl, runId, artifactId); - const response = await fetch(url, { signal: options?.signal }); + const response = await fetch(url, buildConfiguredRequestInit({ signal: options?.signal })); if (!response.ok) { throw new Error(`Failed to fetch spectral time series: ${response.status}`); } @@ -36,7 +64,10 @@ export async function generateSpectralEnhancement( options?: { signal?: AbortSignal }, ): Promise<{ artifacts: SpectralArtifactRef[] }> { const url = `${apiBaseUrl}/api/analysis-runs/${encodeURIComponent(runId)}/spectral-enhancements/${encodeURIComponent(kind)}`; - const response = await fetch(url, { method: 'POST', signal: options?.signal }); + const response = await fetch( + url, + buildConfiguredRequestInit({ method: 'POST', signal: options?.signal }), + ); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body?.error?.message ?? `Enhancement generation failed: ${response.status}`); @@ -51,7 +82,7 @@ export async function fetchOnsetStrengthData( options?: { signal?: AbortSignal }, ): Promise { const url = buildArtifactUrl(apiBaseUrl, runId, artifactId); - const response = await fetch(url, { signal: options?.signal }); + const response = await fetch(url, buildConfiguredRequestInit({ signal: options?.signal })); if (!response.ok) { throw new Error(`Failed to fetch onset strength data: ${response.status}`); } @@ -65,7 +96,7 @@ export async function fetchChromaInteractiveData( options?: { signal?: AbortSignal }, ): Promise { const url = buildArtifactUrl(apiBaseUrl, runId, artifactId); - const response = await fetch(url, { signal: options?.signal }); + const response = await fetch(url, buildConfiguredRequestInit({ signal: options?.signal })); if (!response.ok) { throw new Error(`Failed to fetch interactive chroma data: ${response.status}`); } diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index a04c90de..35c1eda1 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -653,7 +653,6 @@ export interface AnalysisRunArtifact { mimeType: string; sizeBytes: number; contentSha256: string; - path: string; } export interface SpectralArtifactRef { diff --git a/apps/ui/src/vite-env.d.ts b/apps/ui/src/vite-env.d.ts index 3eb1321e..ce014287 100644 --- a/apps/ui/src/vite-env.d.ts +++ b/apps/ui/src/vite-env.d.ts @@ -2,7 +2,9 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; + readonly VITE_API_REQUEST_HEADERS_JSON?: string; readonly VITE_ENABLE_PHASE2_GEMINI?: string; + readonly VITE_RUNTIME_PROFILE?: string; readonly DISABLE_HMR?: string; } @@ -13,5 +15,6 @@ interface ImportMeta { interface Window { __VITE_API_BASE_URL_OVERRIDE__?: string; __VITE_ENABLE_PHASE2_GEMINI_OVERRIDE__?: string; + __ASA_REQUEST_HEADERS_OVERRIDE__?: string | Record; __SONIC_BENCHMARK__?: boolean; } diff --git a/apps/ui/tests/services/analysisRunsClient.test.ts b/apps/ui/tests/services/analysisRunsClient.test.ts index 4f3e7b51..16391e8c 100644 --- a/apps/ui/tests/services/analysisRunsClient.test.ts +++ b/apps/ui/tests/services/analysisRunsClient.test.ts @@ -30,7 +30,6 @@ const baseRunSnapshot: AnalysisRunSnapshot = { mimeType: 'audio/mpeg', sizeBytes: 1024, contentSha256: 'abc123', - path: '/tmp/track.mp3', }, }, stages: { diff --git a/apps/ui/tests/services/analyzer.test.ts b/apps/ui/tests/services/analyzer.test.ts index fb8965d7..1a466ed6 100644 --- a/apps/ui/tests/services/analyzer.test.ts +++ b/apps/ui/tests/services/analyzer.test.ts @@ -159,7 +159,6 @@ function makeRunSnapshot(overrides?: Partial): AnalysisRunS mimeType: 'audio/mpeg', sizeBytes: 4096, contentSha256: 'abc123', - path: '/tmp/track.mp3', }, }, stages: { diff --git a/apps/ui/tests/services/config.test.ts b/apps/ui/tests/services/config.test.ts index 3f3e4331..78590616 100644 --- a/apps/ui/tests/services/config.test.ts +++ b/apps/ui/tests/services/config.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); +}); describe('resolveAppConfig', () => { it('defaults the Phase 2 config gate to on when the env flag is unset', async () => { @@ -31,6 +37,7 @@ describe('resolveAppConfig', () => { it('allows runtime overrides to replace build-time env values', async () => { const { + buildConfiguredRequestInit, isGeminiPhase2ConfigEnabled, resolveAppConfig, } = await vi.importActual('../../src/config'); @@ -45,5 +52,79 @@ describe('resolveAppConfig', () => { ); expect(isGeminiPhase2ConfigEnabled(config)).toBe(false); + expect(buildConfiguredRequestInit({}, config)).toEqual({}); + }); + + it('uses the current web origin as the hosted fallback API base URL', async () => { + const { resolveAppConfig } = + await vi.importActual('../../src/config'); + + const fakeWindow = { + location: { origin: 'https://asa.example.com/' }, + } as unknown as Window; + + const config = resolveAppConfig( + { + VITE_RUNTIME_PROFILE: 'hosted', + }, + {}, + fakeWindow, + ); + + expect(config.runtimeProfile).toBe('hosted'); + expect(config.apiBaseUrl).toBe('https://asa.example.com'); + }); + + it('parses hosted request headers and merges them into request init values', async () => { + const { + buildConfiguredRequestInit, + resolveAppConfig, + } = await vi.importActual('../../src/config'); + + const config = resolveAppConfig( + { + VITE_API_REQUEST_HEADERS_JSON: '{"X-ASA-User-Id":"beta-user-123","X-ASA-User-Email":"beta@example.com"}', + }, + {}, + ); + + const init = buildConfiguredRequestInit( + { + headers: { + Accept: 'application/json', + }, + }, + config, + ); + const headers = new Headers(init.headers); + + expect(config.requestHeaders).toEqual({ + 'X-ASA-User-Id': 'beta-user-123', + 'X-ASA-User-Email': 'beta@example.com', + }); + expect(headers.get('Accept')).toBe('application/json'); + expect(headers.get('X-ASA-User-Id')).toBe('beta-user-123'); + expect(headers.get('X-ASA-User-Email')).toBe('beta@example.com'); + }); + + it('preserves env request headers when browser overrides omit them', async () => { + vi.stubEnv( + 'VITE_API_REQUEST_HEADERS_JSON', + '{"X-ASA-User-Id":"beta-user-123","X-ASA-User-Email":"beta@example.com"}', + ); + vi.stubGlobal( + 'window', + { + location: { origin: 'https://asa.example.com/' }, + } as unknown as Window, + ); + + const { appConfig } = + await vi.importActual('../../src/config'); + + expect(appConfig.requestHeaders).toEqual({ + 'X-ASA-User-Id': 'beta-user-123', + 'X-ASA-User-Email': 'beta@example.com', + }); }); }); diff --git a/apps/ui/tests/services/diagnosticLogs.test.ts b/apps/ui/tests/services/diagnosticLogs.test.ts index b7f7b139..9cab4ce4 100644 --- a/apps/ui/tests/services/diagnosticLogs.test.ts +++ b/apps/ui/tests/services/diagnosticLogs.test.ts @@ -26,7 +26,6 @@ function makeSnapshot(): AnalysisRunSnapshot { mimeType: 'audio/mpeg', sizeBytes: 4096, contentSha256: 'abc123', - path: '/tmp/track.mp3', }, }, stages: { diff --git a/apps/ui/tests/services/spectralArtifactsClient.test.ts b/apps/ui/tests/services/spectralArtifactsClient.test.ts new file mode 100644 index 00000000..bf09c47b --- /dev/null +++ b/apps/ui/tests/services/spectralArtifactsClient.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('fetchArtifactImageObjectUrl', () => { + it('loads artifact images through authenticated fetch and returns a revoker', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(new Blob(['image-bytes'], { type: 'image/png' }), { + status: 200, + headers: { 'Content-Type': 'image/png' }, + }), + ); + const createObjectURL = vi.fn(() => 'blob:https://asa.example.com/spectrogram'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + 'URL', + Object.assign(URL, { + createObjectURL, + revokeObjectURL, + }), + ); + + const { fetchArtifactImageObjectUrl } = + await vi.importActual( + '../../src/services/spectralArtifactsClient', + ); + + const loaded = await fetchArtifactImageObjectUrl( + 'https://asa.example.com', + 'run_123', + 'artifact_456', + { + headers: { 'X-ASA-User-Id': 'beta-user-123' }, + }, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://asa.example.com/api/analysis-runs/run_123/artifacts/artifact_456', + expect.objectContaining({ + headers: { + 'X-ASA-User-Id': 'beta-user-123', + }, + }), + ); + expect(loaded.url).toBe('blob:https://asa.example.com/spectrogram'); + loaded.revoke(); + expect(revokeObjectURL).toHaveBeenCalledWith( + 'blob:https://asa.example.com/spectrogram', + ); + }); +}); diff --git a/apps/ui/tests/smoke/error-states.spec.ts b/apps/ui/tests/smoke/error-states.spec.ts index 30ff7155..19664054 100644 --- a/apps/ui/tests/smoke/error-states.spec.ts +++ b/apps/ui/tests/smoke/error-states.spec.ts @@ -78,7 +78,6 @@ function buildRunSnapshot(runId: string, overrides: RunSnapshotOverrides = {}) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/file-validation.spec.ts b/apps/ui/tests/smoke/file-validation.spec.ts index 60ce212f..37f5b00f 100644 --- a/apps/ui/tests/smoke/file-validation.spec.ts +++ b/apps/ui/tests/smoke/file-validation.spec.ts @@ -71,7 +71,6 @@ function stubBackendRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -127,7 +126,6 @@ function stubBackendRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/phase2-degradation.spec.ts b/apps/ui/tests/smoke/phase2-degradation.spec.ts index c809a2c4..d5b2b59a 100644 --- a/apps/ui/tests/smoke/phase2-degradation.spec.ts +++ b/apps/ui/tests/smoke/phase2-degradation.spec.ts @@ -76,7 +76,6 @@ function stubAnalyzeRoute(page: import('@playwright/test').Page, requestId = 're mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -144,7 +143,6 @@ function stubRunPoll( mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/responsive-layout.spec.ts b/apps/ui/tests/smoke/responsive-layout.spec.ts index 77ced968..c6bb1de8 100644 --- a/apps/ui/tests/smoke/responsive-layout.spec.ts +++ b/apps/ui/tests/smoke/responsive-layout.spec.ts @@ -92,7 +92,6 @@ function stubRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -148,7 +147,6 @@ function stubRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/ui-details.spec.ts b/apps/ui/tests/smoke/ui-details.spec.ts index 8602e74b..0495ba77 100644 --- a/apps/ui/tests/smoke/ui-details.spec.ts +++ b/apps/ui/tests/smoke/ui-details.spec.ts @@ -177,7 +177,6 @@ function stubRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -233,7 +232,6 @@ function stubRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -334,7 +332,6 @@ test('CPU indicator animates during analysis', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -396,7 +393,6 @@ test('CPU indicator animates during analysis', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts b/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts index 2c332754..33c08fb2 100644 --- a/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts +++ b/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts @@ -94,7 +94,6 @@ test('upload shows estimate and local DSP processing copy before phase1 complete mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -153,7 +152,6 @@ test('upload shows estimate and local DSP processing copy before phase1 complete mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts index 75ce1fcc..b5bae750 100644 --- a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts +++ b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts @@ -158,7 +158,6 @@ test('phase1 dual-source session musician panel toggles between pitch-note and m mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -215,7 +214,6 @@ test('phase1 dual-source session musician panel toggles between pitch-note and m mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -467,7 +465,6 @@ test('missing melodyDetail shows MIDI unavailable state', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -524,7 +521,6 @@ test('missing melodyDetail shows MIDI unavailable state', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/upload-phase1.spec.ts b/apps/ui/tests/smoke/upload-phase1.spec.ts index 0840d12f..b234fd16 100644 --- a/apps/ui/tests/smoke/upload-phase1.spec.ts +++ b/apps/ui/tests/smoke/upload-phase1.spec.ts @@ -73,7 +73,6 @@ test('upload + backend phase1 success renders analysis results', async ({ page } mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { @@ -130,7 +129,6 @@ test('upload + backend phase1 success renders analysis results', async ({ page } mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', - path: '/tmp/silence.wav', }, }, stages: { diff --git a/docs/PUBLIC_HOSTING_FOUNDATION.md b/docs/PUBLIC_HOSTING_FOUNDATION.md new file mode 100644 index 00000000..20dbd1ff --- /dev/null +++ b/docs/PUBLIC_HOSTING_FOUNDATION.md @@ -0,0 +1,324 @@ +# ASA Public Hosting Foundation + +_Last updated: 2026-04-01_ + +## Why this work exists + +ASA was built as a local-first tool. That was fine for development, but it meant the code implicitly assumed: + +- the backend and worker logic lived in one process +- artifacts were local files on the same machine +- run state lived in local SQLite only +- the browser talked to a localhost API +- there was no concept of per-user ownership for runs + +That shape is acceptable for local development, but it is not a safe base for public hosting. + +In plain English: this work does **not** make ASA production-hosted yet. What it does is prepare the codebase so ASA can eventually be hosted publicly without breaking the current local app. + +## Goals + +- Keep the analysis engine shared between local and hosted usage. +- Preserve the current local workflow as a first-class path. +- Add explicit hosted-mode boundaries so future cloud work does not leak into the local happy path. +- Remove public API assumptions that would be unsafe in a hosted environment. + +## Non-goals + +This work does **not** yet provide: + +- real cloud object storage +- real PostgreSQL persistence +- real queue infrastructure +- real identity provider token validation +- rate limiting, quota enforcement, or billing protection +- production deployment manifests + +In plain English: the code is now shaped correctly for those systems, but those external systems are still future work. + +## What was implemented + +### 1. Explicit runtime profiles + +Files: + +- `apps/backend/runtime_profile.py` +- `apps/backend/server.py` +- `apps/backend/worker.py` +- `apps/ui/src/config.ts` + +Implemented: + +- Added explicit backend runtime profiles: `local` and `hosted`. +- Added explicit backend process roles: `all`, `api`, and `worker`. +- Kept `local` as the default profile. +- Kept the existing local behavior as the default local process role. +- Made hosted API startup and hosted worker startup separable. +- Added frontend runtime-profile handling so hosted builds do not depend on localhost fallback behavior. + +In plain English: + +- Local mode still behaves like the current app. +- Hosted mode now has its own switch, so future hosting work does not have to mutate the local runtime path. + +### 2. Dedicated worker process boundary + +Files: + +- `apps/backend/server.py` +- `apps/backend/worker.py` +- `apps/backend/tests/test_cleanup.py` +- `apps/backend/tests/test_runtime_profile.py` + +Implemented: + +- Extracted background-task startup into a reusable helper. +- Added a dedicated `worker.py` entrypoint for background stage execution. +- In hosted mode, the API process no longer starts in-process workers by default. +- Recovery of incomplete attempts is now restricted to the worker role in hosted mode. + +Why this matters: + +- A hosted API process should serve HTTP and enqueue or observe work. +- A hosted worker process should own long-running analysis work and restart recovery. + +In plain English: + +- Restarting the web server no longer pretends to be a worker. +- Background jobs now have a clean process boundary, which is required before real queue infrastructure can be introduced. + +### 3. Hosted auth and run ownership groundwork + +Files: + +- `apps/backend/auth_context.py` +- `apps/backend/server.py` +- `apps/backend/analysis_runtime.py` +- `apps/backend/tests/test_server.py` +- `apps/backend/tests/test_analysis_runtime.py` + +Implemented: + +- Added hosted-mode user-context resolution from request headers. +- Added `owner_user_id` to `analysis_runs`. +- Backfilled older local rows to a local development owner. +- Enforced ownership checks on canonical run routes. +- Added hosted-mode authentication-required behavior. +- Added a delete-run endpoint: `DELETE /api/analysis-runs/{run_id}`. + +Current hosted auth contract: + +- hosted mode requires `X-ASA-User-Id` +- local mode continues to use a local development user context automatically + +Important limitation: + +- this is a hosting foundation hook, not final production auth +- there is not yet token verification against a real identity provider + +In plain English: + +- ASA now knows which user owns which hosted run. +- One hosted user can no longer read or interrupt another user’s run through the canonical API. + +### 4. Public artifact contract cleanup + +Files: + +- `apps/backend/analysis_runtime.py` +- `apps/backend/server.py` +- `apps/ui/src/types.ts` +- `apps/ui/src/services/analysisRunsClient.ts` +- related frontend fixtures and tests + +Implemented: + +- Removed internal filesystem `path` values from the public run snapshot contract. +- Split artifact access into: + - public artifact metadata for API responses + - internal artifact records for server-side file operations +- Updated frontend transport parsing to stop expecting `path` in shared API payloads. + +Why this matters: + +- Returning internal disk paths in a hosted API is unsafe and incorrect. +- Hosted storage will eventually use object-store references or signed access instead of local paths. + +In plain English: + +- The browser now gets only the information it should have. +- The server keeps the internal storage details to itself. + +### 5. Artifact storage boundary + +Files: + +- `apps/backend/artifact_storage.py` +- `apps/backend/analysis_runtime.py` +- `apps/backend/server.py` +- `apps/backend/tests/test_analysis_runtime.py` + +Implemented: + +- Introduced an artifact storage abstraction. +- Added the current filesystem implementation as the default storage backend. +- Updated runtime artifact writes to go through that abstraction instead of writing files inline at each call site. +- Added helpers for resolving whether a stored artifact is available as a local file. +- Updated measurement, pitch-note, interpretation, download, and spectral generation paths to use the runtime storage boundary. + +Why this matters: + +- Local filesystem storage still works. +- The code is now structurally prepared for a future object-storage backend. + +In plain English: + +- ASA still saves files on disk today. +- The code no longer assumes disk is the only possible storage system forever. + +### 6. Frontend hosted request plumbing + +Files: + +- `apps/ui/src/config.ts` +- `apps/ui/src/vite-env.d.ts` +- `apps/ui/.env.example` +- `apps/ui/src/services/analysisRunsClient.ts` +- `apps/ui/src/services/backendPhase1Client.ts` +- `apps/ui/src/services/spectralArtifactsClient.ts` +- `apps/ui/tests/services/config.test.ts` + +Implemented: + +- Added `VITE_RUNTIME_PROFILE`. +- Added `VITE_API_REQUEST_HEADERS_JSON`. +- Added shared request-header injection from runtime config. +- Applied configured hosted headers across: + - canonical run requests + - backend identity probing + - multipart estimate/create requests + - spectral artifact fetches +- Changed hosted API-base fallback to use the current web origin instead of localhost. + +Why this matters: + +- Hosted mode needs a clean way to pass auth-related headers or private-beta routing metadata. +- Browser code should not accidentally assume the API lives on `127.0.0.1`. + +In plain English: + +- The frontend can now talk to a hosted backend without silently dropping the headers that identify the user. + +### 7. Hosted spectrogram image loading fix + +Files: + +- `apps/ui/src/services/spectralArtifactsClient.ts` +- `apps/ui/src/components/SpectrogramViewer.tsx` +- `apps/ui/tests/services/spectralArtifactsClient.test.ts` + +Implemented: + +- Added authenticated blob fetching for artifact images. +- Updated the spectrogram viewer to use direct URLs when no request headers are configured. +- Updated the spectrogram viewer to use fetched object URLs when hosted-mode headers are present. + +Why this matters: + +- Plain `` loads cannot carry custom auth headers. +- Hosted/private-beta artifact access needs an authenticated fetch path. + +In plain English: + +- Spectrogram images still load locally the simple way. +- In hosted mode, they now load in a way that can respect auth headers. + +### 8. Local-mode preservation + +Files touched across backend and frontend. + +Preserved intentionally: + +- `./scripts/dev.sh` +- local UI on `127.0.0.1:3100` +- local backend on `127.0.0.1:8100` +- local SQLite runtime +- local filesystem artifact storage +- existing local-first workflow and canonical dev defaults + +Design rule enforced by this work: + +- hosted-only concerns must be isolated behind runtime-profile boundaries +- local mode must not require new cloud infrastructure + +In plain English: + +- this work was specifically designed so the current local app keeps working +- the hosted prep should not slow down or complicate normal local development + +## Verification completed + +Backend: + +- `cd apps/backend && ./venv/bin/python -m py_compile analysis_runtime.py artifact_storage.py auth_context.py runtime_profile.py server.py worker.py` +- `cd apps/backend && ./venv/bin/python -m unittest tests.test_analysis_runtime tests.test_cleanup tests.test_server` +- `cd apps/backend && ./venv/bin/python -m unittest discover -s tests` +- focused follow-up checks: + - `cd apps/backend && ./venv/bin/python -m unittest tests.test_server tests.test_cleanup tests.test_runtime_profile` + +Frontend: + +- `cd apps/ui && npx vitest run tests/services/analysisRunsClient.test.ts tests/services/analyzer.test.ts tests/services/diagnosticLogs.test.ts` +- `cd apps/ui && npm run test:unit` +- `cd apps/ui && npm run build` +- focused follow-up checks: + - `cd apps/ui && npm run lint` + - `cd apps/ui && npm run test -- --run tests/services/config.test.ts tests/services/spectralArtifactsClient.test.ts tests/services/analysisRunsClient.test.ts` + +Results: + +- backend full suite passed +- frontend targeted service tests passed +- frontend production build passed +- follow-up hosted-mode regression checks passed + +## Remaining work before real public hosting + +These are still intentionally unresolved: + +- swap the current local runtime persistence for a real hosted persistence adapter such as PostgreSQL +- swap filesystem artifact storage for real object storage +- add real hosted upload creation and signed-upload flow +- replace header-based hosted identity hooks with verified user tokens +- add queue infrastructure for hosted workers +- add rate limiting, quotas, abuse protection, and billing guardrails +- add observability, alerting, and retention policy enforcement + +In plain English: + +- the codebase is now ready for those next steps +- it is not yet claiming they already exist + +## Practical reading guide + +If you want the shortest possible overview: + +- read this file first +- then read `README.md` + +If you want the backend implementation details: + +- `apps/backend/runtime_profile.py` +- `apps/backend/auth_context.py` +- `apps/backend/artifact_storage.py` +- `apps/backend/analysis_runtime.py` +- `apps/backend/server.py` +- `apps/backend/worker.py` + +If you want the frontend hosted-mode changes: + +- `apps/ui/src/config.ts` +- `apps/ui/src/services/analysisRunsClient.ts` +- `apps/ui/src/services/backendPhase1Client.ts` +- `apps/ui/src/services/spectralArtifactsClient.ts` +- `apps/ui/src/components/SpectrogramViewer.tsx` From 68625ef27a97bf219c06ea0948cadbce42e43e05 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 1 Apr 2026 12:39:12 +1300 Subject: [PATCH 2/2] stabilize backend ci subprocess assertions --- apps/backend/tests/test_server.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index 99c81706..e514171f 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -254,6 +254,22 @@ def _request_body_properties(self, path: str) -> dict: schema_name = schema_ref.split("/")[-1] return openapi["components"]["schemas"][schema_name]["properties"] + def _find_analyze_subprocess_call(self, run_mock) -> tuple[list[str], dict]: + observed_commands: list[object] = [] + for call in reversed(run_mock.call_args_list): + command = call.args[0] if call.args else call.kwargs.get("args", []) + observed_commands.append(command) + if ( + isinstance(command, list) + and len(command) >= 2 + and command[0] == "./venv/bin/python" + and command[1] == "analyze.py" + ): + return command, call.kwargs + self.fail( + f"Did not find analyze.py subprocess call. Observed subprocess calls: {observed_commands}" + ) + def test_phase2_prompt_template_loaded(self) -> None: self.assertIsInstance(server.PHASE2_PROMPT_TEMPLATE, str) self.assertGreater(len(server.PHASE2_PROMPT_TEMPLATE), 100) @@ -1149,7 +1165,7 @@ def test_analyze_endpoint_combines_separate_and_transcribe_in_subprocess( ) self.assertEqual(response.status_code, 200) - command = run_mock.call_args.args[0] + command, call_kwargs = self._find_analyze_subprocess_call(run_mock) self.assertEqual( command, [ @@ -1160,7 +1176,7 @@ def test_analyze_endpoint_combines_separate_and_transcribe_in_subprocess( "--separate", ], ) - self.assertEqual(run_mock.call_args.kwargs["timeout"], 526) + self.assertEqual(call_kwargs["timeout"], 526) build_estimate_mock.assert_called_once_with(214.6, True, False) payload = self._decode_json_response(response) self.assertEqual(payload["diagnostics"]["backendDurationMs"], 200.0) @@ -1246,7 +1262,7 @@ def test_analyze_endpoint_passes_fast_flag_to_subprocess( ) self.assertEqual(response.status_code, 200) - command = run_mock.call_args.args[0] + command, _call_kwargs = self._find_analyze_subprocess_call(run_mock) self.assertIn("--fast", command) payload = self._decode_json_response(response) self.assertIn("--fast", payload["diagnostics"]["timings"]["flagsUsed"]) @@ -1300,7 +1316,7 @@ def test_analyze_endpoint_does_not_pass_fast_when_false( ) self.assertEqual(response.status_code, 200) - command = run_mock.call_args.args[0] + command, _call_kwargs = self._find_analyze_subprocess_call(run_mock) self.assertNotIn("--fast", command) payload = self._decode_json_response(response) self.assertNotIn("--fast", payload["diagnostics"]["timings"]["flagsUsed"])