Files
settled-reach/tooling/domains/assets/endpoints.py
T
jpmschweitzerandClaude Opus 5.5 ddce4441a9 refactor(tooling): T-1290 — the assets domain, where OFF is the normal case
tooling/db/ (a misnamed directory: connectors, not database work),
trellis-batch.sh and synth_ui_sounds.py become `reach assets`:
audio {health,generate,batch,post {convert,normalize,trim,pipeline}},
image {health,generate}, trellis {health,generate,batch}, and synth-ui.
The four audio bash wrappers are retired, and tooling/db/ is gone.

Parity, from baselines taken before anything moved:

- the four UI-sound WAVs and the harmonic-synth WAVs (exponential and linear
  decay) are byte-identical
- the ffmpeg pipeline's decoded PCM is identical. Its .ogg bytes are not,
  even between two runs of the OLD code: Ogg picks a random stream serial,
  so the encoded file was never the right thing to compare
- the network success paths can't be run in a gate (Stable Audio and Trellis
  are kept off, Gemini costs money), so tooling/test_assets.py stands up a
  fake Gradio and pins every payload: the audio submit, Trellis's six-call
  session sequence with its 9-input image_to_3d, and the Gemini body. It
  failed when one Trellis value was mutated (7.5 → 7.0)

Failure classification, in endpoints.py, is the point of the port. The
services are OFF by design (VRAM on tower-of-joy, D-17), and the topology doc
warns against "fixing" one by restarting it. So a refused connection says OFF
and asks for the service to be turned on rather than restarted; a 4xx/5xx says
the request was rejected; 401/403 says credentials; 429 says quota; and an
unreachable Gemini blames the network, not VRAM.

Behaviour changes, each a failure that used to read as success or crash:

- audio batch and trellis batch exited 0 with failures in their summaries;
  they now print the summary and exit 1
- trellis generate on a missing image crashed with a TypeError
  (print(..., indent=2)); it now names the file, and checks it before the
  service so a typo is not reported as an outage
- the ffmpeg pipeline left its intermediates behind when a step failed

Structure: the connectors called each other as subprocesses (batch spawned
the connector, which spawned audio_post) and parsed each other's stdout. They
are now function calls, and ffmpeg is the only exec, through core/process.
ensure_venv() is removed: it os.execv'd into .venv, which D-263's exec rule
forbids, and reach declares the dependencies itself. config.json moved into
the domain deliberately, and the local-services rule follows it.

Output contract: results are still JSON on stdout with the same keys, so skill
readers keep working. Failures are an exit status with a Fix line, never
{"ok": false}. The audio-gen, glb-gen and image-gen skills, Araminta's agent
file and the allow-list are updated to match. glb-gen's "trellis-batch.sh is
hardcoded to one category" caveat is gone: batch takes --input-dir or --names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:49:20 +02:00

152 lines
5.8 KiB
Python

"""Where the generators live, and how a call to one fails.
Two jobs, both shared by every connector in the domain:
1. **Endpoints and keys.** Base URLs come from `config.json` beside this file
(moved deliberately from tooling/db/config.json in T-1290). Keys come from the
environment only: config.json is tracked, so a config fallback for a paid
API key is how one gets committed.
2. **Failure classification.** Network failure is the NORMAL case here, not an
exception. Stable Audio and Trellis are kept stopped because VRAM on
tower-of-joy is scarce (system-admin-danoontje docs/topology.md, D-17), so
"connection refused" means *switched off*, and the remedy is to get it
turned on — never to restart it blindly, which takes VRAM from whatever is
running. That is a different answer from "the service rejected what you
sent", and a caller that cannot tell the two apart wastes a round trip
either way. `call()` maps every urllib failure to one of the two.
`ensure_venv()` (formerly in tooling/db/common.py) is gone: it re-exec'd the
script under .venv/bin/python via os.execv. Under reach the dependencies are
declared by the package itself, and an execv carrying reach's argv into another
interpreter would relaunch something that is not the command at all.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
from tooling.core.errors import ReachError
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
TOPOLOGY = "/var/mnt/data/projects/system-admin-danoontje/docs/topology.md"
# service key -> (display name, config key, fallback URL)
SERVICES: dict[str, tuple[str, str, str]] = {
"audio": ("Stable Audio Open", "stable_audio_url", "http://tower-of-joy:11500"),
"trellis": ("Trellis", "trellis_url", "http://tower-of-joy:11510"),
}
def load_config() -> dict:
"""The tracked endpoint configuration (URLs only, never secrets)."""
with open(CONFIG_PATH) as f:
return json.load(f)
def get_base_url(key: str, default: str) -> str:
"""Resolve a service base URL from config.json, with a fallback default."""
return load_config().get(key, default)
def base_url(service: str) -> str:
"""The configured base URL for one of SERVICES."""
_name, config_key, default = SERVICES[service]
return get_base_url(config_key, default)
def get_api_key(env_var: str) -> str:
"""An API key from the environment — environment-only, by design."""
key = os.environ.get(env_var)
if key:
return key
raise ReachError(
f"{env_var} is not set",
fix=(
f"export {env_var}, or add it to the machine-local "
".claude/settings.local.json env block (untracked). Never put keys in "
"tooling/domains/assets/config.json — it is tracked."
),
)
def unreachable(service: str, url: str, detail: str) -> ReachError:
"""The service did not answer at all.
For the tower-of-joy generators that is their normal resting state. For
anything else (the Gemini API) it means the network, not the service.
"""
if service not in SERVICES:
return ReachError(
f"{service} is not reachable at {url} ({detail})",
fix="check this machine's network connection, then re-run",
)
name = SERVICES[service][0]
return ReachError(
f"{name} is not reachable at {url} ({detail}). It is kept switched off to "
"save VRAM on tower-of-joy, so this usually means OFF, not broken.",
fix=(
f"ask for {name} to be turned on (something else may need to stop first "
f"to free VRAM — {TOPOLOGY}); do not restart it blindly. "
f"Then: reach assets {service} health"
),
)
def rejected(name: str, what: str, code: int, body: str) -> ReachError:
"""The service answered, and said no — the request is what needs changing."""
snippet = body.strip()[:500]
if code in (401, 403):
fix = "the service is up but refused the credentials — check the API key"
elif code == 429:
fix = "rate-limited or out of quota — wait, or check the account's quota"
else:
fix = "the service is up — check the arguments and inputs, then re-run"
return ReachError(
f"{name} rejected {what} (HTTP {code})" + (f": {snippet}" if snippet else ""),
fix=fix,
)
def call(
request: urllib.request.Request,
*,
service: str,
what: str,
timeout: float,
) -> bytes:
"""Perform one HTTP request, classifying any failure. Returns the body."""
name = SERVICES[service][0] if service in SERVICES else service
try:
with urllib.request.urlopen(request, timeout=timeout) as resp:
return resp.read()
except urllib.error.HTTPError as exc:
raise rejected(name, what, exc.code, exc.read().decode("utf-8", errors="replace")) from exc
except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
reason = getattr(exc, "reason", exc)
raise unreachable(service, _origin(request), str(reason)) from exc
def call_json(request: urllib.request.Request, *, service: str, what: str, timeout: float) -> Any:
"""`call()`, then parse JSON — an unparseable body is the service's fault, named as such."""
body = call(request, service=service, what=what, timeout=timeout)
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise ReachError(
f"{what}: the service answered with something that is not JSON: {body[:200]!r}",
fix="the service may be starting up or misconfigured — check its logs on tower-of-joy",
) from exc
def _origin(request: urllib.request.Request) -> str:
"""scheme://host:port of a request — the part that identifies the box."""
parts = request.full_url.split("/")
return "/".join(parts[:3])