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>
152 lines
5.8 KiB
Python
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])
|