From 47c523dd98722039190de8c369aeec31182b3a7f Mon Sep 17 00:00:00 2001 From: groot Date: Fri, 14 Aug 2026 06:17:30 +0200 Subject: [PATCH] RC-14 --- Dockerfile | 21 +- Dockerfile.customer-service | 3 +- Dockerfile.service-controller | 27 + Dockerfile.worker | 13 +- HOSTED_SERVICE.md | 8 + Makefile | 6 +- README.md | 10 +- SECURITY.md | 10 + SERVICE_CONTROLLER.md | 179 ++++++ TESTING.md | 13 + V4.2.10_ADMIN_DROP_DIVERSITY_FIX.md | 43 ++ V4.2.11_RARITY_CARD_STYLES.md | 36 ++ V4.2.12_RARITY_PROBABILITIES_ADMIN_DROPS.md | 67 +++ V4.3_SERVICE_CONTROLLER.md | 29 + cmd/customer-service/main.go | 36 +- cmd/service-controller/main.go | 127 ++++ docker-bake.hcl | 49 +- docker-compose.service-controller.yml | 17 + docker-compose.yml | 9 + internal/artifact/card.go | 225 +++++++- internal/artifact/collection.go | 272 +++++++-- internal/artifact/collection_test.go | 54 ++ internal/artifact/worker.go | 68 ++- internal/controllerui/controllerui.go | 15 + internal/controllerui/dist/app.js | 1 + internal/controllerui/dist/index.html | 1 + internal/controllerui/dist/styles.css | 1 + internal/customer/docker.go | 37 ++ internal/customer/runtime.go | 165 ++++++ internal/customer/runtime_controller_test.go | 51 ++ internal/customer/server.go | 476 ++++++++++++--- internal/customer/store.go | 135 ++++- internal/customerui/dist/admin/app.js | 6 +- internal/customerui/dist/admin/index.html | 3 +- internal/customerui/dist/admin/styles.css | 1 + internal/data/admin_control_test.go | 5 +- internal/data/schema.sql | 2 + internal/data/store.go | 76 ++- internal/server/server.go | 7 +- internal/servicecontroller/server.go | 573 +++++++++++++++++++ internal/settings/settings.go | 135 +++-- internal/settings/settings_test.go | 22 + internal/webui/dist/app.js | 18 +- 43 files changed, 2766 insertions(+), 286 deletions(-) create mode 100644 Dockerfile.service-controller create mode 100644 SERVICE_CONTROLLER.md create mode 100644 V4.2.10_ADMIN_DROP_DIVERSITY_FIX.md create mode 100644 V4.2.11_RARITY_CARD_STYLES.md create mode 100644 V4.2.12_RARITY_PROBABILITIES_ADMIN_DROPS.md create mode 100644 V4.3_SERVICE_CONTROLLER.md create mode 100644 cmd/service-controller/main.go create mode 100644 docker-compose.service-controller.yml create mode 100644 internal/controllerui/controllerui.go create mode 100644 internal/controllerui/dist/app.js create mode 100644 internal/controllerui/dist/index.html create mode 100644 internal/controllerui/dist/styles.css create mode 100644 internal/customer/runtime.go create mode 100644 internal/customer/runtime_controller_test.go create mode 100644 internal/servicecontroller/server.go create mode 100644 internal/settings/settings_test.go diff --git a/Dockerfile b/Dockerfile index b654200..7011835 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,31 @@ FROM golang:1.26-alpine AS build WORKDIR /src -# Keep dependency resolution deterministic. Do NOT run `go mod tidy` before -# source files are present: it can remove required modules because no packages -# are visible yet. +# Resolve dependencies before copying source. Do not run `go mod tidy` here. COPY go.mod go.sum ./ RUN go mod download COPY cmd ./cmd COPY internal ./internal -# Fail early with a useful message if a repository/.dockerignore rule ever -# drops the application's internal data package from the build context. +# Fail early if repository ignore rules accidentally remove runtime sources. RUN test -f /src/internal/data/store.go \ && test -f /src/internal/data/schema.sql -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt ./cmd/server \ - && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt-client ./cmd/client +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath -ldflags="-s -w" \ + -o /out/neuralhunt ./cmd/server FROM alpine:3.24 -RUN adduser -D -H app && mkdir -p /data /app && chown -R app:app /data /app +RUN adduser -D -h /home/app app \ + && mkdir -p /app /data \ + && chown -R app:app /app /data WORKDIR /app + COPY --from=build /out/neuralhunt /app/neuralhunt -COPY --from=build /out/neuralhunt-client /app/neuralhunt-client + USER app ENV SQLITE_PATH=/data/neuralhunt.db \ ARTIFACT_DIR=/data/artifacts -EXPOSE 8080 +EXPOSE 8080 8081 ENTRYPOINT ["/app/neuralhunt"] diff --git a/Dockerfile.customer-service b/Dockerfile.customer-service index 49c5fd7..1413264 100644 --- a/Dockerfile.customer-service +++ b/Dockerfile.customer-service @@ -13,7 +13,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /out/neuralhunt-customer-service ./cmd/customer-service FROM alpine:3.24 -RUN adduser -D -h /home/app app \ +RUN apk add --no-cache ca-certificates \ + && adduser -D -h /home/app app \ && mkdir -p /app /customer-data \ && chown -R app:app /app /customer-data WORKDIR /app diff --git a/Dockerfile.service-controller b/Dockerfile.service-controller new file mode 100644 index 0000000..db008bb --- /dev/null +++ b/Dockerfile.service-controller @@ -0,0 +1,27 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src + +# Resolve dependencies before copying source. Do not run `go mod tidy` here. +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd ./cmd +COPY internal ./internal + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath -ldflags="-s -w" \ + -o /out/neuralhunt-service-controller ./cmd/service-controller + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates \ + && mkdir -p /app +WORKDIR /app + +COPY --from=build /out/neuralhunt-service-controller /app/neuralhunt-service-controller + +# docker.sock access normally requires root. Prefer a Docker Socket Proxy in +# production and point DOCKER_HOST at it if you want to run this process with a +# less privileged container user. +USER root +EXPOSE 8101 8102 +ENTRYPOINT ["/app/neuralhunt-service-controller"] diff --git a/Dockerfile.worker b/Dockerfile.worker index 0b8122d..35080a4 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -13,15 +13,22 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /out/neuralhunt-client ./cmd/client FROM alpine:3.24 -RUN adduser -D -h /home/app app \ +RUN apk add --no-cache su-exec \ + && adduser -D -h /home/app app \ && mkdir -p /app /identity \ && chown -R app:app /app /identity WORKDIR /app COPY --from=build /out/neuralhunt-client /app/neuralhunt-client +COPY docker/worker-entrypoint.sh /usr/local/bin/neuralhunt-worker-entrypoint +RUN chmod 0555 /usr/local/bin/neuralhunt-worker-entrypoint -USER app +# The entrypoint starts as root only long enough to normalize ownership of the +# named identity volume. It immediately execs the client as the unprivileged +# `app` user. Customer Service grants only the bootstrap capabilities required +# for this transition (CHOWN, DAC_OVERRIDE, SETUID, SETGID). +USER root ENV HOME=/home/app VOLUME ["/identity"] -ENTRYPOINT ["/app/neuralhunt-client"] +ENTRYPOINT ["/usr/local/bin/neuralhunt-worker-entrypoint"] CMD ["-help"] diff --git a/HOSTED_SERVICE.md b/HOSTED_SERVICE.md index 2b9df1a..f8038bf 100644 --- a/HOSTED_SERVICE.md +++ b/HOSTED_SERVICE.md @@ -267,3 +267,11 @@ Hosted Worker erkennen stille/halb-offene Game-WebSockets nach spätestens etwa Neue Worker-Container werden mit Docker `RestartPolicy=unless-stopped` erzeugt. Bewusstes Stoppen über das Portal/Admin bleibt gestoppt. Die interne Worker-Registrierung akzeptiert nur Worker mit aktivem `running`/`starting`-Lease, sodass die Restart-Policy keine Sperre umgeht. Im privaten Customer Admin kann das konfigurierte `CS_WORKER_IMAGE` explizit neu gepullt werden. Ein einzelner Worker oder alle Worker können anschließend mit dem neuen Image neu erzeugt werden. Named Identity Volumes werden dabei nicht entfernt; laufende Worker werden nach dem Update wieder gestartet, gestoppte bleiben gestoppt. + +## Distributed Service Controllers (V4.3) + +Worker orchestration can now be separated from the commercial Customer Service. In `controller` mode, Customer Service keeps customer accounts, prepaid billing, PayPal, reward delegation, worker configuration and leases, while Docker lifecycle operations are sent to authenticated Service Controllers on remote worker hosts. + +This means the Customer Service no longer needs `docker.sock` in controller-only deployments. Existing public Customer Portal endpoints and worker semantics remain unchanged. `hybrid` mode is provided as a migration path for already-created local worker identity volumes. + +See `SERVICE_CONTROLLER.md` for the full topology and deployment settings. diff --git a/Makefile b/Makefile index 6408e0c..3d53d8c 100644 --- a/Makefile +++ b/Makefile @@ -13,18 +13,20 @@ build: go build ./cmd/server go build ./cmd/client go build ./cmd/customer-service + go build ./cmd/service-controller test: go test ./... -# Build the same three artifacts that are intended for three independent CI jobs. +# Build the four independently deployable runtime images. images: docker build -f Dockerfile.server -t $${NEURALHUNT_SERVER_IMAGE:-neuralhunt-server:local} . docker build -f Dockerfile.customer-service -t $${NEURALHUNT_CUSTOMER_IMAGE:-neuralhunt-customer-service:local} . docker build -f Dockerfile.worker -t $${CS_WORKER_IMAGE:-neuralhunt-worker:local} . + docker build -f Dockerfile.service-controller -t $${NEURALHUNT_SERVICE_CONTROLLER_IMAGE:-neuralhunt-service-controller:local} . images-compose: - docker compose --profile images build app customer-service worker-image + docker compose --profile images build app customer-service worker-image service-controller-image hosted-up: images docker compose --profile hosted up -d diff --git a/README.md b/README.md index fc18600..947fdfa 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,7 @@ Ablauf: 1. Im Admin-Tab **ARTIFACT** kann `/data/artifacts/_collection/character_anchor.png` einmalig manuell erzeugt werden. Der Anchor entsteht nur aus dem neutralen RIFT-Masterprompt und enthält bewusst **keinen Task-Style**. Ist beim ersten Gewinner noch kein Anchor vorhanden, erzeugt der Worker ihn weiterhin automatisch als Sicherheits-Fallback. 2. Im Admin-Tab **TASK ACTIONS** kann jeder Task ein eigenes JPEG-/PNG-Stylebild erhalten. Es wird content-addressed unter `data/artifacts/_styles/.` gespeichert und über `tasks.nft_style_reference` dem Task zugeordnet. Ein Folge-Task erbt dieselbe Style-Referenz. Ohne Custom-Style wird das eingebettete `internal/artifact/assets/style_reference.jpg` als Default verwendet. 3. Für jede RIFT-Karte sendet Neural Hunt zwei Bildinputs an `/v1/images/edits`: **Image 1 = globaler Character Anchor (Identität)** und **Image 2 = Task Style Reference (Rendering-Look)**. Der Prompt weist das Modell explizit an, Gesicht/Fell/Proportionen aus Image 1 und Rendering-Technik/Material/Licht/Farbverhalten aus Image 2 zu übernehmen. Der Kartenprompt erzwingt dabei bewusst keinen festen 3D-Look mehr: ein Task-Style darf z. B. 3D-Cartoon, Cel-Shading, Comic, malerisch, Watercolor-artig, Clay/Toy, Low-Poly oder Retro-Game sein, solange RIFT als Charakter erkennbar und die Ausgabe nicht fotorealistisch bleibt. -4. `internal/artifact/collection.go` wählt deterministisch Theme, Outfit, Accessoires, Szene, Mood, Pose, Atmosphäre, Rarity und Akzentfarben. Task-spezifische kreative Vorgaben und Ausschlüsse bleiben optionale Overrides. +4. `internal/artifact/collection.go` wählt deterministisch Theme, Outfit, Accessoires, Szene, Mood, Pose, Atmosphäre, Rarity und Akzentfarben. Die Rarity-Verteilung (Common / Uncommon / Rare / Ultra Rare / Special Illustration Rare) ist im Server-Admin unter ARTIFACT prozentual konfigurierbar; die Summe muss 100 % ergeben. Die tatsächlich zugewiesene Rarity wird am Collectible persistiert. Task-spezifische kreative Vorgaben und Ausschlüsse bleiben optionale Overrides. 5. Das generierte PNG wird als `art.png` gespeichert. `internal/artifact/card.go` baut daraus anschließend das finale `image.svg` im Format 1024×1536. Die KI muss daher keine Karten-Typografie oder UI exakt rendern. 6. Das Manifest bindet sowohl Roh-Art als auch fertige Karte per SHA-256 und speichert Collection-Traits sowie die verwendete Task-Style-Referenz/Provider-Metadaten. @@ -750,3 +750,11 @@ New identities can be given one-time Hashcash-style proof-of-work plus a short w Before every OpenAI image request the worker checks rolling 1-hour/24-hour call caps and a rolling estimated 24-hour USD budget. If the breaker trips, a winner artifact is returned to `pending` with a diagnostic message and is retried after the hold period rather than spending more immediately. Configure `openai_max_calls_1h`, `openai_max_calls_24h`, `openai_max_cost_24h_usd`, and `openai_budget_reserve_usd` in Admin → Runtime. Successful provider usage remains the basis for the local estimate. For public operation, `JWT_SECRET` must be a unique random string of at least 32 characters and `ADMIN_PASSWORD` must be at least 16 characters; known development defaults make startup fail. `ALLOW_INSECURE_DEV_DEFAULTS=1` exists only for local throwaway development. + +## Distributed Worker Hosts / Service Controller (V4.3) + +For larger hosted installations, Customer Service no longer has to run every worker on its own Docker host. `cmd/service-controller` is a private worker-host agent that owns Docker access on a remote server, registers at Customer Service, and receives the existing worker lifecycle operations from the Master. + +The deployment remains backward compatible through `CS_WORKER_ORCHESTRATION_MODE=direct|controller|hybrid`. Use `hybrid` when upgrading an installation with existing local identity volumes; use `controller` once Customer Service should no longer receive `docker.sock` at all. + +Build the fourth image with `Dockerfile.service-controller`. Configuration, VPN topology, controller emergency UI and failure behavior are documented in `SERVICE_CONTROLLER.md`. diff --git a/SECURITY.md b/SECURITY.md index a162fbb..366f0cc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -69,3 +69,13 @@ PayPal live mode is deliberately gated, manual credit grants are disabled by default and available only from the private Customer Service admin listener, and all credit changes are written to an idempotent ledger. These controls are operational safeguards, not a legal classification of the commercial product. + +## Service Controller trust boundary (V4.3) + +A Service Controller is privileged infrastructure because it controls a Docker Engine. Its control listener (`SC_CONTROL_ADDR`, default `:8102`) and emergency admin listener (`SC_ADMIN_ADDR`, default `:8101`) must remain on a private/VPN network. Do not route either through the public Customer Portal. + +Master/controller traffic is authenticated with the separate `SERVICE_CONTROLLER_SHARED_SECRET`. Controller registration is not an unauthenticated service-discovery mechanism. Customer Service stores the authenticated controller's advertised private URL and routes only worker-runtime operations to it. + +In `CS_WORKER_ORCHESTRATION_MODE=controller`, remove the Docker socket from Customer Service entirely. On worker hosts, a restricted Docker Socket Proxy is preferable to raw `/var/run/docker.sock` when available. + +Worker identity volumes are host-sticky. Customer Service never silently reassigns a worker with an existing container/identity volume to another controller. This avoids replacing a private P-256 identity with a new empty volume during a host outage. diff --git a/SERVICE_CONTROLLER.md b/SERVICE_CONTROLLER.md new file mode 100644 index 0000000..8d3f0c2 --- /dev/null +++ b/SERVICE_CONTROLLER.md @@ -0,0 +1,179 @@ +# Neural Hunt Service Controller + +V4.3 adds a distributed worker-host control plane. The existing Customer Service remains the **Master** for customers, prepaid billing, worker leases, task assignment and reward delegation. A Service Controller is a small process that runs on each worker host, owns that host's Docker access and registers itself with the Master. + +## Architecture + +```text + Game Server + https://play.example + ^ + | + Worker traffic + | +Customer Portal/Master | +customer-service :8090 | +admin :8091 | +internal :8092 | + | | + | authenticated private control + +------------+-------------------+ + | | + Controller A Controller B + :8102 API :8102 API + :8101 UI :8101 UI + | | + docker.sock docker.sock + | | + Worker A1,A2... Worker B1,B2... +``` + +The public Customer Portal API does **not** change. Browser, CLI, game server and worker image remain compatible. Only worker runtime operations are routed through the controller assigned to that worker. + +## Modes on Customer Service + +`CS_WORKER_ORCHESTRATION_MODE` supports: + +- `direct` — legacy mode; Customer Service talks to its local Docker Engine directly. +- `controller` — Customer Service does not need `docker.sock`; new workers must run on registered Service Controllers. +- `hybrid` — new workers prefer Service Controllers, while local Docker remains a fallback and preserves pre-controller workers. + +For a safe upgrade from an existing installation, start with `hybrid`. Existing workers that already have a local container remain sticky to `local`; they are never silently moved because their named identity volume lives on that host. New workers are assigned to the least-loaded online controller. + +## Master configuration + +Generate a **new independent secret**: + +```env +CS_WORKER_ORCHESTRATION_MODE=controller +SERVICE_CONTROLLER_SHARED_SECRET=<32+ random characters> +CS_CONTROLLER_OFFLINE_AFTER=45s + +CS_WORKER_IMAGE=git.example/neuralhunt/worker:worker_latest +CS_WORKER_AUTO_PULL=true + +# These two addresses must be usable by workers on remote hosts unless a +# controller-specific override is supplied. +CS_GAME_PUBLIC_URL=https://play.example.com +CS_WORKER_REGISTER_URL=http://10.20.0.10:8092/internal/workers/register +``` + +In `controller` mode the Customer Service container no longer needs `/var/run/docker.sock`. Keep `:8092` reachable only over the private/VPN network because it handles controller registration and worker leases. + +## Controller configuration + +Every worker host needs a stable unique `SC_ID`: + +```env +SC_ID=worker-host-01 +SC_NAME=Worker Host 01 + +SC_MASTER_URL=http://10.20.0.10:8092 +SC_ADVERTISE_URL=http://10.20.0.21:8102 +SERVICE_CONTROLLER_SHARED_SECRET= + +SC_CONTROL_ADDR=:8102 +SC_ADMIN_ADDR=:8101 +SC_ADMIN_USER=admin +SC_ADMIN_PASSWORD= +SC_ADMIN_COOKIE_SECURE=auto + +SC_MAX_WORKERS=500 +SC_MAX_RUNNING_WORKERS=100 +SC_HEARTBEAT_INTERVAL=15s + +# Host-specific Docker topology overrides. `bridge` is usually sufficient when +# the game is public HTTPS and the Master VPN IP is routed from Docker. +SC_WORKER_NETWORK=bridge +SC_WORKER_REGISTER_URL=http://10.20.0.10:8092/internal/workers/register +SC_GAME_PUBLIC_URL=https://play.example.com + +SC_WORKER_IMAGE=git.example/neuralhunt/worker:worker_latest +SC_WORKER_REGISTRY_SERVER=git.example +SC_WORKER_REGISTRY_USERNAME= +SC_WORKER_REGISTRY_PASSWORD= + +DOCKER_HOST=unix:///var/run/docker.sock +``` + +`SC_WORKER_NETWORK`, `SC_WORKER_REGISTER_URL` and `SC_GAME_PUBLIC_URL` override the values sent by the Master only on that particular worker host. This is useful when remote Docker networks differ from the central host. + +## Registration and placement + +A controller posts a shared-secret-authenticated heartbeat to: + +```text +POST /internal/controllers/register +``` + +The Master stores its ID, private control URL, capacities, reported worker counts and last heartbeat. A controller becomes unavailable for **new** assignments when: + +- its heartbeat is older than `CS_CONTROLLER_OFFLINE_AFTER`, +- it is disabled by the Master admin, +- it is in `DRAIN` mode, +- or its worker inventory capacity is full. + +Placement is sticky. After a worker is assigned to `worker-host-02`, all start/stop/image/identity/delete operations for that worker are sent to that same controller. The Master never silently creates an empty identity volume on another host. + +## Customer Admin integration + +The private Customer Service Admin now shows all registered controllers with: + +- online/offline state, +- heartbeat, +- configured capacity, +- reported host counts, +- Master-assigned worker/running counts, +- `DRAIN` / `DRAIN AUFHEBEN`, +- `AKTIVIEREN` / `DEAKTIVIEREN`. + +The existing per-worker **IMAGE UPDATE** and **IMAGE LADEN · ALLE WORKER AKTUALISIEREN** actions are distributed automatically. A bulk image update pulls the configured worker image once on every host involved and recreates workers on their existing identity host. + +## Emergency UI + +Each controller has a separate private emergency UI on `SC_ADMIN_ADDR` (default `:8101`). It intentionally exposes only containers labeled as Neural-Hunt-managed. It can: + +- view local worker/container state, +- start a local container, +- stop a local container, +- restart a local container, +- stop all local Neural-Hunt workers, +- pull the configured worker image, +- see whether the last registration/heartbeat to the Master succeeded and the last error if it did not. + +Use this UI only for host-level emergencies. The Master remains authoritative for billing, customer status and leases. If an emergency action starts a worker whose Master lease is revoked, the worker cannot resume hunting. + +## Billing and failure behavior + +Worker leases are still issued by Customer Service. V4.3 records `last_lease_at`, which gives the Master a controller-independent liveness signal. Billing therefore does not depend solely on a successful Docker API query to a remote host. + +If a controller goes offline, existing workers are not automatically migrated because their private identity volume is host-local. The Master keeps the assignment and rejects destructive operations that would risk orphaning a volume. Use `hybrid` during migration of old local workers, or explicitly export/import an identity when moving an existing worker to another host. + +## Security + +- Never publish `:8102` to the Internet. It can create/start/stop Docker workloads. +- Never publish `:8101` publicly either; keep it behind VPN/private routing. +- Use a separate `SERVICE_CONTROLLER_SHARED_SECRET`; do not reuse JWT, Customer-Service or admin secrets. +- The controller gets Docker access; treat it as privileged infrastructure. +- Prefer a Docker Socket Proxy with only the required endpoints if practical. +- Registry credentials remain server-side. Normal Master operations send the Docker `X-Registry-Auth` value only to the authenticated controller; workers never receive registry credentials. +- `SC_ID` must remain stable for a host. + +## Image build + +A fourth independent pipeline image is now available: + +```text +Dockerfile.server +Dockerfile.customer-service +Dockerfile.worker +Dockerfile.service-controller +``` + +Example registry tag: + +```text +git.example/neuralhunt/service-controller:v4.3 +``` + +The sample `docker-compose.service-controller.yml` is intended for a Linux worker host and uses host networking for the controller itself plus `docker.sock` for orchestration. Adapt the private/VPN routing to your environment. diff --git a/TESTING.md b/TESTING.md index ea0453d..1f8736f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -519,3 +519,16 @@ For production/open registration, do not rely on PoW as a one-human/one-account 5. Im Customer Admin bei einem Worker `IMAGE UPDATE` ausführen. Vorher laufend -> danach wieder laufend; vorher gestoppt -> danach weiterhin gestoppt. `/identity/identity.json` und Worker-Client-ID müssen erhalten bleiben. 6. Bei einem mutable Tag (z. B. `worker_latest`) ein neues Registry-Image publizieren und `IMAGE LADEN · ALLE WORKER AKTUALISIEREN` verwenden. Docker muss den Tag erneut pullen, obwohl er lokal bereits existiert. 7. Einen Worker bewusst über Portal/Admin stoppen. Die Docker-Restart-Policy `unless-stopped` darf ihn nicht wieder starten. Bei gesperrtem Benutzer oder widerrufenem Lease muss `/internal/workers/register` mit 409 ablehnen. + +## V4.3 Service Controller smoke test + +1. Start Customer Service with `CS_WORKER_ORCHESTRATION_MODE=controller`, a 32+ character `SERVICE_CONTROLLER_SHARED_SECRET`, and no mounted docker.sock. +2. Start one Service Controller on a worker host with matching secret, stable `SC_ID`, `SC_MASTER_URL` pointing to Customer Service `:8092`, and `SC_ADVERTISE_URL` pointing to the controller's private `:8102` address. +3. In Customer Admin, verify the controller becomes `ONLINE` and its heartbeat/capacity appears. +4. Create/start a customer worker. Verify its `controller_id` is the registered host and the actual container exists only on that host. +5. Download/upload the worker identity from the normal Customer Portal; both operations must be proxied through the assigned controller. +6. Use per-worker `IMAGE UPDATE`; verify the image is pulled/recreated on the remote host while the same named identity volume remains. +7. Put the controller into `DRAIN`; existing workers remain controllable, but a new worker must be placed on another online controller (or fail cleanly if none exists). +8. Stop the controller process and wait longer than `CS_CONTROLLER_OFFLINE_AFTER`; Master admin must show `OFFLINE` and must not assign new workers there. +9. Use the controller emergency UI on `:8101` over VPN to stop/restart a local managed worker and to execute `ALLE STOPPEN`. +10. Verify direct mode still works unchanged with `CS_WORKER_ORCHESTRATION_MODE=direct` and local docker.sock. diff --git a/V4.2.10_ADMIN_DROP_DIVERSITY_FIX.md b/V4.2.10_ADMIN_DROP_DIVERSITY_FIX.md new file mode 100644 index 0000000..1879e8d --- /dev/null +++ b/V4.2.10_ADMIN_DROP_DIVERSITY_FIX.md @@ -0,0 +1,43 @@ +# V4.2.10 – Admin Drop Diversity Fix + +## Problem +Admin-generated NFT gifts (`artifact_origin=admin_drop`) could end up visually too close to an already owned winning NFT. In practice this produced near-duplicate collectibles with the same visible theme (for example two very similar `Apex Racer` cards), which weakens the collector value. + +## Root cause +The collection prompt already varied by task/winner/seed, but the visible trait selection was still narrow enough that an admin drop could collide with a previously owned card: + +- the same high-level theme could be selected again, +- pose and atmosphere could also land close to an earlier card, +- the prompt did not strongly enough enforce cross-card distinctness. + +This was not an admin queue problem anymore; it was a *collectible diversity* problem. + +## Fix +The artifact generator now adds a collector-aware diversity pass for `admin_drop` items: + +1. Before rendering an admin drop, the artifact worker loads up to 128 already-owned ready collectibles for the target owner. +2. Their visible collection traits are re-derived. +3. The new admin drop then deliberately prefers the **least-used** values for: + - theme + - pose + - atmosphere + - camera / staging + - palette story + - scene emphasis +4. The image prompt now includes stronger art-direction sections: + - `CAMERA / STAGING` + - `COLOR STORY` + - `SCENE EMPHASIS` + - `COLLECTOR UNIQUENESS REQUIREMENT` + +This makes admin gifts much less likely to repeat a card the account already owns. + +## Scope +Only the collection-artifact path changed. No database migration is required. + +## Deployment +Rebuild only: + +- `Dockerfile.server` + +Customer-Service and Worker images do not need changes for this fix. diff --git a/V4.2.11_RARITY_CARD_STYLES.md b/V4.2.11_RARITY_CARD_STYLES.md new file mode 100644 index 0000000..f768d4b --- /dev/null +++ b/V4.2.11_RARITY_CARD_STYLES.md @@ -0,0 +1,36 @@ +# V4.2.11 – Rarity Card Styles + +## Goal +Introduce distinct collectible rarity tiers with visibly different programmatic card layouts. + +## New rarity tiers +The collection preset now uses these tiers: + +- Common +- Uncommon +- Rare +- Ultra Rare +- Special Illustration Rare + +The rarity is still deterministic from task / winner / seed. + +## What changed +The final card is still rendered by Go/SVG after the image model returns the raw full-art illustration. The card renderer now applies rarity-dependent styling: + +- **Common**: restrained steel look, low holo intensity. +- **Uncommon**: brighter emerald accents, a bit more sparkle. +- **Rare**: stronger holo and foil streaks. +- **Ultra Rare**: warm gold / pink premium treatment with stronger glow. +- **Special Illustration Rare**: most elaborate effect pass, premium badge, high holo intensity, richer sparkle and foil overlays. + +The image-generation prompt was also updated so the illustration can gently reflect the assigned rarity in its staging / spectacle, while the actual collectible-frame treatment remains deterministic and programmatic. + +## Admin/UI note +The Server Admin "PIPELINE" description now mentions the new rarity tiers. + +## Deployment +Rebuild only: + +- `Dockerfile.server` + +Customer-Service and Worker images do not need changes. diff --git a/V4.2.12_RARITY_PROBABILITIES_ADMIN_DROPS.md b/V4.2.12_RARITY_PROBABILITIES_ADMIN_DROPS.md new file mode 100644 index 0000000..d096d96 --- /dev/null +++ b/V4.2.12_RARITY_PROBABILITIES_ADMIN_DROPS.md @@ -0,0 +1,67 @@ +# V4.2.12 – Configurable Rarity Probabilities + Admin Drop Override + +## Global rarity probabilities +The Server Admin → ARTIFACT view now contains a **RARITY-CHANCEN** section. + +The five percentages must add up to exactly 100%: + +- Common +- Uncommon +- Rare +- Ultra Rare +- Special Illustration Rare + +Defaults preserve the V4.2.11 distribution: + +- Common: 58.00% +- Uncommon: 29.50% +- Rare: 9.90% +- Ultra Rare: 2.25% +- Special Illustration Rare: 0.35% + +The values are stored in the existing runtime settings JSON. Existing installations therefore need no separate settings-table migration. + +## Stable assigned rarity +A new `tasks.artifact_rarity` field stores the rarity actually assigned to a collectible. The worker assigns it before the image API call and keeps it for retries / later regeneration. + +This is separate from `artifact_rarity_override`: + +- `artifact_rarity_override`: optional Admin instruction for a gift +- `artifact_rarity`: actual permanently assigned tier for the collectible + +Normal winner cards use the configured probability distribution. Once assigned, changing the distribution does not change their rarity. + +## Admin gifts +ADMIN NFT DROP now offers: + +- RANDOM / according to global chances +- COMMON +- UNCOMMON +- RARE +- ULTRA RARE +- SPECIAL ILLUSTRATION RARE + +When a fixed tier is selected, all cards in that specific Admin-Drop request use that tier. Their artwork still receives the collector-diversity logic introduced in V4.2.10. + +## APIs / gallery +The public artifact list and authenticated `MEINE NFTS` list now expose `rarity` for newly assigned collectibles. The UI displays it next to the artifact metadata. + +## Environment defaults +Optional seed values: + +```env +DEFAULT_ARTIFACT_RARITY_COMMON_PCT=58.00 +DEFAULT_ARTIFACT_RARITY_UNCOMMON_PCT=29.50 +DEFAULT_ARTIFACT_RARITY_RARE_PCT=9.90 +DEFAULT_ARTIFACT_RARITY_ULTRA_RARE_PCT=2.25 +DEFAULT_ARTIFACT_RARITY_SPECIAL_ILLUSTRATION_PCT=0.35 +``` + +After settings have been saved in Admin, the stored runtime values are authoritative. + +## Deployment +Only the Server image changed: + +- `Dockerfile.server` + +No Customer-Service or Worker image rebuild is required. diff --git a/V4.3_SERVICE_CONTROLLER.md b/V4.3_SERVICE_CONTROLLER.md new file mode 100644 index 0000000..5b8f42b --- /dev/null +++ b/V4.3_SERVICE_CONTROLLER.md @@ -0,0 +1,29 @@ +# V4.3 – Distributed Service Controller + +V4.3 introduces a fourth deployable process/image: `neuralhunt-service-controller`. + +The Customer Service remains the Master for customer accounts, credits, billing, worker configuration, worker leases and reward ownership. Docker orchestration can now be delegated to one or more remote Service Controllers. + +Highlights: + +- `CS_WORKER_ORCHESTRATION_MODE=direct|controller|hybrid` +- authenticated controller registration + heartbeat at Customer Service `:8092` +- bidirectional registration health check: the Master only accepts a controller it can call back +- automatic least-loaded placement for new workers +- sticky `controller_id` per worker so host-local identity volumes are never silently moved +- remote start / stop / remove / image pull / image update / identity download / identity upload +- distributed "update all worker images" across all assigned hosts +- controller capacity + heartbeat shown in Customer Admin +- Master-side `DRAIN` and enable/disable controls +- private emergency UI on every Service Controller (`:8101`) +- controller-independent `last_lease_at` liveness signal for prepaid billing +- Customer Service can run with no docker.sock at all in `controller` mode +- direct mode stays compatible with the previous single-host setup + +New pipeline Dockerfile: + +```text +Dockerfile.service-controller +``` + +See `SERVICE_CONTROLLER.md` for configuration and deployment topology. diff --git a/cmd/customer-service/main.go b/cmd/customer-service/main.go index 85febbd..a1260e4 100644 --- a/cmd/customer-service/main.go +++ b/cmd/customer-service/main.go @@ -138,6 +138,16 @@ func validate(cfg customer.Config) error { if cfg.NewCustomerCreditsMicros < 0 || cfg.PositiveTipCreditsMicros < 0 { return fmt.Errorf("CS_NEW_CUSTOMER_CREDITS and CS_POSITIVE_TIP_CREDITS must be >= 0") } + mode := strings.ToLower(strings.TrimSpace(cfg.WorkerOrchestrationMode)) + if mode == "" { + mode = "direct" + } + if mode != "direct" && mode != "controller" && mode != "hybrid" { + return fmt.Errorf("CS_WORKER_ORCHESTRATION_MODE must be direct, controller or hybrid") + } + if (mode == "controller" || mode == "hybrid") && len(strings.TrimSpace(cfg.ControllerSharedSecret)) < 32 { + return fmt.Errorf("SERVICE_CONTROLLER_SHARED_SECRET must be at least 32 characters in controller/hybrid mode") + } if _, err := customer.RegistryAuthHeader(cfg.WorkerRegistryUsername, cfg.WorkerRegistryPassword, cfg.WorkerRegistryServer); err != nil { return fmt.Errorf("worker registry auth: %w", err) } @@ -162,7 +172,7 @@ func main() { cfg := customer.Config{ PublicAddr: env("CS_HTTP_ADDR", ":8090"), AdminAddr: env("CS_ADMIN_HTTP_ADDR", ":8091"), InternalAddr: env("CS_INTERNAL_ADDR", ":8092"), PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://127.0.0.1:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://127.0.0.1:8081"), SharedSecret: env("CUSTOMER_SERVICE_SHARED_SECRET", ""), - DockerHost: env("DOCKER_HOST", "unix:///var/run/docker.sock"), WorkerImage: env("CS_WORKER_IMAGE", "neuralhunt-worker:local"), WorkerEntrypoint: env("CS_WORKER_ENTRYPOINT", ""), WorkerNetwork: env("CS_WORKER_NETWORK", "neuralhunt_backend"), WorkerRegisterURL: env("CS_WORKER_REGISTER_URL", "http://customer-service:8092/internal/workers/register"), WorkerAutoPull: boolEnv("CS_WORKER_AUTO_PULL", true), WorkerRegistryUsername: env("CS_WORKER_REGISTRY_USERNAME", ""), WorkerRegistryPassword: env("CS_WORKER_REGISTRY_PASSWORD", ""), WorkerRegistryServer: env("CS_WORKER_REGISTRY_SERVER", ""), WorkerRateMicrosPerMinute: int64(rate*1_000_000 + 0.5), MaxWorkersPerCustomer: intEnv("CS_MAX_WORKERS_PER_CUSTOMER", 20), MaxWorkersGlobal: intEnv("CS_MAX_WORKERS_GLOBAL", 1000), MaxRunningPerCustomer: intEnv("CS_MAX_RUNNING_WORKERS_PER_CUSTOMER", 10), MaxRunningGlobal: intEnv("CS_MAX_RUNNING_WORKERS_GLOBAL", 100), + DockerHost: env("DOCKER_HOST", "unix:///var/run/docker.sock"), WorkerImage: env("CS_WORKER_IMAGE", "neuralhunt-worker:local"), WorkerEntrypoint: env("CS_WORKER_ENTRYPOINT", ""), WorkerNetwork: env("CS_WORKER_NETWORK", "neuralhunt_backend"), WorkerRegisterURL: env("CS_WORKER_REGISTER_URL", "http://customer-service:8092/internal/workers/register"), WorkerOrchestrationMode: env("CS_WORKER_ORCHESTRATION_MODE", "direct"), ControllerSharedSecret: env("SERVICE_CONTROLLER_SHARED_SECRET", ""), ControllerOfflineAfter: durationEnv("CS_CONTROLLER_OFFLINE_AFTER", 45*time.Second), WorkerAutoPull: boolEnv("CS_WORKER_AUTO_PULL", true), WorkerRegistryUsername: env("CS_WORKER_REGISTRY_USERNAME", ""), WorkerRegistryPassword: env("CS_WORKER_REGISTRY_PASSWORD", ""), WorkerRegistryServer: env("CS_WORKER_REGISTRY_SERVER", ""), WorkerRateMicrosPerMinute: int64(rate*1_000_000 + 0.5), MaxWorkersPerCustomer: intEnv("CS_MAX_WORKERS_PER_CUSTOMER", 20), MaxWorkersGlobal: intEnv("CS_MAX_WORKERS_GLOBAL", 1000), MaxRunningPerCustomer: intEnv("CS_MAX_RUNNING_WORKERS_PER_CUSTOMER", 10), MaxRunningGlobal: intEnv("CS_MAX_RUNNING_WORKERS_GLOBAL", 100), SessionTTL: durationEnv("CS_SESSION_TTL", 24*time.Hour), CookieSecure: boolEnv("CS_COOKIE_SECURE", true), AdminCookieSecureMode: env("CS_ADMIN_COOKIE_SECURE", "auto"), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false), LoginEnabled: boolEnv("CS_CUSTOMER_LOGIN_ENABLED", true), RegistrationEnabled: boolEnv("CS_CUSTOMER_REGISTRATION_ENABLED", true), RegistrationPOWBits: intEnv("CS_REGISTRATION_POW_BITS", 16), RegistrationInviteRequired: boolEnv("CS_REGISTRATION_INVITE_REQUIRED", false), NewCustomerCreditsMicros: int64(floatEnv("CS_NEW_CUSTOMER_CREDITS", 0)*1_000_000 + 0.5), PositiveTipCreditsMicros: int64(floatEnv("CS_POSITIVE_TIP_CREDITS", 0)*1_000_000 + 0.5), PayPalEnabled: boolEnv("PAYPAL_ENABLED", false), PayPalEnvironment: env("PAYPAL_ENVIRONMENT", "sandbox"), PayPalWebhookID: env("PAYPAL_WEBHOOK_ID", ""), PayPalLiveApprovalAck: env("PAYPAL_LIVE_APPROVAL_ACK", ""), Packages: pkgs, @@ -178,15 +188,24 @@ func main() { if err := st.RecoverStartingWorkers(ctx); err != nil { log.Fatal(err) } - dc, err := customer.NewDockerClient(cfg.DockerHost) - if err != nil { - log.Fatal(err) - } - if err := dc.Ping(ctx); err != nil { - log.Fatalf("Docker Engine API unavailable: %v", err) + mode := strings.ToLower(strings.TrimSpace(cfg.WorkerOrchestrationMode)) + var runtime customer.WorkerRuntime + if mode == "direct" || mode == "hybrid" || mode == "" { + dc, err := customer.NewDockerClient(cfg.DockerHost) + if err != nil { + log.Fatal(err) + } + if err := dc.Ping(ctx); err != nil { + if mode == "direct" || mode == "" { + log.Fatalf("Docker Engine API unavailable: %v", err) + } + log.Printf("WARNING: local Docker fallback unavailable in hybrid mode: %v", err) + } else { + runtime = dc + } } pp := customer.NewPayPalClient(env("PAYPAL_CLIENT_ID", ""), env("PAYPAL_CLIENT_SECRET", ""), cfg.PayPalEnvironment) - svc := customer.NewService(st, dc, pp, cfg) + svc := customer.NewService(st, runtime, pp, cfg) if err := svc.SeedPortalSettings(ctx); err != nil { log.Fatal(err) } @@ -197,6 +216,7 @@ func main() { log.Printf("Hosted-Code/Reward-Control-Plane bereit: %s", cfg.GameAdminURL) } checkCancel() + log.Printf("worker orchestration mode: %s", strings.ToLower(strings.TrimSpace(cfg.WorkerOrchestrationMode))) go svc.RunBilling(ctx) servers := []*http.Server{ {Addr: cfg.PublicAddr, Handler: svc.PublicRoutes(customerui.Public()), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}, diff --git a/cmd/service-controller/main.go b/cmd/service-controller/main.go new file mode 100644 index 0000000..836e7f4 --- /dev/null +++ b/cmd/service-controller/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "neuralhunt/internal/controllerui" + "neuralhunt/internal/customer" + "neuralhunt/internal/servicecontroller" +) + +func loadDotEnv(path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if k == "" { + continue + } + if _, exists := os.LookupEnv(k); exists { + continue + } + if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { + v = v[1 : len(v)-1] + } + _ = os.Setenv(k, v) + } +} +func env(k, d string) string { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + return d +} +func intEnv(k string, d int) int { + v, e := strconv.Atoi(strings.TrimSpace(os.Getenv(k))) + if e != nil { + return d + } + return v +} +func durationEnv(k string, d time.Duration) time.Duration { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + x, e := time.ParseDuration(v) + if e != nil { + return d + } + return x +} + +func main() { + loadDotEnv(".env") + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + cfg := servicecontroller.Config{ + ID: env("SC_ID", ""), Name: env("SC_NAME", env("SC_ID", "Service Controller")), + MasterURL: strings.TrimRight(env("SC_MASTER_URL", ""), "/"), AdvertiseURL: strings.TrimRight(env("SC_ADVERTISE_URL", ""), "/"), SharedSecret: env("SERVICE_CONTROLLER_SHARED_SECRET", ""), + ControlAddr: env("SC_CONTROL_ADDR", ":8102"), AdminAddr: env("SC_ADMIN_ADDR", ":8101"), AdminUser: env("SC_ADMIN_USER", "admin"), AdminPassword: env("SC_ADMIN_PASSWORD", ""), AdminCookieSecureMode: env("SC_ADMIN_COOKIE_SECURE", "auto"), + MaxWorkers: intEnv("SC_MAX_WORKERS", 500), MaxRunning: intEnv("SC_MAX_RUNNING_WORKERS", 100), HeartbeatInterval: durationEnv("SC_HEARTBEAT_INTERVAL", 15*time.Second), + DefaultWorkerImage: env("SC_WORKER_IMAGE", env("CS_WORKER_IMAGE", "neuralhunt-worker:local")), WorkerNetworkOverride: env("SC_WORKER_NETWORK", ""), WorkerRegisterURLOverride: env("SC_WORKER_REGISTER_URL", ""), GameURLOverride: env("SC_GAME_PUBLIC_URL", ""), RegistryUsername: env("SC_WORKER_REGISTRY_USERNAME", env("CS_WORKER_REGISTRY_USERNAME", "")), RegistryPassword: env("SC_WORKER_REGISTRY_PASSWORD", env("CS_WORKER_REGISTRY_PASSWORD", "")), RegistryServer: env("SC_WORKER_REGISTRY_SERVER", env("CS_WORKER_REGISTRY_SERVER", "")), + } + if err := servicecontroller.ValidateConfig(cfg); err != nil { + log.Fatal(err) + } + docker, err := customer.NewDockerClient(env("DOCKER_HOST", "unix:///var/run/docker.sock")) + if err != nil { + log.Fatal(err) + } + pingCtx, pingCancel := context.WithTimeout(ctx, 5*time.Second) + err = docker.Ping(pingCtx) + pingCancel() + if err != nil { + log.Fatalf("Docker Engine API unavailable: %v", err) + } + svc := servicecontroller.New(docker, cfg) + servers := []*http.Server{ + {Addr: cfg.ControlAddr, Handler: svc.ControlRoutes(), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 45 * time.Second}, + {Addr: cfg.AdminAddr, Handler: svc.AdminRoutes(controllerui.Handler()), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}, + } + names := []string{"service-controller control/private", "service-controller admin/private"} + for i, s := range servers { + go func(n string, hs *http.Server) { + log.Printf("%s listener on %s", n, hs.Addr) + if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }(names[i], s) + } + // Start registration only after the control listener is live enough for the + // Master's bidirectional health check. + go func() { time.Sleep(250 * time.Millisecond); svc.RunRegistration(ctx) }() + log.Printf("service-controller %s (%s) registering at %s and advertising %s", cfg.ID, cfg.Name, cfg.MasterURL, cfg.AdvertiseURL) + <-ctx.Done() + shutdown, done := context.WithTimeout(context.Background(), 10*time.Second) + defer done() + for _, s := range servers { + _ = s.Shutdown(shutdown) + } + fmt.Println("service-controller stopped") +} diff --git a/docker-bake.hcl b/docker-bake.hcl index fc9cda7..8dd0729 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -1,38 +1,27 @@ -variable "NEURALHUNT_SERVER_IMAGE" { - default = "neuralhunt-server:local" -} +variable "NEURALHUNT_SERVER_IMAGE" { default = "neuralhunt-server:local" } +variable "NEURALHUNT_CUSTOMER_IMAGE" { default = "neuralhunt-customer-service:local" } +variable "NEURALHUNT_WORKER_IMAGE" { default = "neuralhunt-worker:local" } +variable "NEURALHUNT_SERVICE_CONTROLLER_IMAGE" { default = "neuralhunt-service-controller:local" } -variable "NEURALHUNT_CUSTOMER_IMAGE" { - default = "neuralhunt-customer-service:local" -} - -variable "CS_WORKER_IMAGE" { - default = "neuralhunt-worker:local" -} - -group "default" { - targets = ["server", "customer-service", "worker"] -} - -target "common" { - context = "." - dockerfile = "Dockerfile" -} +group "default" { targets = ["server", "customer-service", "worker", "service-controller"] } target "server" { - inherits = ["common"] - target = "server" - tags = [NEURALHUNT_SERVER_IMAGE] + context = "." + dockerfile = "Dockerfile.server" + tags = [NEURALHUNT_SERVER_IMAGE] } - target "customer-service" { - inherits = ["common"] - target = "customer-service" - tags = [NEURALHUNT_CUSTOMER_IMAGE] + context = "." + dockerfile = "Dockerfile.customer-service" + tags = [NEURALHUNT_CUSTOMER_IMAGE] } - target "worker" { - inherits = ["common"] - target = "worker" - tags = [CS_WORKER_IMAGE] + context = "." + dockerfile = "Dockerfile.worker" + tags = [NEURALHUNT_WORKER_IMAGE] +} +target "service-controller" { + context = "." + dockerfile = "Dockerfile.service-controller" + tags = [NEURALHUNT_SERVICE_CONTROLLER_IMAGE] } diff --git a/docker-compose.service-controller.yml b/docker-compose.service-controller.yml new file mode 100644 index 0000000..5bbb843 --- /dev/null +++ b/docker-compose.service-controller.yml @@ -0,0 +1,17 @@ +services: + service-controller: + image: ${NEURALHUNT_SERVICE_CONTROLLER_IMAGE:-neuralhunt-service-controller:local} + build: + context: . + dockerfile: Dockerfile.service-controller + env_file: + - path: .env.controller + required: true + # Linux worker host: the controller is reachable directly on the host/VPN + # addresses configured in SC_ADVERTISE_URL. Keep 8101/8102 firewalled to the + # private management network/VPN only. + network_mode: host + user: "0:0" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 5725de4..bdb9c1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,6 +78,15 @@ services: entrypoint: ["/bin/true"] restart: "no" + service-controller-image: + profiles: ["images"] + image: ${NEURALHUNT_SERVICE_CONTROLLER_IMAGE:-neuralhunt-service-controller:local} + build: + context: . + dockerfile: Dockerfile.service-controller + entrypoint: ["/bin/true"] + restart: "no" + volumes: neuralhunt_data: {} customer_data: {} diff --git a/internal/artifact/card.go b/internal/artifact/card.go index a93e8b4..18528a6 100644 --- a/internal/artifact/card.go +++ b/internal/artifact/card.go @@ -34,6 +34,168 @@ func trimLabel(s string, maxRunes int) string { return string(r[:maxRunes-1]) + "…" } +type rarityStyle struct { + Name string + AccentA string + AccentB string + MetalBase string + MetalEdge string + PillFill string + PillText string + EditionFill string + EditionStroke string + EditionText string + HeaderLine string + OuterGlow string + OuterGlowOpacity string + HoloOpacityA string + HoloOpacityB string + BorderOpacityA string + BorderOpacityB string + SparkleBoost int + FoilPattern string + BadgeText string + BadgeSubtext string +} + +func cardRarityStyle(traits collectionTraits) rarityStyle { + base := rarityStyle{ + Name: traits.Rarity, + AccentA: traits.AccentA, + AccentB: traits.AccentB, + MetalBase: "#f4f7ff", + MetalEdge: "#e9f2ff", + PillFill: "#060a12", + PillText: "#ffffff", + EditionFill: "#ffffff", + EditionStroke: "#ffffff", + EditionText: "#ffffff", + HeaderLine: "#ffc400", + OuterGlow: traits.AccentA, + OuterGlowOpacity: ".00", + HoloOpacityA: ".10", + HoloOpacityB: ".12", + BorderOpacityA: ".28", + BorderOpacityB: ".72", + SparkleBoost: 0, + BadgeText: traits.Rarity, + BadgeSubtext: "RIFT EDITION", + } + switch strings.ToUpper(strings.TrimSpace(traits.Rarity)) { + case "COMMON": + base.AccentA = "#7e8ca5" + base.AccentB = "#5f6c83" + base.MetalBase = "#d8dee9" + base.MetalEdge = "#aeb8c8" + base.HeaderLine = "#b9c2d4" + base.OuterGlowOpacity = ".00" + base.HoloOpacityA = ".05" + base.HoloOpacityB = ".06" + base.BorderOpacityA = ".20" + base.BorderOpacityB = ".45" + base.BadgeSubtext = "COMMON" + case "UNCOMMON": + base.AccentA = "#60eaa9" + base.AccentB = "#2dbf9a" + base.MetalBase = "#e8fff5" + base.MetalEdge = "#b4f1db" + base.HeaderLine = "#7bffc8" + base.HoloOpacityA = ".10" + base.HoloOpacityB = ".14" + base.BorderOpacityB = ".68" + base.SparkleBoost = 8 + base.BadgeSubtext = "UNCOMMON" + case "RARE": + base.AccentA = "#6ae4ff" + base.AccentB = "#6f7bff" + base.MetalBase = "#f0fbff" + base.MetalEdge = "#c8dcff" + base.HeaderLine = "#7ed8ff" + base.HoloOpacityA = ".16" + base.HoloOpacityB = ".18" + base.BorderOpacityA = ".30" + base.BorderOpacityB = ".82" + base.SparkleBoost = 18 + base.FoilPattern = `` + base.BadgeSubtext = "RARE" + case "ULTRA RARE": + base.AccentA = "#ffcf58" + base.AccentB = "#ff69c9" + base.MetalBase = "#fff6d8" + base.MetalEdge = "#ffe7a6" + base.HeaderLine = "#ffd86c" + base.OuterGlow = "#ffd670" + base.OuterGlowOpacity = ".30" + base.HoloOpacityA = ".24" + base.HoloOpacityB = ".28" + base.BorderOpacityA = ".36" + base.BorderOpacityB = ".92" + base.SparkleBoost = 34 + base.FoilPattern = `` + base.BadgeSubtext = "ULTRA" + case "SPECIAL ILLUSTRATION RARE": + base.AccentA = "#ffd46a" + base.AccentB = "#8b5fff" + base.MetalBase = "#fff7df" + base.MetalEdge = "#fff0bf" + base.PillFill = "#fff6d0" + base.PillText = "#241535" + base.EditionFill = "#fff8da" + base.EditionStroke = "#ffe596" + base.EditionText = "#241535" + base.HeaderLine = "#ffe17c" + base.OuterGlow = "#ffd76d" + base.OuterGlowOpacity = ".42" + base.HoloOpacityA = ".32" + base.HoloOpacityB = ".36" + base.BorderOpacityA = ".46" + base.BorderOpacityB = "1.00" + base.SparkleBoost = 56 + base.FoilPattern = `` + base.BadgeText = "SIR" + base.BadgeSubtext = "SPECIAL" + } + return base +} + +func raritySparkleCount(style rarityStyle) int { + count := 42 + style.SparkleBoost + if count < 16 { + count = 16 + } + return count +} + +func renderSparkles(h [32]byte, style rarityStyle) string { + count := raritySparkleCount(style) + var sparkles strings.Builder + for i := 0; i < count; i++ { + b0 := int(h[i%32]) + b1 := int(h[(i*7+5)%32]) + xv := 62 + (b0*37+i*97)%900 + yv := 54 + (b1*29+i*83)%1420 + r := 1 + (b0+i)%4 + op := 10 + (b1 % 30) + if style.SparkleBoost > 0 && i%9 == 0 { + r += 2 + op += 18 + } + if style.SparkleBoost > 24 && i%13 == 0 { + fmt.Fprintf(&sparkles, ``, xv, yv, minInt(98, op)) + continue + } + fmt.Fprintf(&sparkles, ``, xv, yv, r, minInt(98, op)) + } + return sparkles.String() +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + // renderCardSVG converts raw generated artwork into the deterministic final // collectible card. Keeping the typography/layout outside the image model makes // all cards line up exactly and avoids model-generated fake text/logos. @@ -50,52 +212,50 @@ func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) [] editionLabel = "ADMIN DROP" bitLabel = "COLLECTIBLE" } - - var sparkles strings.Builder + style := cardRarityStyle(traits) h := sha256.Sum256([]byte(x.ID + "|card|" + x.Winner + "|" + x.Seed)) - for i := 0; i < 42; i++ { - b0 := int(h[i%32]) - b1 := int(h[(i*7+5)%32]) - xv := 62 + (b0*37+i*97)%900 - yv := 54 + (b1*29+i*83)%1420 - r := 1 + (b0+i)%4 - op := 12 + (b1 % 34) - fmt.Fprintf(&sparkles, ``, xv, yv, r, op) - } + sparkles := renderSparkles(h, style) svg := fmt.Sprintf(` - + - + + + + + + - - + + + %s %s - - + + NEURAL HUNT - + %s - - - %s + + + %s + %s @@ -106,17 +266,30 @@ func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) [] - - %s + + %s + %s %s · ORIGINAL FULL-ART SERIES `, - traits.AccentA, traits.AccentB, traits.AccentA, traits.AccentB, - mime, encoded, sparkles.String(), - series, rarity, editionLabel, theme, bitLabel, completed, traits.AccentA, traits.AccentB, + style.MetalBase, style.AccentA, style.AccentB, style.MetalEdge, + style.AccentA, style.AccentB, + style.AccentA, style.AccentB, + style.AccentB, style.AccentA, + style.OuterGlow, style.OuterGlowOpacity, style.OuterGlow, + style.OuterGlow, style.OuterGlowOpacity, + mime, encoded, style.HoloOpacityA, style.HoloOpacityB, style.FoilPattern, sparkles, + style.BorderOpacityA, style.BorderOpacityB, + style.HeaderLine, + series, + style.PillFill, style.PillText, html.EscapeString(style.BadgeText), style.PillText, html.EscapeString(style.BadgeSubtext), + editionLabel, theme, + style.EditionFill, style.EditionStroke, style.EditionText, bitLabel, + style.AccentA, rarity, + completed, style.AccentA, style.AccentB, ) return []byte(svg) } diff --git a/internal/artifact/collection.go b/internal/artifact/collection.go index d084c03..f339ea8 100644 --- a/internal/artifact/collection.go +++ b/internal/artifact/collection.go @@ -177,18 +177,49 @@ var atmosphereOptions = []string{ "tiny environmental sparks or motes that echo the outfit materials", } +var cameraOptions = []string{ + "heroic low-angle framing with strong foreground-to-background depth", + "clean eye-level framing with a crisp, premium editorial feel", + "slightly top-down cinematic framing that reveals more environment around the character", + "dynamic three-quarter camera angle with the body turned and the face still clearly readable", + "wide-lens action framing with bold perspective exaggeration but no distortion of the face", + "portrait-style framing with intimate character presence and rich environmental layering", +} + +var paletteStoryOptions = []string{ + "blue-gold contrast with cool shadows and warm metallic highlights", + "magenta-cyan night energy with bright accent reflections", + "amber-teal cinematic contrast with soft glow in the midtones", + "emerald-violet contrast with luminous accent edges", + "sunset orange against deep indigo with polished highlight pops", + "crimson-gold energy balanced by dark graphite neutrals", +} + +var sceneFocusOptions = []string{ + "emphasize strong environmental architecture and depth lines behind the character", + "emphasize floating particles, atmospheric layers and soft depth transitions around the silhouette", + "emphasize prop interaction and costume detail storytelling in the hands and upper body", + "emphasize motion energy, directional streaks and a clear sense of momentum", + "emphasize dramatic lighting shapes and distinct foreground framing elements", + "emphasize a bold silhouette read with a simpler but striking background rhythm", +} + type collectionTraits struct { - ThemeName string `json:"theme"` - Outfit string `json:"outfit"` - Accessories string `json:"accessories"` - Scene string `json:"scene"` - Mood string `json:"mood"` - Pose string `json:"pose"` - Atmosphere string `json:"atmosphere"` - Rarity string `json:"rarity"` - AccentA string `json:"accent_a"` - AccentB string `json:"accent_b"` - EditionCode string `json:"edition_code"` + ThemeName string `json:"theme"` + Outfit string `json:"outfit"` + Accessories string `json:"accessories"` + Scene string `json:"scene"` + Mood string `json:"mood"` + Pose string `json:"pose"` + Atmosphere string `json:"atmosphere"` + Camera string `json:"camera"` + PaletteStory string `json:"palette_story"` + SceneFocus string `json:"scene_focus"` + Uniqueness string `json:"uniqueness"` + Rarity string `json:"rarity"` + AccentA string `json:"accent_a"` + AccentB string `json:"accent_b"` + EditionCode string `json:"edition_code"` } var accentPairs = [][2]string{ @@ -202,36 +233,181 @@ var accentPairs = [][2]string{ {"#C7B2FF", "#6CFFD7"}, } -func deriveCollectionTraits(x win) collectionTraits { - h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed + "|" + x.Guess)) - theme := collectionThemes[int(h[0])%len(collectionThemes)] - pair := accentPairs[int(h[1])%len(accentPairs)] - rarityRoll := binary.BigEndian.Uint16(h[2:4]) % 10000 - rarity := "RARE" - switch { - case rarityRoll < 35: - rarity = "MYTHIC" - case rarityRoll < 260: - rarity = "LEGENDARY" - case rarityRoll < 1250: - rarity = "EPIC" - case rarityRoll < 4200: - rarity = "RARE" +type traitUsage struct { + Themes map[string]int + Poses map[string]int + Atmospheres map[string]int + Cameras map[string]int + Palettes map[string]int + SceneFocuses map[string]int +} + +func newTraitUsage() *traitUsage { + return &traitUsage{ + Themes: map[string]int{}, + Poses: map[string]int{}, + Atmospheres: map[string]int{}, + Cameras: map[string]int{}, + Palettes: map[string]int{}, + SceneFocuses: map[string]int{}, + } +} + +func pickLeastUsed(base int, options []string, usage map[string]int) int { + if len(options) == 0 { + return 0 + } + base = ((base % len(options)) + len(options)) % len(options) + bestIdx := base + bestScore := int(^uint(0) >> 1) + for step := 0; step < len(options); step++ { + idx := (base + step) % len(options) + score := usage[options[idx]]*100 + step + if score < bestScore { + bestScore = score + bestIdx = idx + } + } + return bestIdx +} + +type rarityDistribution struct { + Common int + Uncommon int + Rare int + UltraRare int + SpecialIllustrationRare int +} + +func defaultRarityDistribution() rarityDistribution { + return rarityDistribution{ + Common: 5800, + Uncommon: 2950, + Rare: 990, + UltraRare: 225, + SpecialIllustrationRare: 35, + } +} + +func rarityDistributionFromPercentages(common, uncommon, rare, ultraRare, specialIllustration float64) rarityDistribution { + toBP := func(v float64) int { + if v <= 0 { + return 0 + } + return int(v*100 + 0.5) + } + d := rarityDistribution{ + Common: toBP(common), + Uncommon: toBP(uncommon), + Rare: toBP(rare), + UltraRare: toBP(ultraRare), + SpecialIllustrationRare: toBP(specialIllustration), + } + // Settings validation guarantees 100%, but normalize the final bucket to + // 10,000 basis points so float-to-int conversion can never leave a gap. + total := d.Common + d.Uncommon + d.Rare + d.UltraRare + d.SpecialIllustrationRare + d.Common += 10000 - total + if d.Common < 0 { + return defaultRarityDistribution() + } + return d +} + +func canonicalRarityName(v string) string { + switch strings.ToUpper(strings.TrimSpace(v)) { + case "COMMON": + return "COMMON" + case "UNCOMMON": + return "UNCOMMON" + case "RARE": + return "RARE" + case "ULTRA RARE", "ULTRA_RARE", "ULTRARARE": + return "ULTRA RARE" + case "SPECIAL ILLUSTRATION RARE", "SPECIAL_ILLUSTRATION_RARE", "SIR": + return "SPECIAL ILLUSTRATION RARE" default: - rarity = "SIGNATURE" + return "" + } +} + +func chooseRarity(roll int, distribution rarityDistribution, override string) string { + if forced := canonicalRarityName(override); forced != "" { + return forced + } + roll %= 10000 + if roll < 0 { + roll += 10000 + } + if roll < distribution.SpecialIllustrationRare { + return "SPECIAL ILLUSTRATION RARE" + } + roll -= distribution.SpecialIllustrationRare + if roll < distribution.UltraRare { + return "ULTRA RARE" + } + roll -= distribution.UltraRare + if roll < distribution.Rare { + return "RARE" + } + roll -= distribution.Rare + if roll < distribution.Uncommon { + return "UNCOMMON" + } + return "COMMON" +} + +func deriveCollectionTraits(x win) collectionTraits { + return deriveCollectionTraitsConfigured(x, nil, defaultRarityDistribution(), "") +} + +func deriveCollectionTraitsWithUsage(x win, usage *traitUsage) collectionTraits { + return deriveCollectionTraitsConfigured(x, usage, defaultRarityDistribution(), "") +} + +func deriveCollectionTraitsConfigured(x win, usage *traitUsage, distribution rarityDistribution, rarityOverride string) collectionTraits { + h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed + "|" + x.Guess)) + themeIdx := int(h[0]) % len(collectionThemes) + poseIdx := int(h[4]) % len(poseOptions) + atmosIdx := int(h[5]) % len(atmosphereOptions) + cameraIdx := int(h[10]) % len(cameraOptions) + paletteIdx := int(h[11]) % len(paletteStoryOptions) + sceneFocusIdx := int(h[12]) % len(sceneFocusOptions) + if usage != nil { + themeNames := make([]string, 0, len(collectionThemes)) + for _, theme := range collectionThemes { + themeNames = append(themeNames, theme.Name) + } + themeIdx = pickLeastUsed(themeIdx, themeNames, usage.Themes) + poseIdx = pickLeastUsed(poseIdx, poseOptions, usage.Poses) + atmosIdx = pickLeastUsed(atmosIdx, atmosphereOptions, usage.Atmospheres) + cameraIdx = pickLeastUsed(cameraIdx, cameraOptions, usage.Cameras) + paletteIdx = pickLeastUsed(paletteIdx, paletteStoryOptions, usage.Palettes) + sceneFocusIdx = pickLeastUsed(sceneFocusIdx, sceneFocusOptions, usage.SceneFocuses) + } + theme := collectionThemes[themeIdx] + pair := accentPairs[int(h[1])%len(accentPairs)] + rarityRoll := int(binary.BigEndian.Uint16(h[2:4]) % 10000) + rarity := chooseRarity(rarityRoll, distribution, rarityOverride) + uniqueness := "Give this collectible its own identity. Use a clearly distinct silhouette rhythm, prop handling, color balance and scene staging rather than a minor variation of another card." + if strings.EqualFold(strings.TrimSpace(x.Origin), "admin_drop") { + uniqueness = "This is an admin gift collectible and must feel materially different from other cards in the owner's collection. Prioritize a fresh theme, fresh pose, fresh camera language and a noticeably different color story. Avoid making a near-duplicate of an existing owned card." } return collectionTraits{ - ThemeName: theme.Name, - Outfit: theme.Outfit, - Accessories: theme.Accessories, - Scene: theme.Scene, - Mood: theme.Mood, - Pose: poseOptions[int(h[4])%len(poseOptions)], - Atmosphere: atmosphereOptions[int(h[5])%len(atmosphereOptions)], - Rarity: rarity, - AccentA: pair[0], - AccentB: pair[1], - EditionCode: strings.ToUpper(fmt.Sprintf("NH-%02X%02X-%02X%02X", h[6], h[7], h[8], h[9])), + ThemeName: theme.Name, + Outfit: theme.Outfit, + Accessories: theme.Accessories, + Scene: theme.Scene, + Mood: theme.Mood, + Pose: poseOptions[poseIdx], + Atmosphere: atmosphereOptions[atmosIdx], + Camera: cameraOptions[cameraIdx], + PaletteStory: paletteStoryOptions[paletteIdx], + SceneFocus: sceneFocusOptions[sceneFocusIdx], + Uniqueness: uniqueness, + Rarity: rarity, + AccentA: pair[0], + AccentB: pair[1], + EditionCode: strings.ToUpper(fmt.Sprintf("NH-%02X%02X-%02X%02X", h[6], h[7], h[8], h[9])), } } @@ -265,6 +441,9 @@ RIFT, one anthropomorphic raccoon character. The character must remain unmistaka THEME: %s +RARITY PRESENTATION: +This collectible has rarity tier %s. The illustration itself should feel premium at every tier, but higher tiers may feel more visually spectacular, elaborate or magical in their staging and environmental energy while remaining coherent with the theme. + CLOTHING — PRIMARY VARIABLE: %s. The clothing is the main collectible trait. It must instantly communicate the theme, look custom-tailored to RIFT, use premium materials and retain a clear silhouette. No logos or recognizable real-world brand marks. @@ -278,12 +457,21 @@ SCENE: POSE: %s. +CAMERA / STAGING: +%s. + +COLOR STORY: +%s. + MOOD / LIGHTING: %s. ATMOSPHERE: %s. +SCENE EMPHASIS: +%s. + COMPOSITION: Vertical 1024x1536 full-art composition. Exactly one dominant character occupying about 65–75%% of the frame. Three-quarter body view with the face and right-ear notch clearly visible and at least most of the striped tail visible. Put the eyes near the upper third. Use foreground/background layering and dynamic depth, but keep the silhouette immediately readable. Leave natural darker breathing room near the very top and bottom so a programmatic collectible-card overlay can remain legible. @@ -293,6 +481,9 @@ Follow Image 2 decisively for the rendering language. Recreate its level of styl FULL-ART ENERGY: The environment should visually merge with the character through thematic particles, light, fabric motion, mist, sparks, petals, snow, dust or other scene-appropriate elements. Create many small visual discoveries while keeping RIFT instantly readable. +COLLECTOR UNIQUENESS REQUIREMENT: +%s + BEACON HUNT WIN TRAIT: %s @@ -307,12 +498,17 @@ Original character design only. Exactly one raccoon. No human face. No photoreal Deterministic creative fingerprint only, never render it as text: task=%s winner=%s difficulty=%d-bit seed=%s.`, traits.ThemeName, + traits.Rarity, traits.Outfit, traits.Accessories, traits.Scene, traits.Pose, + traits.Camera, + traits.PaletteStory, traits.Mood, traits.Atmosphere, + traits.SceneFocus, + traits.Uniqueness, beaconDirection, taskDirection, avoid, diff --git a/internal/artifact/collection_test.go b/internal/artifact/collection_test.go index 34d8aec..cc7896e 100644 --- a/internal/artifact/collection_test.go +++ b/internal/artifact/collection_test.go @@ -242,3 +242,57 @@ func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) { t.Fatalf("unexpected reference content types: %v", contentTypes) } } + +func TestRarityDistributionAndOverride(t *testing.T) { + d := rarityDistribution{ + Common: 5000, + Uncommon: 2500, + Rare: 1500, + UltraRare: 900, + SpecialIllustrationRare: 100, + } + cases := []struct { + roll int + want string + }{ + {0, "SPECIAL ILLUSTRATION RARE"}, + {99, "SPECIAL ILLUSTRATION RARE"}, + {100, "ULTRA RARE"}, + {999, "ULTRA RARE"}, + {1000, "RARE"}, + {2499, "RARE"}, + {2500, "UNCOMMON"}, + {4999, "UNCOMMON"}, + {5000, "COMMON"}, + {9999, "COMMON"}, + } + for _, tc := range cases { + if got := chooseRarity(tc.roll, d, ""); got != tc.want { + t.Fatalf("roll %d = %q, want %q", tc.roll, got, tc.want) + } + } + if got := chooseRarity(9999, d, "SIR"); got != "SPECIAL ILLUSTRATION RARE" { + t.Fatalf("forced rarity = %q", got) + } + if got := chooseRarity(0, d, "ULTRA RARE"); got != "ULTRA RARE" { + t.Fatalf("forced ultra rarity = %q", got) + } +} + +func TestRarityCardStylesDiffer(t *testing.T) { + x := sampleWin() + base := deriveCollectionTraits(x) + seen := map[string]string{} + for _, rarity := range []string{"COMMON", "UNCOMMON", "RARE", "ULTRA RARE", "SPECIAL ILLUSTRATION RARE"} { + traits := base + traits.Rarity = rarity + card := string(renderCardSVG([]byte("art"), "png", x, traits)) + if !strings.Contains(card, rarity) && rarity != "SPECIAL ILLUSTRATION RARE" { + t.Fatalf("card for %s does not visibly identify rarity", rarity) + } + seen[rarity] = card + } + if seen["COMMON"] == seen["RARE"] || seen["RARE"] == seen["ULTRA RARE"] || seen["ULTRA RARE"] == seen["SPECIAL ILLUSTRATION RARE"] { + t.Fatal("rarity card renders should differ") + } +} diff --git a/internal/artifact/worker.go b/internal/artifact/worker.go index 76ed505..793ed11 100644 --- a/internal/artifact/worker.go +++ b/internal/artifact/worker.go @@ -68,6 +68,8 @@ func envDuration(k string, d time.Duration) time.Duration { type win struct { ID, Seed, Winner, ProvenanceWinner, Worker, Signature, Guess string Origin string + RarityOverride string + Rarity string DisplayName string RangeBits int Completed time.Time @@ -118,9 +120,9 @@ func (w *Worker) claim(ctx context.Context) (win, error) { var x win var completedMS int64 var raw string - err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,COALESCE(t.artifact_owner_client_id,t.winner_client_id),COALESCE(t.winner_client_id,''),COALESCE(t.winner_worker_client_id,t.winner_client_id,t.artifact_owner_client_id),COALESCE(t.winner_signature,''),COALESCE(t.winning_guess,''),t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.winner_beacon_path,t.winner_beacon_boosted_path,t.winner_beacon_round,t.artifact_origin + err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,COALESCE(t.artifact_owner_client_id,t.winner_client_id),COALESCE(t.winner_client_id,''),COALESCE(t.winner_worker_client_id,t.winner_client_id,t.artifact_owner_client_id),COALESCE(t.winner_signature,''),COALESCE(t.winning_guess,''),t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.winner_beacon_path,t.winner_beacon_boosted_path,t.winner_beacon_round,t.artifact_origin,COALESCE(t.artifact_rarity_override,''),COALESCE(t.artifact_rarity,'') FROM tasks t JOIN clients c ON c.id=COALESCE(t.winner_worker_client_id,t.winner_client_id,t.artifact_owner_client_id) - WHERE t.artifact_status='pending' AND COALESCE(t.artifact_owner_client_id,t.winner_client_id) IS NOT NULL ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.ProvenanceWinner, &x.Worker, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference, &x.BeaconPath, &x.BeaconBoostedPath, &x.BeaconRound, &x.Origin) + WHERE t.artifact_status='pending' AND COALESCE(t.artifact_owner_client_id,t.winner_client_id) IS NOT NULL ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.ProvenanceWinner, &x.Worker, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference, &x.BeaconPath, &x.BeaconBoostedPath, &x.BeaconRound, &x.Origin, &x.RarityOverride, &x.Rarity) if err != nil { return win{}, err } @@ -135,6 +137,39 @@ func (w *Worker) claim(ctx context.Context) (win, error) { return x, nil } +func (w *Worker) ownerTraitUsage(ctx context.Context, ownerClientID, excludeTaskID string) (*traitUsage, error) { + ownerClientID = strings.TrimSpace(ownerClientID) + if ownerClientID == "" { + return nil, nil + } + rows, err := w.db.QueryContext(ctx, `SELECT id,public_seed,COALESCE(artifact_owner_client_id,winner_client_id),COALESCE(winning_guess,''),COALESCE(artifact_origin,'win') + FROM tasks + WHERE COALESCE(artifact_owner_client_id,winner_client_id)=? AND id<>? AND status='completed' AND artifact_status='ready' + ORDER BY COALESCE(completed_at,created_at) DESC LIMIT 128`, ownerClientID, excludeTaskID) + if err != nil { + return nil, err + } + defer rows.Close() + usage := newTraitUsage() + for rows.Next() { + var prior win + if err := rows.Scan(&prior.ID, &prior.Seed, &prior.Winner, &prior.Guess, &prior.Origin); err != nil { + return nil, err + } + traits := deriveCollectionTraits(prior) + usage.Themes[traits.ThemeName]++ + usage.Poses[traits.Pose]++ + usage.Atmospheres[traits.Atmosphere]++ + usage.Cameras[traits.Camera]++ + usage.Palettes[traits.PaletteStory]++ + usage.SceneFocuses[traits.SceneFocus]++ + } + if err := rows.Err(); err != nil { + return nil, err + } + return usage, nil +} + type imageResult struct { Bytes []byte Ext string @@ -181,7 +216,32 @@ func (w *Worker) one(ctx context.Context) error { } cfg := w.settings.Get() preset := strings.ToLower(strings.TrimSpace(cfg.ArtifactPreset)) - traits := deriveCollectionTraits(x) + rarityDist := rarityDistributionFromPercentages( + cfg.ArtifactRarityCommonPct, + cfg.ArtifactRarityUncommonPct, + cfg.ArtifactRarityRarePct, + cfg.ArtifactRarityUltraRarePct, + cfg.ArtifactRaritySpecialIllustrationPct, + ) + effectiveRarity := x.Rarity + if strings.TrimSpace(effectiveRarity) == "" { + effectiveRarity = x.RarityOverride + } + traits := deriveCollectionTraitsConfigured(x, nil, rarityDist, effectiveRarity) + if preset == collectionPresetRaccoon && strings.EqualFold(strings.TrimSpace(x.Origin), "admin_drop") { + if usage, usageErr := w.ownerTraitUsage(ctx, x.Winner, x.ID); usageErr != nil { + log.Printf("artifact worker task %s owner trait usage unavailable: %v", x.ID, usageErr) + } else if usage != nil { + traits = deriveCollectionTraitsConfigured(x, usage, rarityDist, effectiveRarity) + } + } + if preset == collectionPresetRaccoon && strings.TrimSpace(x.Rarity) == "" { + if _, rarityErr := w.db.ExecContext(ctx, `UPDATE tasks SET artifact_rarity=? WHERE id=? AND artifact_rarity=''`, traits.Rarity, x.ID); rarityErr != nil { + w.fail(ctx, x.ID, rarityErr) + return fmt.Errorf("task %s: persist rarity: %w", x.ID, rarityErr) + } + x.Rarity = traits.Rarity + } prompt := "" negativePrompt := "" if preset == collectionPresetRaccoon { @@ -232,6 +292,8 @@ func (w *Worker) one(ctx context.Context) error { "task_display_name": x.DisplayName, "task_range_bits": x.RangeBits, "artifact_origin": x.Origin, + "artifact_rarity_override": x.RarityOverride, + "artifact_rarity": x.Rarity, "artifact_owner_client_id": x.Winner, "winner_client_id": x.ProvenanceWinner, "winner_worker_client_id": x.Worker, diff --git a/internal/controllerui/controllerui.go b/internal/controllerui/controllerui.go new file mode 100644 index 0000000..61a26d4 --- /dev/null +++ b/internal/controllerui/controllerui.go @@ -0,0 +1,15 @@ +package controllerui + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed dist/* +var files embed.FS + +func Handler() http.Handler { + sub, _ := fs.Sub(files, "dist") + return http.FileServer(http.FS(sub)) +} diff --git a/internal/controllerui/dist/app.js b/internal/controllerui/dist/app.js new file mode 100644 index 0000000..1937ca8 --- /dev/null +++ b/internal/controllerui/dist/app.js @@ -0,0 +1 @@ +const $=id=>document.getElementById(id);function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden');setTimeout(()=>$('msg').classList.add('hidden'),6500)}async function api(p,o={}){if(o.body&&typeof o.body!=='string'){o.headers={'Content-Type':'application/json',...(o.headers||{})};o.body=JSON.stringify(o.body)}const r=await fetch(p,{credentials:'same-origin',...o});const x=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(x.error||`HTTP ${r.status}`);e.status=r.status;throw e}return x}function workerHTML(w){return `
${esc(w.worker_id||'—')}${esc(w.name||w.id)}
${esc(w.image)} · ${esc(w.status||w.state)}
${w.running?'RUNNING':'STOPPED'}
`}function bind(){document.querySelectorAll('.start').forEach(b=>b.onclick=()=>action(b,'start'));document.querySelectorAll('.stop').forEach(b=>b.onclick=()=>action(b,'stop'));document.querySelectorAll('.restart').forEach(b=>b.onclick=()=>action(b,'restart'))}async function action(b,a){b.disabled=true;try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.id)}/${a}`,{method:'POST'});msg(`Worker ${a}`);await load()}catch(e){msg(e.message,true)}finally{b.disabled=false}}async function load(){const x=await api('/api/admin/overview');$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('controllerName').textContent=x.controller?.name||x.controller?.id||'Service Controller';const mc=x.controller?.master_connected?'MASTER ONLINE':'MASTER OFFLINE';const ma=x.controller?.master_last_attempt?new Date(x.controller.master_last_attempt).toLocaleString('de-DE'):'';const me=x.controller?.master_last_error?` · ${x.controller.master_last_error}`:'';$('controllerMeta').textContent=`${x.controller?.id||''} · ${x.controller?.advertise_url||''} · Master ${x.controller?.master_url||''} · ${mc}${ma?' · '+ma:''}${me}`;$('running').textContent=x.running||0;$('maxRunning').textContent=x.controller?.max_running||0;$('total').textContent=x.total||0;$('maxWorkers').textContent=x.controller?.max_workers||0;$('workers').innerHTML=(x.workers||[]).map(workerHTML).join('')||'
Keine verwalteten Worker auf diesem Host.
';bind()}$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};$('refresh').onclick=()=>load().catch(e=>msg(e.message,true));$('stopAll').onclick=async()=>{if(!confirm('ALLE laufenden Neural-Hunt-Worker auf diesem Host stoppen?'))return;try{const x=await api('/api/admin/workers/stop-all',{method:'POST'});msg(`${x.stopped||0} Worker gestoppt${x.warnings?.length?' · '+x.warnings.length+' Warnungen':''}`,!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}};$('pullImage').onclick=async()=>{try{const x=await api('/api/admin/image/pull',{method:'POST'});msg(`Image aktualisiert: ${x.image}`)}catch(e){msg(e.message,true)}};load().catch(()=>{}); diff --git a/internal/controllerui/dist/index.html b/internal/controllerui/dist/index.html new file mode 100644 index 0000000..60d6db6 --- /dev/null +++ b/internal/controllerui/dist/index.html @@ -0,0 +1 @@ +Neural Hunt Service Controller
NEURAL HUNT

Service Controller

Notfall-Steuerung für diesen Worker-Host.

diff --git a/internal/controllerui/dist/styles.css b/internal/controllerui/dist/styles.css new file mode 100644 index 0000000..e6f8cdf --- /dev/null +++ b/internal/controllerui/dist/styles.css @@ -0,0 +1 @@ +:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;background:#07111c;color:#eef8ff}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#102a3d 0,#07111c 38%,#040a12 100%);min-height:100vh}main{max-width:1180px;margin:auto;padding:32px}header,.row,.worker{display:flex;align-items:center;gap:14px}header{justify-content:space-between;margin-bottom:24px}.between{justify-content:space-between}.eyebrow{font-size:12px;font-weight:900;letter-spacing:.24em;color:#69f5ff}h1,h2,p{margin:.25rem 0}.card{border:1px solid #17374b;background:#081724e8;border-radius:18px;padding:20px;margin-bottom:16px;box-shadow:0 18px 45px #0005}.login{max-width:460px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.small{font-size:13px;color:#98adbb;line-height:1.55}.metric{display:inline-flex;align-items:baseline;gap:8px;margin-right:30px}.metric strong{font-size:34px;color:#fff}.workers{display:grid;gap:10px}.worker{border:1px solid #17374b;border-radius:13px;padding:12px 14px}.worker-main{flex:1;min-width:0}.worker-main strong,.worker-main code{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pill{border:1px solid #375164;border-radius:999px;padding:5px 9px;font-size:11px;font-weight:900;letter-spacing:.08em}.pill.on{color:#8cffbd;border-color:#2e9d65;background:#0a2a1b}button{border:1px solid #1fa8bc;background:#0a5f72;color:#fff;border-radius:10px;padding:10px 14px;font-weight:850;cursor:pointer}button.ghost{background:#0b1b28;border-color:#385262}button.danger{background:#3b1118;border-color:#a63a49}button:disabled{opacity:.45;cursor:not-allowed}label{display:grid;gap:6px;margin:12px 0;font-size:13px;color:#9db2c1}input{background:#050d15;color:#fff;border:1px solid #26465a;border-radius:9px;padding:11px}.hidden{display:none!important}.msg{position:sticky;top:12px;z-index:4;background:#103428;border:1px solid #2d9d68;border-radius:12px;padding:12px 16px;margin-bottom:14px}.msg.err{background:#35141b;border-color:#a63a49}@media(max-width:760px){main{padding:18px}.grid{grid-template-columns:1fr}.between,header{align-items:flex-start;flex-direction:column}.worker{align-items:flex-start;flex-wrap:wrap}.worker-main{width:100%}} diff --git a/internal/customer/docker.go b/internal/customer/docker.go index 701f579..bad217a 100644 --- a/internal/customer/docker.go +++ b/internal/customer/docker.go @@ -361,3 +361,40 @@ func (d *DockerClient) RemoveVolume(ctx context.Context, name string) error { } return err } + +type ManagedContainer struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + WorkerID string `json:"worker_id"` + Running bool `json:"running"` +} + +// ManagedContainers lists only containers created by Neural Hunt. It is used by +// the Service Controller emergency UI and never exposes unrelated Docker +// workloads on the host. +func (d *DockerClient) ManagedContainers(ctx context.Context) ([]ManagedContainer, error) { + q := url.QueryEscape(`{"label":["neuralhunt.managed=true"]}`) + var raw []struct { + ID string `json:"Id"` + Names []string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + Labels map[string]string `json:"Labels"` + } + if err := d.req(ctx, http.MethodGet, "/containers/json?all=true&filters="+q, nil, &raw); err != nil { + return nil, err + } + out := make([]ManagedContainer, 0, len(raw)) + for _, x := range raw { + name := "" + if len(x.Names) > 0 { + name = strings.TrimPrefix(x.Names[0], "/") + } + out = append(out, ManagedContainer{ID: x.ID, Name: name, Image: x.Image, State: x.State, Status: x.Status, WorkerID: x.Labels["neuralhunt.worker_id"], Running: x.State == "running"}) + } + return out, nil +} diff --git a/internal/customer/runtime.go b/internal/customer/runtime.go new file mode 100644 index 0000000..5001137 --- /dev/null +++ b/internal/customer/runtime.go @@ -0,0 +1,165 @@ +package customer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// WorkerRuntime is the minimal worker-host API used by Customer Service. A +// local Docker Engine and a remote Service Controller both implement the same +// contract, so public/customer/game APIs do not need to know where a worker is +// actually running. +type WorkerRuntime interface { + Ping(context.Context) error + EnsureImage(context.Context, string, bool, string) error + PullImage(context.Context, string, string) error + CreateVolume(context.Context, string) error + CreateWorker(context.Context, WorkerContainerConfig) (string, error) + Start(context.Context, string) error + Stop(context.Context, string, int) error + Remove(context.Context, string) error + Running(context.Context, string) (bool, error) + GetFile(context.Context, string, string) ([]byte, error) + PutFile(context.Context, string, string, string, []byte) error + RemoveVolume(context.Context, string) error +} + +// RemoteControllerClient speaks the private Service-Controller API. It exposes +// the same logical methods as DockerClient while never granting Customer +// Service access to the remote host's docker.sock. +type RemoteControllerClient struct { + base string + secret string + hc *http.Client +} + +func NewRemoteControllerClient(baseURL, secret string) (*RemoteControllerClient, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return nil, errors.New("service controller base URL is empty") + } + u, err := url.Parse(baseURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("invalid service controller URL %q", baseURL) + } + if len(strings.TrimSpace(secret)) < 24 { + return nil, errors.New("service controller shared secret missing/too short") + } + return &RemoteControllerClient{base: baseURL, secret: strings.TrimSpace(secret), hc: &http.Client{Timeout: 45 * time.Second}}, nil +} + +func (c *RemoteControllerClient) req(ctx context.Context, method, path string, in, out any) error { + var body io.Reader + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.base+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.secret) + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + client := c.hc + if strings.Contains(path, "/image/pull") { + clone := *c.hc + clone.Timeout = 11 * time.Minute + client = &clone + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("service controller %s: %w", c.base, err) + } + defer resp.Body.Close() + b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if readErr != nil { + return readErr + } + if resp.StatusCode/100 != 2 { + var e struct { + Error string `json:"error"` + } + _ = json.Unmarshal(b, &e) + if strings.TrimSpace(e.Error) != "" { + return fmt.Errorf("service controller HTTP %d: %s", resp.StatusCode, strings.TrimSpace(e.Error)) + } + return fmt.Errorf("service controller HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + if out != nil && len(bytes.TrimSpace(b)) > 0 { + if err := json.Unmarshal(b, out); err != nil { + return err + } + } + return nil +} + +func (c *RemoteControllerClient) Ping(ctx context.Context) error { + return c.req(ctx, http.MethodGet, "/internal/health", nil, nil) +} +func (c *RemoteControllerClient) EnsureImage(ctx context.Context, image string, autoPull bool, auth string) error { + return c.req(ctx, http.MethodPost, "/internal/image/ensure", map[string]any{"image": image, "auto_pull": autoPull, "registry_auth": auth}, nil) +} +func (c *RemoteControllerClient) PullImage(ctx context.Context, image, auth string) error { + return c.req(ctx, http.MethodPost, "/internal/image/pull", map[string]any{"image": image, "registry_auth": auth}, nil) +} +func (c *RemoteControllerClient) CreateVolume(ctx context.Context, name string) error { + return c.req(ctx, http.MethodPost, "/internal/volumes", map[string]string{"name": name}, nil) +} +func (c *RemoteControllerClient) CreateWorker(ctx context.Context, cfg WorkerContainerConfig) (string, error) { + var out struct { + ID string `json:"id"` + } + if err := c.req(ctx, http.MethodPost, "/internal/workers", cfg, &out); err != nil { + return "", err + } + if strings.TrimSpace(out.ID) == "" { + return "", errors.New("service controller returned empty container id") + } + return out.ID, nil +} +func (c *RemoteControllerClient) Start(ctx context.Context, id string) error { + return c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/start", nil, nil) +} +func (c *RemoteControllerClient) Stop(ctx context.Context, id string, seconds int) error { + return c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/stop", map[string]int{"seconds": seconds}, nil) +} +func (c *RemoteControllerClient) Remove(ctx context.Context, id string) error { + return c.req(ctx, http.MethodDelete, "/internal/workers/"+url.PathEscape(id), nil, nil) +} +func (c *RemoteControllerClient) Running(ctx context.Context, id string) (bool, error) { + var out struct { + Running bool `json:"running"` + } + if err := c.req(ctx, http.MethodGet, "/internal/workers/"+url.PathEscape(id)+"/running", nil, &out); err != nil { + return false, err + } + return out.Running, nil +} +func (c *RemoteControllerClient) GetFile(ctx context.Context, id, path string) ([]byte, error) { + var out struct { + Data []byte `json:"data"` + } + if err := c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/file/get", map[string]string{"path": path}, &out); err != nil { + return nil, err + } + return out.Data, nil +} +func (c *RemoteControllerClient) PutFile(ctx context.Context, id, dir, name string, data []byte) error { + return c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/file/put", map[string]any{"dir": dir, "name": name, "data": data}, nil) +} +func (c *RemoteControllerClient) RemoveVolume(ctx context.Context, name string) error { + return c.req(ctx, http.MethodDelete, "/internal/volumes/"+url.PathEscape(name), nil, nil) +} diff --git a/internal/customer/runtime_controller_test.go b/internal/customer/runtime_controller_test.go new file mode 100644 index 0000000..f560715 --- /dev/null +++ b/internal/customer/runtime_controller_test.go @@ -0,0 +1,51 @@ +package customer + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRemoteControllerClientAuthAndCreate(t *testing.T) { + const secret = "0123456789abcdef0123456789abcdef" + seen := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+secret { + t.Fatalf("authorization = %q", r.Header.Get("Authorization")) + } + switch r.URL.Path { + case "/internal/health": + w.WriteHeader(http.StatusOK) + case "/internal/workers": + seen = true + var cfg WorkerContainerConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + t.Fatal(err) + } + if cfg.WorkerID != "wrk_test" || cfg.Image != "worker:test" { + t.Fatalf("unexpected cfg: %+v", cfg) + } + _ = json.NewEncoder(w).Encode(map[string]string{"id": "container_123"}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + c, err := NewRemoteControllerClient(ts.URL, secret) + if err != nil { + t.Fatal(err) + } + if err := c.Ping(context.Background()); err != nil { + t.Fatal(err) + } + id, err := c.CreateWorker(context.Background(), WorkerContainerConfig{WorkerID: "wrk_test", Image: "worker:test"}) + if err != nil { + t.Fatal(err) + } + if id != "container_123" || !seen { + t.Fatalf("id=%q seen=%v", id, seen) + } +} diff --git a/internal/customer/server.go b/internal/customer/server.go index e6d29d1..690258c 100644 --- a/internal/customer/server.go +++ b/internal/customer/server.go @@ -33,6 +33,9 @@ type Config struct { GamePublicURL, GameAdminURL, SharedSecret string DockerHost, WorkerImage, WorkerEntrypoint, WorkerNetwork string WorkerRegisterURL string + WorkerOrchestrationMode string + ControllerSharedSecret string + ControllerOfflineAfter time.Duration WorkerRegistryUsername string WorkerRegistryPassword string WorkerRegistryServer string @@ -62,7 +65,7 @@ type Config struct { type Service struct { store *Store - docker *DockerClient + docker WorkerRuntime paypal *PayPalClient cfg Config adminSessions sync.Map @@ -70,7 +73,7 @@ type Service struct { hc *http.Client } -func NewService(store *Store, docker *DockerClient, paypal *PayPalClient, cfg Config) *Service { +func NewService(store *Store, docker WorkerRuntime, paypal *PayPalClient, cfg Config) *Service { if cfg.SessionTTL <= 0 { cfg.SessionTTL = 24 * time.Hour } @@ -89,6 +92,17 @@ func NewService(store *Store, docker *DockerClient, paypal *PayPalClient, cfg Co if cfg.MaxRunningGlobal <= 0 { cfg.MaxRunningGlobal = 100 } + if cfg.ControllerOfflineAfter < 15*time.Second { + cfg.ControllerOfflineAfter = 45 * time.Second + } + mode := strings.ToLower(strings.TrimSpace(cfg.WorkerOrchestrationMode)) + if mode == "" { + mode = "direct" + } + if mode != "direct" && mode != "controller" && mode != "hybrid" { + mode = "direct" + } + cfg.WorkerOrchestrationMode = mode return &Service{store: store, docker: docker, paypal: paypal, cfg: cfg, hc: &http.Client{Timeout: 10 * time.Second}} } @@ -263,6 +277,7 @@ func (s *Service) AdminRoutes(ui http.Handler) http.Handler { r.Post("/api/admin/workers/{id}/stop", s.adminStopAnyWorker) r.Post("/api/admin/workers/{id}/update-image", s.adminUpdateWorkerImage) r.Post("/api/admin/workers/update-image", s.adminUpdateAllWorkerImages) + r.Put("/api/admin/controllers/{id}", s.adminUpdateControllerPolicy) r.Post("/api/admin/invites", s.adminCreateInvite) }) r.Mount("/", ui) @@ -274,6 +289,7 @@ func (s *Service) InternalRoutes() http.Handler { r.Post("/internal/workers/register", s.internalWorkerRegister) r.Post("/internal/workers/lease", s.internalWorkerLease) r.Post("/internal/game/positive-tip", s.internalGamePositiveTip) + r.Post("/internal/controllers/register", s.internalControllerRegister) r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) }) return r } @@ -518,6 +534,14 @@ func (s *Service) workers(w http.ResponseWriter, r *http.Request) { jsonOut(w, 500, map[string]string{"error": err.Error()}) return } + // Physical host/controller IDs are private infrastructure metadata. The + // customer-facing API deliberately keeps placement transparent. + for i := range x { + x[i].ControllerID = "" + x[i].ContainerID = "" + x[i].Volume = "" + x[i].LastLeaseAt = nil + } jsonOut(w, 200, x) } func (s *Service) createWorkerRecord(ctx context.Context, cid, taskID, beaconPath string, ignoreCustomerLimit bool) (Worker, error) { @@ -574,6 +598,10 @@ func (s *Service) createWorker(w http.ResponseWriter, r *http.Request) { jsonOut(w, status, map[string]string{"error": err.Error()}) return } + wk.ControllerID = "" + wk.ContainerID = "" + wk.Volume = "" + wk.LastLeaseAt = nil jsonOut(w, 201, wk) } @@ -632,9 +660,14 @@ func (s *Service) updateWorker(w http.ResponseWriter, r *http.Request) { return } if wk.ContainerID != "" { - _ = s.docker.Stop(r.Context(), wk.ContainerID, 5) - _ = s.docker.Remove(r.Context(), wk.ContainerID) - _ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", "", "") + // Revoke centrally first. If the remote host is temporarily unavailable, + // the worker loses its next lease and exits on its own. + _ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", wk.ContainerID, "") + if rt, rtErr := s.runtimeForWorker(r.Context(), &wk, false); rtErr == nil { + _ = rt.Stop(r.Context(), wk.ContainerID, 5) + _ = rt.Remove(r.Context(), wk.ContainerID) + _ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", "", "") + } } if err := s.store.UpdateWorkerConfig(r.Context(), cid, wid, strings.TrimSpace(in.TaskID), normalizePath(in.BeaconPath)); err != nil { jsonOut(w, 500, map[string]string{"error": err.Error()}) @@ -659,30 +692,151 @@ func (s *Service) workerContainerConfig(wk Worker, name string) WorkerContainerC return WorkerContainerConfig{Image: s.cfg.WorkerImage, Entrypoint: s.cfg.WorkerEntrypoint, Network: s.cfg.WorkerNetwork, GameURL: s.cfg.GamePublicURL, RegisterURL: registerURL, WorkerID: wk.ID, RegisterToken: wk.RegisterToken, TaskID: wk.TaskID, BeaconPath: wk.BeaconPath, Volume: wk.Volume, Name: name} } -func (s *Service) ensureWorkerImage(ctx context.Context) error { - // Avoid several customer requests triggering concurrent pulls of the same - // image. The local inspect is cheap once the image is available. - s.workerImageMu.Lock() - defer s.workerImageMu.Unlock() - auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer) - if err != nil { - return err - } - return s.docker.EnsureImage(ctx, s.cfg.WorkerImage, s.cfg.WorkerAutoPull, auth) +func (s *Service) controllerOnline(c ServiceController) bool { + return c.Enabled && !c.Draining && time.Since(c.LastSeen) <= s.cfg.ControllerOfflineAfter } -// pullWorkerImage always asks Docker to refresh CS_WORKER_IMAGE, even if the -// tag already exists locally. This is intentionally admin-only behavior for -// mutable release tags such as worker_latest; normal worker starts keep the -// cheaper EnsureImage behavior. -func (s *Service) pullWorkerImage(ctx context.Context) error { - s.workerImageMu.Lock() - defer s.workerImageMu.Unlock() +func (s *Service) controllerRuntime(ctx context.Context, controllerID string) (WorkerRuntime, error) { + c, err := s.store.ServiceController(ctx, strings.TrimSpace(controllerID)) + if err != nil { + return nil, fmt.Errorf("service controller %q not found", controllerID) + } + if !c.Enabled { + return nil, fmt.Errorf("service controller %s is disabled", c.ID) + } + if time.Since(c.LastSeen) > s.cfg.ControllerOfflineAfter { + return nil, fmt.Errorf("service controller %s is offline (last heartbeat %s ago)", c.ID, time.Since(c.LastSeen).Round(time.Second)) + } + return NewRemoteControllerClient(c.BaseURL, s.cfg.ControllerSharedSecret) +} + +func (s *Service) chooseController(ctx context.Context) (ServiceController, error) { + controllers, err := s.store.ServiceControllers(ctx) + if err != nil { + return ServiceController{}, err + } + var best ServiceController + bestScore := 1e18 + found := false + for _, c := range controllers { + if !s.controllerOnline(c) { + continue + } + total, running, err := s.store.ControllerWorkerCounts(ctx, c.ID) + if err != nil { + continue + } + if c.MaxWorkers > 0 && (total >= c.MaxWorkers || c.ReportedWorkers >= c.MaxWorkers) { + continue + } + maxW := c.MaxWorkers + if maxW <= 0 { + maxW = 1 + } + maxR := c.MaxRunning + if maxR <= 0 { + maxR = 1 + } + // Prefer the least-loaded controller. Local DB assignment counts are the + // source of truth; reported counts add a small penalty for emergency/manual + // containers that the Master may not know about yet. + score := float64(running)/float64(maxR)*10 + float64(total)/float64(maxW) + if c.ReportedRunning > running { + score += float64(c.ReportedRunning-running) / float64(maxR) * 4 + } + if c.ReportedWorkers > total { + score += float64(c.ReportedWorkers-total) / float64(maxW) + } + if !found || score < bestScore { + best, bestScore, found = c, score, true + } + } + if !found { + return ServiceController{}, errors.New("no online Service Controller with free capacity") + } + return best, nil +} + +// runtimeForWorker resolves the worker host. Existing assignments are sticky so +// identity volumes never jump hosts accidentally. New workers are placed on a +// healthy controller in controller/hybrid mode; direct mode keeps the legacy +// local docker.sock behavior. +func (s *Service) runtimeForWorker(ctx context.Context, wk *Worker, assign bool) (WorkerRuntime, error) { + if wk == nil { + return nil, errors.New("worker required") + } + if strings.EqualFold(strings.TrimSpace(wk.ControllerID), "local") { + if s.docker == nil { + return nil, errors.New("worker is assigned to local Docker but local runtime is disabled") + } + return s.docker, nil + } + if strings.TrimSpace(wk.ControllerID) != "" { + return s.controllerRuntime(ctx, wk.ControllerID) + } + mode := s.cfg.WorkerOrchestrationMode + if mode == "direct" { + if s.docker == nil { + return nil, errors.New("local Docker runtime unavailable") + } + return s.docker, nil + } + // Backward compatibility: workers created before Service Controllers existed + // have no controller_id. If they already have a local container, never move + // their named identity volume implicitly to another host. + if wk.ContainerID != "" || wk.WorkerClientID != "" { + if s.docker != nil { + _ = s.store.SetWorkerController(ctx, wk.ID, "local") + wk.ControllerID = "local" + return s.docker, nil + } + return nil, errors.New("legacy local worker has an existing runtime identity but no controller assignment; use direct/hybrid mode to export or stop it before moving to controller-only mode") + } + if assign { + c, err := s.chooseController(ctx) + if err == nil { + rt, rtErr := NewRemoteControllerClient(c.BaseURL, s.cfg.ControllerSharedSecret) + if rtErr != nil { + return nil, rtErr + } + if err := s.store.SetWorkerController(ctx, wk.ID, c.ID); err != nil { + return nil, err + } + wk.ControllerID = c.ID + return rt, nil + } + if mode == "controller" { + return nil, err + } + } + if mode == "hybrid" && s.docker != nil { + _ = s.store.SetWorkerController(ctx, wk.ID, "local") + wk.ControllerID = "local" + return s.docker, nil + } + return nil, errors.New("worker has no Service Controller assignment") +} + +func (s *Service) ensureWorkerImageOn(ctx context.Context, rt WorkerRuntime) error { + if rt == nil { + return errors.New("worker runtime unavailable") + } auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer) if err != nil { return err } - if err := s.docker.PullImage(ctx, s.cfg.WorkerImage, auth); err != nil { + return rt.EnsureImage(ctx, s.cfg.WorkerImage, s.cfg.WorkerAutoPull, auth) +} + +func (s *Service) pullWorkerImageOn(ctx context.Context, rt WorkerRuntime) error { + if rt == nil { + return errors.New("worker runtime unavailable") + } + auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer) + if err != nil { + return err + } + if err := rt.PullImage(ctx, s.cfg.WorkerImage, auth); err != nil { return fmt.Errorf("pull worker image %q: %w", s.cfg.WorkerImage, err) } return nil @@ -693,6 +847,10 @@ func (s *Service) pullWorkerImage(ctx context.Context) error { // container was recreated; stopped workers remain stopped. Pulling is done by // the caller so bulk updates need only one registry request. func (s *Service) recreateWorkerWithCurrentImage(ctx context.Context, wk Worker) error { + rt, err := s.runtimeForWorker(ctx, &wk, true) + if err != nil { + return err + } resume := wk.Status == "running" || wk.Status == "starting" if resume { if cust, err := s.store.CustomerByID(ctx, wk.CustomerID); err != nil || cust.Blocked { @@ -701,28 +859,26 @@ func (s *Service) recreateWorkerWithCurrentImage(ctx context.Context, wk Worker) } oldID := wk.ContainerID if oldID != "" { - _ = s.docker.Stop(ctx, oldID, 5) - if err := s.docker.Remove(ctx, oldID); err != nil { + _ = rt.Stop(ctx, oldID, 5) + if err := rt.Remove(ctx, oldID); err != nil { return fmt.Errorf("remove old worker container: %w", err) } } name := "neuralhunt-worker-" + strings.TrimPrefix(wk.ID, "wrk_") - // Clear a crash-orphan with the deterministic name. Remove is idempotent for - // 404s and never removes the named identity volume (v=false). - _ = s.docker.Remove(ctx, name) - containerID, err := s.docker.CreateWorker(ctx, s.workerContainerConfig(wk, name)) + _ = rt.Remove(ctx, name) + containerID, err := rt.CreateWorker(ctx, s.workerContainerConfig(wk, name)) if err != nil { _ = s.store.SetWorkerRuntime(ctx, wk.ID, "error", "", "image update: "+err.Error()) return err } if resume { - if err := s.docker.Start(ctx, containerID); err != nil { - _ = s.docker.Remove(ctx, containerID) + if err := rt.Start(ctx, containerID); err != nil { + _ = rt.Remove(ctx, containerID) _ = s.store.SetWorkerRuntime(ctx, wk.ID, "error", "", "image update start: "+err.Error()) return err } if err := s.store.SetWorkerRuntime(ctx, wk.ID, "running", containerID, ""); err != nil { - _ = s.docker.Stop(ctx, containerID, 2) + _ = rt.Stop(ctx, containerID, 2) return err } return nil @@ -737,17 +893,24 @@ func (s *Service) ensureWorkerContainer(ctx context.Context, wk Worker) (Worker, if wk.ContainerID != "" { return wk, nil } - if err := s.ensureWorkerImage(ctx); err != nil { + rt, err := s.runtimeForWorker(ctx, &wk, true) + if err != nil { + return wk, err + } + s.workerImageMu.Lock() + err = s.ensureWorkerImageOn(ctx, rt) + s.workerImageMu.Unlock() + if err != nil { return wk, err } name := "neuralhunt-worker-" + strings.TrimPrefix(wk.ID, "wrk_") - id, err := s.docker.CreateWorker(ctx, s.workerContainerConfig(wk, name)) + id, err := rt.CreateWorker(ctx, s.workerContainerConfig(wk, name)) if err != nil { return wk, err } wk.ContainerID = id if err := s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", id, ""); err != nil { - _ = s.docker.Remove(ctx, id) + _ = rt.Remove(ctx, id) return wk, err } return wk, nil @@ -791,10 +954,20 @@ func (s *Service) startWorker(w http.ResponseWriter, r *http.Request) { jsonOut(w, 409, map[string]string{"error": "configure and verify a main reward identity before starting hosted workers"}) return } - if err := s.ensureWorkerImage(r.Context()); err != nil { + + rt, err := s.runtimeForWorker(r.Context(), &wk, true) + if err != nil { + jsonOut(w, 503, map[string]string{"error": err.Error()}) + return + } + s.workerImageMu.Lock() + err = s.ensureWorkerImageOn(r.Context(), rt) + s.workerImageMu.Unlock() + if err != nil { jsonOut(w, 502, map[string]string{"error": err.Error()}) return } + claimed, err := s.store.ClaimWorkerStart(r.Context(), cid, wid) if err != nil { jsonOut(w, 500, map[string]string{"error": err.Error()}) @@ -816,36 +989,36 @@ func (s *Service) startWorker(w http.ResponseWriter, r *http.Request) { return } if wk.ContainerID != "" { - _ = s.docker.Remove(r.Context(), wk.ContainerID) + _ = rt.Remove(r.Context(), wk.ContainerID) } name := "neuralhunt-worker-" + strings.TrimPrefix(wid, "wrk_") - _ = s.docker.Remove(r.Context(), name) // clears a crash-orphan with the deterministic name - containerID, err := s.docker.CreateWorker(r.Context(), s.workerContainerConfig(wk, name)) + _ = rt.Remove(r.Context(), name) + containerID, err := rt.CreateWorker(r.Context(), s.workerContainerConfig(wk, name)) if err == nil { - err = s.docker.Start(r.Context(), containerID) + err = rt.Start(r.Context(), containerID) } if err != nil { if containerID != "" { - _ = s.docker.Stop(r.Context(), containerID, 2) - _ = s.docker.Remove(r.Context(), containerID) + _ = rt.Stop(r.Context(), containerID, 2) + _ = rt.Remove(r.Context(), containerID) } if newlyCharged { - _ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "docker_start_failed") + _ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "runtime_start_failed") } _ = s.store.SetWorkerRuntime(r.Context(), wid, "error", "", err.Error()) jsonOut(w, 502, map[string]string{"error": err.Error()}) return } if err := s.store.SetWorkerRuntime(r.Context(), wid, "running", containerID, ""); err != nil { - _ = s.docker.Stop(r.Context(), containerID, 2) - _ = s.docker.Remove(r.Context(), containerID) + _ = rt.Stop(r.Context(), containerID, 2) + _ = rt.Remove(r.Context(), containerID) if newlyCharged { _ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "runtime_state_failed") } jsonOut(w, 500, map[string]string{"error": "worker state could not be persisted"}) return } - jsonOut(w, 200, map[string]any{"ok": true, "container_id": containerID}) + jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Service) stopWorker(w http.ResponseWriter, r *http.Request) { cid, wid := customerID(r), chi.URLParam(r, "id") @@ -861,16 +1034,20 @@ func (s *Service) stopWorker(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Service) stopWorkerInternal(ctx context.Context, wk Worker, status string) error { - // Revoke the lease in SQLite first. Even if Docker itself is temporarily - // unavailable, the worker's next lease renewal will fail and its agent will - // self-terminate instead of continuing with a blocked/stopped account. + // Revoke the central lease first. Even when a remote controller is offline, + // the agent can no longer renew its lease and will self-terminate. if err := s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, ""); err != nil { return err } - if wk.ContainerID != "" { - if err := s.docker.Stop(ctx, wk.ContainerID, 5); err != nil && !strings.Contains(err.Error(), "304") { - return err - } + if wk.ContainerID == "" { + return nil + } + rt, err := s.runtimeForWorker(ctx, &wk, false) + if err != nil { + return err + } + if err := rt.Stop(ctx, wk.ContainerID, 5); err != nil && !strings.Contains(err.Error(), "304") { + return err } return nil } @@ -881,18 +1058,31 @@ func (s *Service) deleteWorker(w http.ResponseWriter, r *http.Request) { jsonOut(w, 404, map[string]string{"error": "worker not found"}) return } + rt, rtErr := s.runtimeForWorker(r.Context(), &wk, false) if wk.ContainerID != "" { - _ = s.docker.Stop(r.Context(), wk.ContainerID, 3) - _ = s.docker.Remove(r.Context(), wk.ContainerID) + if rtErr != nil { + jsonOut(w, 502, map[string]string{"error": "worker host unavailable; refusing to orphan identity volume: " + rtErr.Error()}) + return + } + _ = rt.Stop(r.Context(), wk.ContainerID, 3) + if err := rt.Remove(r.Context(), wk.ContainerID); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } } if wk.WorkerClientID != "" { _ = s.syncDelegation(r.Context(), wk.WorkerClientID, "") } + if rtErr == nil && rt != nil { + if err := rt.RemoveVolume(r.Context(), wk.Volume); err != nil { + jsonOut(w, 502, map[string]string{"error": "identity volume removal failed: " + err.Error()}) + return + } + } if err := s.store.DeleteWorker(r.Context(), cid, wid); err != nil { jsonOut(w, 500, map[string]string{"error": err.Error()}) return } - _ = s.docker.RemoveVolume(r.Context(), wk.Volume) jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Service) workerIdentityGet(w http.ResponseWriter, r *http.Request) { @@ -905,7 +1095,12 @@ func (s *Service) workerIdentityGet(w http.ResponseWriter, r *http.Request) { jsonOut(w, 409, map[string]string{"error": "worker identity does not exist yet; start the worker once or upload an identity first"}) return } - b, err := s.docker.GetFile(r.Context(), wk.ContainerID, "/identity/identity.json") + rt, err := s.runtimeForWorker(r.Context(), &wk, false) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + b, err := rt.GetFile(r.Context(), wk.ContainerID, "/identity/identity.json") if err != nil { jsonOut(w, 502, map[string]string{"error": err.Error()}) return @@ -926,6 +1121,11 @@ func (s *Service) workerIdentityPut(w http.ResponseWriter, r *http.Request) { jsonOut(w, 502, map[string]string{"error": err.Error()}) return } + rt, err := s.runtimeForWorker(r.Context(), &wk, false) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } b, err := io.ReadAll(io.LimitReader(r.Body, 256<<10)) if err != nil || len(b) == 0 { jsonOut(w, 400, map[string]string{"error": "identity JSON required"}) @@ -936,9 +1136,9 @@ func (s *Service) workerIdentityPut(w http.ResponseWriter, r *http.Request) { return } if wk.Status == "running" { - _ = s.docker.Stop(r.Context(), wk.ContainerID, 5) + _ = rt.Stop(r.Context(), wk.ContainerID, 5) } - if err := s.docker.PutFile(r.Context(), wk.ContainerID, "/identity", "identity.json", b); err != nil { + if err := rt.PutFile(r.Context(), wk.ContainerID, "/identity", "identity.json", b); err != nil { jsonOut(w, 502, map[string]string{"error": err.Error()}) return } @@ -980,9 +1180,70 @@ func (s *Service) internalWorkerLease(w http.ResponseWriter, r *http.Request) { jsonOut(w, 409, map[string]string{"error": "worker lease revoked"}) return } + _ = s.store.TouchWorkerLease(r.Context(), wk.ID) jsonOut(w, 200, map[string]any{"ok": true, "lease_sec": 45}) } +func (s *Service) internalControllerRegister(w http.ResponseWriter, r *http.Request) { + secret := strings.TrimSpace(s.cfg.ControllerSharedSecret) + provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if len(secret) < 32 || len(provided) != len(secret) || subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { + jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + var in struct { + ID string `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + ProtocolVersion int `json:"protocol_version"` + MaxWorkers int `json:"max_workers"` + MaxRunning int `json:"max_running"` + ReportedWorkers int `json:"reported_workers"` + ReportedRunning int `json:"reported_running"` + } + if decode(r, &in) != nil || strings.TrimSpace(in.ID) == "" || strings.TrimSpace(in.BaseURL) == "" { + jsonOut(w, 400, map[string]string{"error": "id and base_url required"}) + return + } + u, err := url.Parse(strings.TrimSpace(in.BaseURL)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + jsonOut(w, 400, map[string]string{"error": "invalid base_url"}) + return + } + if in.ProtocolVersion != 1 { + jsonOut(w, http.StatusConflict, map[string]string{"error": fmt.Sprintf("unsupported Service Controller protocol version %d (Master supports 1)", in.ProtocolVersion)}) + return + } + if in.MaxWorkers <= 0 || in.MaxWorkers > 100000 || in.MaxRunning <= 0 || in.MaxRunning > 100000 || in.ReportedWorkers < 0 || in.ReportedRunning < 0 { + jsonOut(w, 400, map[string]string{"error": "invalid capacity"}) + return + } + baseURL := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/") + // Registration is bidirectional: accept a heartbeat only when the Master can + // reach the advertised private control endpoint as well. + rt, rtErr := NewRemoteControllerClient(baseURL, s.cfg.ControllerSharedSecret) + if rtErr != nil { + jsonOut(w, 400, map[string]string{"error": rtErr.Error()}) + return + } + pingCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + pingErr := rt.Ping(pingCtx) + cancel() + if pingErr != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Master cannot reach advertised controller URL: " + pingErr.Error()}) + return + } + name := strings.TrimSpace(in.Name) + if name == "" { + name = in.ID + } + if err := s.store.UpsertServiceController(r.Context(), ServiceController{ID: strings.TrimSpace(in.ID), Name: name, BaseURL: baseURL, ProtocolVersion: in.ProtocolVersion, MaxWorkers: in.MaxWorkers, MaxRunning: in.MaxRunning, ReportedWorkers: in.ReportedWorkers, ReportedRunning: in.ReportedRunning}); err != nil { + jsonOut(w, 500, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "controller_id": strings.TrimSpace(in.ID), "master_mode": s.cfg.WorkerOrchestrationMode}) +} + func (s *Service) internalWorkerRegister(w http.ResponseWriter, r *http.Request) { var in struct { WorkerID string `json:"worker_id"` @@ -1012,6 +1273,7 @@ func (s *Service) internalWorkerRegister(w http.ResponseWriter, r *http.Request) jsonOut(w, 500, map[string]string{"error": err.Error()}) return } + _ = s.store.TouchWorkerLease(r.Context(), wk.ID) cust, _ := s.store.CustomerByID(r.Context(), wk.CustomerID) if err := s.syncDelegation(r.Context(), in.ClientID, cust.RewardClientID); err != nil { _ = s.store.SetWorkerRuntime(r.Context(), wk.ID, "error", wk.ContainerID, "delegation failed: "+err.Error()) @@ -1189,9 +1451,20 @@ func (s *Service) billOnce(ctx context.Context) { if wk.ContainerID == "" { continue } - running, err := s.docker.Running(ctx, wk.ContainerID) - if err != nil || !running { - _ = s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", wk.ContainerID, "") + // A fresh worker lease is the most reliable liveness signal in distributed + // mode because it reaches the Master independently of the controller API. + alive := wk.LastLeaseAt != nil && now.Sub(*wk.LastLeaseAt) <= 90*time.Second + if !alive { + rt, rtErr := s.runtimeForWorker(ctx, &wk, false) + if rtErr == nil { + running, runErr := rt.Running(ctx, wk.ContainerID) + if runErr == nil && running { + alive = true + } + } + } + if !alive { + _ = s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", wk.ContainerID, "worker lease/liveness expired") continue } last := wk.LastChargeAt @@ -1510,10 +1783,18 @@ func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) { } out = append(out, item) } + controllers, _ := s.store.ServiceControllers(r.Context()) + controllerItems := make([]map[string]any, 0, len(controllers)) + for _, c := range controllers { + total, running, _ := s.store.ControllerWorkerCounts(r.Context(), c.ID) + controllerItems = append(controllerItems, map[string]any{"id": c.ID, "name": c.Name, "base_url": c.BaseURL, "protocol_version": c.ProtocolVersion, "max_workers": c.MaxWorkers, "max_running": c.MaxRunning, "reported_workers": c.ReportedWorkers, "reported_running": c.ReportedRunning, "assigned_workers": total, "assigned_running": running, "enabled": c.Enabled, "draining": c.Draining, "online": time.Since(c.LastSeen) <= s.cfg.ControllerOfflineAfter, "last_seen": c.LastSeen, "last_error": c.LastError}) + } jsonOut(w, 200, map[string]any{ "manual_credits_enabled": s.cfg.AllowManualCredits, "paypal_enabled": s.paypalAllowed(), "worker_image": s.cfg.WorkerImage, + "orchestration_mode": s.cfg.WorkerOrchestrationMode, + "controllers": controllerItems, "settings": s.portalSettings(r.Context()), "customers": out, }) @@ -1526,7 +1807,15 @@ func (s *Service) adminUpdateWorkerImage(w http.ResponseWriter, r *http.Request) jsonOut(w, http.StatusNotFound, map[string]string{"error": "worker not found"}) return } - if err := s.pullWorkerImage(r.Context()); err != nil { + rt, err := s.runtimeForWorker(r.Context(), &wk, true) + if err != nil { + jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) + return + } + s.workerImageMu.Lock() + err = s.pullWorkerImageOn(r.Context(), rt) + s.workerImageMu.Unlock() + if err != nil { jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } @@ -1534,17 +1823,13 @@ func (s *Service) adminUpdateWorkerImage(w http.ResponseWriter, r *http.Request) jsonOut(w, http.StatusBadGateway, map[string]string{"error": "worker image update: " + err.Error()}) return } - jsonOut(w, http.StatusOK, map[string]any{"ok": true, "worker_id": wk.ID, "image": s.cfg.WorkerImage, "resumed": wk.Status == "running" || wk.Status == "starting"}) + jsonOut(w, http.StatusOK, map[string]any{"ok": true, "worker_id": wk.ID, "image": s.cfg.WorkerImage, "controller_id": wk.ControllerID, "resumed": wk.Status == "running" || wk.Status == "starting"}) } func (s *Service) adminUpdateAllWorkerImages(w http.ResponseWriter, r *http.Request) { - if err := s.pullWorkerImage(r.Context()); err != nil { - jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } rows, err := s.store.DB.QueryContext(r.Context(), `SELECT `+workerCols+` FROM workers ORDER BY created_at`) if err != nil { - jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + jsonOut(w, 500, map[string]string{"error": err.Error()}) return } var workers []Worker @@ -1552,22 +1837,69 @@ func (s *Service) adminUpdateAllWorkerImages(w http.ResponseWriter, r *http.Requ wk, scanErr := scanWorker(rows) if scanErr != nil { _ = rows.Close() - jsonOut(w, http.StatusInternalServerError, map[string]string{"error": scanErr.Error()}) + jsonOut(w, 500, map[string]string{"error": scanErr.Error()}) return } workers = append(workers, wk) } _ = rows.Close() + // Pull once per distinct worker host, then recreate each worker on the host + // that owns its persistent identity volume. + pullState := map[string]error{} updated := 0 var warnings []string - for _, wk := range workers { + for i := range workers { + wk := workers[i] + rt, rtErr := s.runtimeForWorker(r.Context(), &wk, true) + if rtErr != nil { + warnings = append(warnings, wk.ID+": "+rtErr.Error()) + continue + } + key := wk.ControllerID + if key == "" { + key = "local" + } + pullErr, seen := pullState[key] + if !seen { + s.workerImageMu.Lock() + pullErr = s.pullWorkerImageOn(r.Context(), rt) + s.workerImageMu.Unlock() + pullState[key] = pullErr + if pullErr != nil { + warnings = append(warnings, "host "+key+": "+pullErr.Error()) + } + } + if pullErr != nil { + continue + } if err := s.recreateWorkerWithCurrentImage(r.Context(), wk); err != nil { warnings = append(warnings, wk.ID+": "+err.Error()) continue } updated++ } - jsonOut(w, http.StatusOK, map[string]any{"ok": len(warnings) == 0, "image": s.cfg.WorkerImage, "updated": updated, "total": len(workers), "warnings": warnings}) + jsonOut(w, http.StatusOK, map[string]any{"ok": len(warnings) == 0, "image": s.cfg.WorkerImage, "updated": updated, "total": len(workers), "hosts": len(pullState), "warnings": warnings}) +} + +func (s *Service) adminUpdateControllerPolicy(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + jsonOut(w, 400, map[string]string{"error": "controller id required"}) + return + } + var in struct { + Enabled bool `json:"enabled"` + Draining bool `json:"draining"` + } + if decode(r, &in) != nil { + jsonOut(w, 400, map[string]string{"error": "bad json"}) + return + } + if err := s.store.SetServiceControllerPolicy(r.Context(), id, in.Enabled, in.Draining); err != nil { + jsonOut(w, 500, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "id": id, "enabled": in.Enabled, "draining": in.Draining}) } func (s *Service) adminCreditGrant(w http.ResponseWriter, r *http.Request) { diff --git a/internal/customer/store.go b/internal/customer/store.go index fb48d2e..9c0d09f 100644 --- a/internal/customer/store.go +++ b/internal/customer/store.go @@ -51,17 +51,36 @@ CREATE TABLE IF NOT EXISTS workers( task_id TEXT NOT NULL, beacon_path TEXT NOT NULL DEFAULT 'auto', docker_container_id TEXT NOT NULL DEFAULT '', + controller_id TEXT NOT NULL DEFAULT '', docker_volume TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'stopped' CHECK(status IN ('stopped','starting','running','error')), worker_client_id TEXT NOT NULL DEFAULT '', register_token TEXT NOT NULL, rate_micros_per_minute INTEGER NOT NULL, last_charge_at INTEGER, + last_lease_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_error TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS workers_customer_idx ON workers(customer_id,created_at); +CREATE TABLE IF NOT EXISTS service_controllers( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + protocol_version INTEGER NOT NULL DEFAULT 1, + max_workers INTEGER NOT NULL DEFAULT 1000, + max_running INTEGER NOT NULL DEFAULT 100, + reported_workers INTEGER NOT NULL DEFAULT 0, + reported_running INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + draining INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS service_controllers_seen_idx ON service_controllers(last_seen); CREATE TABLE IF NOT EXISTS paypal_orders( order_id TEXT PRIMARY KEY, customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, @@ -147,6 +166,19 @@ func Open(ctx context.Context, path string) (*Store, error) { return nil, fmt.Errorf("customer add customers.%s: %w", m.name, err) } } + for _, m := range []struct{ name, def string }{ + {"controller_id", "TEXT NOT NULL DEFAULT ''"}, + {"last_lease_at", "INTEGER"}, + } { + if err := ensureCustomerColumn(ctx, db, "workers", m.name, m.def); err != nil { + db.Close() + return nil, fmt.Errorf("customer add workers.%s: %w", m.name, err) + } + } + if err := ensureCustomerColumn(ctx, db, "service_controllers", "protocol_version", "INTEGER NOT NULL DEFAULT 1"); err != nil { + db.Close() + return nil, fmt.Errorf("customer add service_controllers.protocol_version: %w", err) + } return &Store{DB: db}, nil } @@ -189,17 +221,100 @@ type Worker struct { TaskID string `json:"task_id"` BeaconPath string `json:"beacon_path"` ContainerID string `json:"container_id"` + ControllerID string `json:"controller_id,omitempty"` Volume string `json:"volume"` Status string `json:"status"` WorkerClientID string `json:"worker_client_id"` RegisterToken string `json:"-"` RateMicrosPerMinute int64 `json:"rate_micros_per_minute"` LastChargeAt *time.Time `json:"last_charge_at,omitempty"` + LastLeaseAt *time.Time `json:"last_lease_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastError string `json:"last_error,omitempty"` } +type ServiceController struct { + ID string `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + ProtocolVersion int `json:"protocol_version"` + MaxWorkers int `json:"max_workers"` + MaxRunning int `json:"max_running"` + ReportedWorkers int `json:"reported_workers"` + ReportedRunning int `json:"reported_running"` + Enabled bool `json:"enabled"` + Draining bool `json:"draining"` + LastSeen time.Time `json:"last_seen"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func scanServiceController(row interface{ Scan(...any) error }) (ServiceController, error) { + var c ServiceController + var enabled, draining int + var seen, created, updated int64 + err := row.Scan(&c.ID, &c.Name, &c.BaseURL, &c.ProtocolVersion, &c.MaxWorkers, &c.MaxRunning, &c.ReportedWorkers, &c.ReportedRunning, &enabled, &draining, &seen, &c.LastError, &created, &updated) + c.Enabled = enabled != 0 + c.Draining = draining != 0 + c.LastSeen = time.UnixMilli(seen).UTC() + c.CreatedAt = time.UnixMilli(created).UTC() + c.UpdatedAt = time.UnixMilli(updated).UTC() + return c, err +} + +const controllerCols = `id,name,base_url,protocol_version,max_workers,max_running,reported_workers,reported_running,enabled,draining,last_seen,last_error,created_at,updated_at` + +func (s *Store) UpsertServiceController(ctx context.Context, c ServiceController) error { + now := time.Now().UTC().UnixMilli() + if c.MaxWorkers <= 0 { + c.MaxWorkers = 1000 + } + if c.MaxRunning <= 0 { + c.MaxRunning = 100 + } + if c.ProtocolVersion <= 0 { + c.ProtocolVersion = 1 + } + _, err := s.DB.ExecContext(ctx, `INSERT INTO service_controllers(id,name,base_url,protocol_version,max_workers,max_running,reported_workers,reported_running,enabled,draining,last_seen,last_error,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,1,0,?,'',?,?) + ON CONFLICT(id) DO UPDATE SET name=excluded.name,base_url=excluded.base_url,protocol_version=excluded.protocol_version,max_workers=excluded.max_workers,max_running=excluded.max_running,reported_workers=excluded.reported_workers,reported_running=excluded.reported_running,last_seen=excluded.last_seen,last_error='',updated_at=excluded.updated_at`, strings.TrimSpace(c.ID), strings.TrimSpace(c.Name), strings.TrimRight(strings.TrimSpace(c.BaseURL), "/"), c.ProtocolVersion, c.MaxWorkers, c.MaxRunning, c.ReportedWorkers, c.ReportedRunning, now, now, now) + return err +} +func (s *Store) ServiceController(ctx context.Context, id string) (ServiceController, error) { + return scanServiceController(s.DB.QueryRowContext(ctx, `SELECT `+controllerCols+` FROM service_controllers WHERE id=?`, strings.TrimSpace(id))) +} +func (s *Store) ServiceControllers(ctx context.Context) ([]ServiceController, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT `+controllerCols+` FROM service_controllers ORDER BY name,id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ServiceController + for rows.Next() { + c, err := scanServiceController(rows) + if err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} +func (s *Store) SetServiceControllerPolicy(ctx context.Context, id string, enabled, draining bool) error { + _, err := s.DB.ExecContext(ctx, `UPDATE service_controllers SET enabled=?,draining=?,updated_at=? WHERE id=?`, boolInt(enabled), boolInt(draining), time.Now().UTC().UnixMilli(), strings.TrimSpace(id)) + return err +} +func (s *Store) ControllerWorkerCounts(ctx context.Context, id string) (total, running int, err error) { + err = s.DB.QueryRowContext(ctx, `SELECT count(*),COALESCE(sum(CASE WHEN status IN ('running','starting') THEN 1 ELSE 0 END),0) FROM workers WHERE controller_id=?`, strings.TrimSpace(id)).Scan(&total, &running) + return +} +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + var ErrInvalidInvite = errors.New("registration invite invalid, expired or already used") func scanCustomer(row interface{ Scan(...any) error }, withPassword bool) (Customer, string, string, error) { @@ -438,9 +553,9 @@ func (s *Store) WorkerByClientID(ctx context.Context, clientID string) (Worker, func scanWorker(row interface{ Scan(...any) error }) (Worker, error) { var w Worker - var last sql.NullInt64 + var last, lastLease sql.NullInt64 var created, updated int64 - err := row.Scan(&w.ID, &w.CustomerID, &w.TaskID, &w.BeaconPath, &w.ContainerID, &w.Volume, &w.Status, &w.WorkerClientID, &w.RegisterToken, &w.RateMicrosPerMinute, &last, &created, &updated, &w.LastError) + err := row.Scan(&w.ID, &w.CustomerID, &w.TaskID, &w.BeaconPath, &w.ContainerID, &w.ControllerID, &w.Volume, &w.Status, &w.WorkerClientID, &w.RegisterToken, &w.RateMicrosPerMinute, &last, &lastLease, &created, &updated, &w.LastError) if err != nil { return w, err } @@ -448,16 +563,20 @@ func scanWorker(row interface{ Scan(...any) error }) (Worker, error) { v := time.UnixMilli(last.Int64).UTC() w.LastChargeAt = &v } + if lastLease.Valid { + v := time.UnixMilli(lastLease.Int64).UTC() + w.LastLeaseAt = &v + } w.CreatedAt = time.UnixMilli(created).UTC() w.UpdatedAt = time.UnixMilli(updated).UTC() return w, nil } -const workerCols = `id,customer_id,task_id,beacon_path,docker_container_id,docker_volume,status,worker_client_id,register_token,rate_micros_per_minute,last_charge_at,created_at,updated_at,last_error` +const workerCols = `id,customer_id,task_id,beacon_path,docker_container_id,controller_id,docker_volume,status,worker_client_id,register_token,rate_micros_per_minute,last_charge_at,last_lease_at,created_at,updated_at,last_error` func (s *Store) CreateWorker(ctx context.Context, w Worker) error { now := time.Now().UTC().UnixMilli() - _, err := s.DB.ExecContext(ctx, `INSERT INTO workers(id,customer_id,task_id,beacon_path,docker_volume,status,register_token,rate_micros_per_minute,created_at,updated_at) VALUES(?,?,?,?,?,'stopped',?,?,?,?)`, w.ID, w.CustomerID, w.TaskID, w.BeaconPath, w.Volume, w.RegisterToken, w.RateMicrosPerMinute, now, now) + _, err := s.DB.ExecContext(ctx, `INSERT INTO workers(id,customer_id,task_id,beacon_path,controller_id,docker_volume,status,register_token,rate_micros_per_minute,created_at,updated_at) VALUES(?,?,?,?,?,?,'stopped',?,?,?,?)`, w.ID, w.CustomerID, w.TaskID, w.BeaconPath, w.ControllerID, w.Volume, w.RegisterToken, w.RateMicrosPerMinute, now, now) return err } func (s *Store) Worker(ctx context.Context, cid, wid string) (Worker, error) { @@ -540,6 +659,14 @@ func (s *Store) SetWorkerRuntime(ctx context.Context, wid, status, containerID, _, err := s.DB.ExecContext(ctx, `UPDATE workers SET status=?,docker_container_id=?,last_error=?,updated_at=? WHERE id=?`, status, containerID, lastErr, time.Now().UTC().UnixMilli(), wid) return err } +func (s *Store) SetWorkerController(ctx context.Context, wid, controllerID string) error { + _, err := s.DB.ExecContext(ctx, `UPDATE workers SET controller_id=?,updated_at=? WHERE id=?`, strings.TrimSpace(controllerID), time.Now().UTC().UnixMilli(), wid) + return err +} +func (s *Store) TouchWorkerLease(ctx context.Context, wid string) error { + _, err := s.DB.ExecContext(ctx, `UPDATE workers SET last_lease_at=?,updated_at=? WHERE id=?`, time.Now().UTC().UnixMilli(), time.Now().UTC().UnixMilli(), wid) + return err +} func (s *Store) SetWorkerClient(ctx context.Context, wid, clientID string) error { _, err := s.DB.ExecContext(ctx, `UPDATE workers SET worker_client_id=?,updated_at=? WHERE id=?`, clientID, time.Now().UTC().UnixMilli(), wid) return err diff --git a/internal/customerui/dist/admin/app.js b/internal/customerui/dist/admin/app.js index e56ab9c..e4a07ce 100644 --- a/internal/customerui/dist/admin/app.js +++ b/internal/customerui/dist/admin/app.js @@ -6,9 +6,11 @@ const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3}) function settingsToUI(s){$('loginEnabled').checked=!!s.login_enabled;$('registrationEnabled').checked=!!s.registration_enabled;$('inviteRequired').checked=!!s.invite_required;$('powBits').value=s.registration_pow_bits??0;$('signupBonus').value=Number(s.signup_bonus_micros||0)/1e6;$('positiveBonus').value=Number(s.positive_tip_bonus_micros||0)/1e6;$('inviteCount').textContent=s.active_registration_invites??0} function settingsPayload(){return{login_enabled:$('loginEnabled').checked,registration_enabled:$('registrationEnabled').checked,invite_required:$('inviteRequired').checked,registration_pow_bits:Number($('powBits').value||0),signup_bonus_credits:Number($('signupBonus').value||0),positive_tip_bonus_credits:Number($('positiveBonus').value||0)}} async function saveSettings(){try{const x=await api('/api/admin/settings',{method:'PUT',body:settingsPayload()});settingsToUI(x);msg('Portal-Einstellungen gespeichert')}catch(e){msg(e.message,true)}} -function workerRows(c){if(!c.worker_items?.length)return'
Keine Worker.
';return `
${c.worker_items.map(w=>`
${esc(w.id)}
${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}
${w.last_error?`
${esc(w.last_error)}
`:''}
${esc(w.status)}
`).join('')}
`} +function workerRows(c){if(!c.worker_items?.length)return'
Keine Worker.
';return `
${c.worker_items.map(w=>`
${esc(w.id)}
${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)} · Host ${esc(w.controller_id||'local')}
${w.last_error?`
${esc(w.last_error)}
`:''}
${esc(w.status)}
`).join('')}
`} +function controllerHTML(c){const state=c.online?'ONLINE':'OFFLINE',drain=c.draining?'DRAIN':'',disabled=!c.enabled?'DISABLED':'';return `
${esc(c.name||c.id)}
${esc(c.id)} · protocol v${Number(c.protocol_version||0)} ${state} ${drain} ${disabled}
${Number(c.assigned_running||0)}/${Number(c.max_running||0)} running
${Number(c.assigned_workers||0)}/${Number(c.max_workers||0)} assigned
${esc(c.base_url||'—')}
Heartbeat: ${c.last_seen?new Date(c.last_seen).toLocaleString('de-DE'):'—'} · reported ${Number(c.reported_running||0)}/${Number(c.reported_workers||0)}
`} +function bindControllers(){document.querySelectorAll('.controllerDrain').forEach(b=>b.onclick=async()=>{const row=b.closest('.controller-card'),c=last?.controllers?.find(x=>x.id===row.dataset.cid);try{await api(`/api/admin/controllers/${encodeURIComponent(row.dataset.cid)}`,{method:'PUT',body:{enabled:c?.enabled!==false,draining:!c?.draining}});msg(c?.draining?'Drain aufgehoben':'Controller auf Drain gesetzt');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.controllerEnable').forEach(b=>b.onclick=async()=>{const row=b.closest('.controller-card'),c=last?.controllers?.find(x=>x.id===row.dataset.cid);const enabled=!(c?.enabled!==false);if(!enabled&&!confirm('Controller deaktivieren? Bestehende Worker werden nicht automatisch verschoben; Steueraktionen sind bis zur Reaktivierung blockiert.'))return;try{await api(`/api/admin/controllers/${encodeURIComponent(row.dataset.cid)}`,{method:'PUT',body:{enabled,draining:c?.draining||false}});msg(enabled?'Controller aktiviert':'Controller deaktiviert');await load()}catch(e){msg(e.message,true)}})} function taskOptions(){return taskCatalog.map(t=>``).join('')} function customerHTML(c){const status=c.blocked?`GESPERRT`:'AKTIV',bypass=c.worker_limit_bypass?'LIMIT BYPASS':'';return `

${esc(c.username)} ${status} ${bypass}

${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}
${c.blocked_reason?`
Grund: ${esc(c.blocked_reason)}
`:''}
${cr(c.balance_micros)} Credits${c.running}/${c.workers} Worker aktiv
${c.blocked?'':''}${manual?'':''}
${workerRows(c)}
`} function bindCustomerActions(){document.querySelectorAll('.block').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const reason=prompt('Optionaler Sperrgrund:','');if(reason===null)return;if(!confirm('Benutzer sperren, Sessions invalidieren und alle Worker stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/block`,{method:'POST',body:{reason}});msg(x.warnings?.length?`Benutzer gesperrt · Warnungen: ${x.warnings.join(' · ')}`:'Benutzer gesperrt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.unblock').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/unblock`,{method:'POST'});msg('Benutzer freigegeben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopAll').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');if(!confirm('Alle Worker dieses Benutzers stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers/stop`,{method:'POST'});msg(x.warnings?.length?x.warnings.join(' · '):'Worker gestoppt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopWorker').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/stop`,{method:'POST'});msg('Worker gestoppt');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.updateWorkerImage').forEach(b=>b.onclick=async()=>{if(!confirm(`Worker ${b.dataset.wid} mit dem aktuell konfigurierten Image neu erzeugen? Die Identity bleibt erhalten.`))return;const old=b.textContent;b.disabled=true;b.textContent='UPDATE …';try{const x=await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/update-image`,{method:'POST'});msg(`Worker aktualisiert · ${x.image}${x.resumed?' · wieder gestartet':''}`);await load()}catch(e){msg(e.message,true)}finally{b.disabled=false;b.textContent=old}});document.querySelectorAll('.limitBypass').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),c=last?.customers?.find(x=>x.id===row.dataset.id);try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/worker-limit`,{method:'PUT',body:{bypass:!c?.worker_limit_bypass}});msg(c?.worker_limit_bypass?'Worker-Limit wieder aktiv':'Worker-Limit für Benutzer aufgehoben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.addWorker').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),task=row.querySelector('.newWorkerTask').value,beacon=row.querySelector('.newWorkerBeacon').value;if(!task){msg('Task auswählen',true);return}try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers`,{method:'POST',body:{task_id:task,beacon_path:beacon}});msg('Worker dem Benutzer zugeordnet');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Credits gebucht`);await load()}catch(e){msg(e.message,true)}})} -async function load(){const [x,tasks]=await Promise.all([api('/api/admin/overview'),api('/api/admin/tasks').catch(()=>[])]);last=x;taskCatalog=Array.isArray(tasks)?tasks:[];manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('workerImage').textContent=x.worker_image||'—';$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'
Noch keine Benutzer.
';bindCustomerActions()} +async function load(){const [x,tasks]=await Promise.all([api('/api/admin/overview'),api('/api/admin/tasks').catch(()=>[])]);last=x;taskCatalog=Array.isArray(tasks)?tasks:[];manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('workerImage').textContent=x.worker_image||'—';$('orchestrationMode').textContent=(x.orchestration_mode||'direct').toUpperCase();$('controllers').innerHTML=(x.controllers||[]).map(controllerHTML).join('')||'
Keine Service Controller registriert.
';$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'
Noch keine Benutzer.
';bindControllers();bindCustomerActions()} $('saveSettings').onclick=saveSettings;$('saveRewards').onclick=saveSettings;$('createInvite').onclick=async()=>{try{const x=await api('/api/admin/invites',{method:'POST',body:{label:$('inviteLabel').value,hours:Number($('inviteHours').value||24)}});$('inviteOut').textContent=x.invite_code;$('inviteOut').classList.remove('hidden');msg('Invite erzeugt · jetzt sicher an genau einen Benutzer weitergeben');await load()}catch(e){msg(e.message,true)}};$('updateAllImages').onclick=async()=>{const total=(last?.customers||[]).reduce((n,c)=>n+Number(c.workers||0),0);if(!confirm(`CS_WORKER_IMAGE neu pullen und ${total} bestehende Worker aktualisieren? Laufende Worker werden kurz neu gestartet.`))return;const b=$('updateAllImages'),old=b.textContent;b.disabled=true;b.textContent='IMAGE UPDATE LÄUFT …';try{const x=await api('/api/admin/workers/update-image',{method:'POST'});const warn=x.warnings?.length?` · ${x.warnings.length} Fehler`:'';msg(`${x.updated}/${x.total} Worker aktualisiert · ${x.image}${warn}`,!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}finally{b.disabled=false;b.textContent=old}};$('refresh').onclick=()=>load().catch(e=>msg(e.message,true));$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{}); diff --git a/internal/customerui/dist/admin/index.html b/internal/customerui/dist/admin/index.html index b50d914..e29abe6 100644 --- a/internal/customerui/dist/admin/index.html +++ b/internal/customerui/dist/admin/index.html @@ -9,6 +9,7 @@

Invite-Codes

Optionaler Schutz gegen Account-Farming. Jeder Code ist einmalig und wird nur hier bei der Erstellung im Klartext gezeigt.

Aktive, unbenutzte Invites: 0
-

Hosted Worker Image

Aktuell konfiguriert: —

„Alle aktualisieren“ pullt das konfigurierte CS_WORKER_IMAGE erneut und erzeugt alle bestehenden Worker-Container mit demselben Identity-Volume neu. Laufende Worker werden anschließend automatisch fortgesetzt.

+

Hosted Worker Image

Aktuell konfiguriert: —

„Alle aktualisieren“ pullt das konfigurierte CS_WORKER_IMAGE auf jedem beteiligten Worker-Host erneut und erzeugt die Container auf ihrem zugeordneten Host mit demselben Identity-Volume neu. Laufende Worker werden anschließend automatisch fortgesetzt.

+

Service Controller

Orchestrierung: —. Neue Worker werden im Controller-Modus automatisch auf erreichbare Hosts mit freier Kapazität verteilt. DRAIN verhindert nur neue Zuweisungen; vorhandene Worker bleiben auf ihrem Host.

Benutzer

diff --git a/internal/customerui/dist/admin/styles.css b/internal/customerui/dist/admin/styles.css index b6c5b40..c7e1367 100644 --- a/internal/customerui/dist/admin/styles.css +++ b/internal/customerui/dist/admin/styles.css @@ -1,3 +1,4 @@ :root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6;--danger:#ff6978;--warn:#ffca56}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1200px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}h2,h3{font-family:system-ui;margin:0 0 10px}section,.customer,article{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);margin:10px 0}label.toggle{display:flex;align-items:center;gap:9px;color:var(--text)}label.toggle input{width:auto;margin:0}input{display:block;width:100%;margin-top:5px;background:#090e14;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:10px}button{background:var(--accent);color:#06120c;border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}button:disabled{opacity:.4;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}button.danger{background:#4b1d24;color:#ffd7dc;border:1px solid #79343f}.hidden{display:none!important}.head,.section-title,.customer-head,.actions,.worker-row{display:flex;justify-content:space-between;gap:12px;align-items:center}.admin-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.admin-grid article{margin:0}.small{font-size:12px;color:var(--muted);word-break:break-all}.notice,.msg,.invite{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}.invite{border:1px solid var(--accent);font-weight:800;word-break:break-all;user-select:all}.customer{margin:12px 0}.customer.blocked{border-color:#6b333b}.customer h3{display:flex;align-items:center;gap:8px}.customer-stats{display:flex;flex-direction:column;align-items:flex-end;color:var(--muted)}.actions{justify-content:flex-start;flex-wrap:wrap;margin:14px 0}.actions input{width:130px;margin:0}.worker-list{display:grid;gap:6px;border-top:1px solid var(--line);padding-top:10px}.worker-row{background:#0b1118;border-radius:8px;padding:8px 10px}.pill{font-size:11px;padding:3px 7px;border-radius:99px;background:#222d38;color:var(--muted)}.pill.on{background:#123022;color:var(--accent)}.pill.danger{background:#3c171d;color:#ff9ba6}.danger-text{color:#ff9ba6}@media(max-width:900px){.admin-grid{grid-template-columns:1fr}.customer-head,.head,.section-title{align-items:flex-start;flex-direction:column}.customer-stats{align-items:flex-start}.worker-row{align-items:flex-start;flex-wrap:wrap}} .admin-worker-create{display:grid;grid-template-columns:minmax(0,1fr) 110px auto;gap:8px;margin:10px 0 12px}.admin-worker-create select{min-width:0}.pill.warn{color:#ffd781;border-color:rgba(255,205,90,.28);background:rgba(255,205,90,.07)}@media(max-width:720px){.admin-worker-create{grid-template-columns:1fr 1fr}.admin-worker-create .addWorker{grid-column:1/-1}} .worker-image-control{display:flex;align-items:center;justify-content:space-between;gap:18px}.worker-image-control>div{min-width:0}.worker-image-control strong{word-break:break-all;color:var(--accent)}.worker-main{min-width:0;flex:1}.worker-row .updateWorkerImage{white-space:nowrap}@media(max-width:720px){.worker-image-control{align-items:flex-start;flex-direction:column}.worker-image-control button{width:100%}} +.controller-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:10px;margin-top:12px}.controller-card{border:1px solid #1a4052;border-radius:12px;padding:13px;background:#071522}.controller-card.offline{border-color:#79313d;background:#1b0d12}.controller-card.drain{border-color:#81641c}.controller-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.controller-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}.controller-host{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:#7898aa;overflow-wrap:anywhere} diff --git a/internal/data/admin_control_test.go b/internal/data/admin_control_test.go index ee237da..d49fe25 100644 --- a/internal/data/admin_control_test.go +++ b/internal/data/admin_control_test.go @@ -101,7 +101,7 @@ func TestArtifactTransferPreservesWinnerAndAdminDropHasNoGameWin(t *testing.T) { t.Fatalf("transfer audit count=%d", transfers) } - drop, err := s.CreateAdminArtifactDrop(ctx, "client_b", id, 28) + drop, err := s.CreateAdminArtifactDrop(ctx, "client_b", id, "ULTRA RARE", 28) if err != nil { t.Fatal(err) } @@ -114,4 +114,7 @@ func TestArtifactTransferPreservesWinnerAndAdminDropHasNoGameWin(t *testing.T) { if drop.ArtifactOwnerClientID == nil || *drop.ArtifactOwnerClientID != "client_b" { t.Fatalf("admin drop owner=%v", drop.ArtifactOwnerClientID) } + if drop.ArtifactRarityOverride != "ULTRA RARE" { + t.Fatalf("admin drop rarity override=%q", drop.ArtifactRarityOverride) + } } diff --git a/internal/data/schema.sql b/internal/data/schema.sql index 06c2ace..66113c3 100644 --- a/internal/data/schema.sql +++ b/internal/data/schema.sql @@ -32,6 +32,8 @@ CREATE TABLE IF NOT EXISTS tasks ( retire_after_completion INTEGER NOT NULL DEFAULT 0, artifact_owner_client_id TEXT REFERENCES clients(id), artifact_origin TEXT NOT NULL DEFAULT 'win', + artifact_rarity_override TEXT NOT NULL DEFAULT '', + artifact_rarity TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, completed_at INTEGER, winner_client_id TEXT REFERENCES clients(id), diff --git a/internal/data/store.go b/internal/data/store.go index fa8f8d8..04f0525 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -117,6 +117,8 @@ func OpenSQLite(ctx context.Context, path string) (*sql.DB, error) { {"retire_after_completion", "INTEGER NOT NULL DEFAULT 0"}, {"artifact_owner_client_id", "TEXT REFERENCES clients(id)"}, {"artifact_origin", "TEXT NOT NULL DEFAULT 'win'"}, + {"artifact_rarity_override", "TEXT NOT NULL DEFAULT ''"}, + {"artifact_rarity", "TEXT NOT NULL DEFAULT ''"}, } { if err := ensureColumn(ctx, db, "tasks", m.name, m.def); err != nil { db.Close() @@ -228,6 +230,8 @@ type Task struct { RetireAfterCompletion bool ArtifactOwnerClientID *string ArtifactOrigin string + ArtifactRarityOverride string + ArtifactRarity string CreatedAt time.Time CompletedAt *time.Time WinnerClientID *string @@ -237,7 +241,7 @@ type Task struct { ArtifactManifestURI *string } -const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,retire_after_completion,artifact_owner_client_id,artifact_origin,created_at,completed_at,winner_client_id,winner_worker_client_id,artifact_status,artifact_uri,artifact_manifest_uri` +const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,retire_after_completion,artifact_owner_client_id,artifact_origin,artifact_rarity_override,artifact_rarity,created_at,completed_at,winner_client_id,winner_worker_client_id,artifact_status,artifact_uri,artifact_manifest_uri` func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, string, error) { var t Task @@ -247,7 +251,7 @@ func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, s var guessMin, clientSubmit sql.NullInt64 var paused, retireAfter int var secret string - args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &retireAfter, &artifactOwner, &t.ArtifactOrigin, &created, &completed, &winner, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI} + args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &retireAfter, &artifactOwner, &t.ArtifactOrigin, &t.ArtifactRarityOverride, &t.ArtifactRarity, &created, &completed, &winner, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI} if withSecret { args = append(args, &secret) } @@ -857,6 +861,7 @@ type PublicArtifact struct { WinnerClientID string `json:"winner_client_id"` RangeBits int `json:"range_bits"` ArtifactOrigin string `json:"artifact_origin"` + Rarity string `json:"rarity"` CompletedAt time.Time `json:"completed_at"` PreviewURI string `json:"preview_uri"` } @@ -866,7 +871,7 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) ( limit = 48 } winner = strings.TrimSpace(winner) - rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(artifact_owner_client_id,winner_client_id),range_bits,artifact_origin,COALESCE(completed_at,created_at) + rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(artifact_owner_client_id,winner_client_id),range_bits,artifact_origin,artifact_rarity,COALESCE(completed_at,created_at) FROM tasks WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND COALESCE(artifact_owner_client_id,winner_client_id) IS NOT NULL @@ -880,7 +885,7 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) ( for rows.Next() { var a PublicArtifact var completed int64 - if err := rows.Scan(&a.TaskID, &a.WinnerClientID, &a.RangeBits, &a.ArtifactOrigin, &completed); err != nil { + if err := rows.Scan(&a.TaskID, &a.WinnerClientID, &a.RangeBits, &a.ArtifactOrigin, &a.Rarity, &completed); err != nil { return nil, err } a.CompletedAt = fromUnixMS(completed) @@ -897,6 +902,7 @@ type OwnedArtifact struct { DisplayName string `json:"display_name"` RangeBits int `json:"range_bits"` ArtifactOrigin string `json:"artifact_origin"` + Rarity string `json:"rarity"` CompletedAt time.Time `json:"completed_at"` PreviewURI string `json:"preview_uri"` DownloadURI string `json:"download_uri"` @@ -907,7 +913,7 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow limit = 48 } cid = strings.TrimSpace(cid) - rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,artifact_origin,COALESCE(completed_at,created_at) + rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,artifact_origin,artifact_rarity,COALESCE(completed_at,created_at) FROM tasks WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND COALESCE(artifact_owner_client_id,winner_client_id)=? ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, cid, limit) @@ -919,7 +925,7 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow for rows.Next() { var a OwnedArtifact var completed int64 - if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &a.ArtifactOrigin, &completed); err != nil { + if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &a.ArtifactOrigin, &a.Rarity, &completed); err != nil { return nil, err } a.CompletedAt = fromUnixMS(completed) @@ -991,6 +997,8 @@ type AdminTask struct { RetireAfterCompletion bool `json:"retire_after_completion"` ArtifactOwnerClientID *string `json:"artifact_owner_client_id,omitempty"` ArtifactOrigin string `json:"artifact_origin"` + ArtifactRarityOverride string `json:"artifact_rarity_override"` + ArtifactRarity string `json:"artifact_rarity"` CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at"` WinnerClientID *string `json:"winner_client_id"` @@ -1006,7 +1014,7 @@ func (s *Store) AdminTasks(ctx context.Context, status, query string, limit int) if limit < 1 || limit > 1000 { limit = 200 } - rows, err := s.DB.QueryContext(ctx, `SELECT t.id,t.status,t.paused,t.range_bits,t.guess_min_interval_sec,t.client_submit_interval_sec,t.revision,t.parent_task_id,t.display_name,t.description,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.retire_after_completion,t.artifact_owner_client_id,t.artifact_origin,t.created_at,t.completed_at,t.winner_client_id, + rows, err := s.DB.QueryContext(ctx, `SELECT t.id,t.status,t.paused,t.range_bits,t.guess_min_interval_sec,t.client_submit_interval_sec,t.revision,t.parent_task_id,t.display_name,t.description,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.retire_after_completion,t.artifact_owner_client_id,t.artifact_origin,t.artifact_rarity_override,t.artifact_rarity,t.created_at,t.completed_at,t.winner_client_id, (SELECT count(*) FROM task_points p WHERE p.task_id=t.id), COALESCE((SELECT sum(p.guess_count) FROM task_points p WHERE p.task_id=t.id),0), t.artifact_status,t.artifact_uri,t.artifact_manifest_uri,t.artifact_error @@ -1025,7 +1033,7 @@ func (s *Store) AdminTasks(ctx context.Context, status, query string, limit int) var completed sql.NullInt64 var winner, owner, artifactURI, manifestURI, artifactErr, parent sql.NullString var guessMin, clientSubmit sql.NullInt64 - if err := rows.Scan(&t.ID, &t.Status, &paused, &t.RangeBits, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &retire, &owner, &t.ArtifactOrigin, &created, &completed, &winner, &t.PointCount, &t.GuessCount, &t.ArtifactStatus, &artifactURI, &manifestURI, &artifactErr); err != nil { + if err := rows.Scan(&t.ID, &t.Status, &paused, &t.RangeBits, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &retire, &owner, &t.ArtifactOrigin, &t.ArtifactRarityOverride, &t.ArtifactRarity, &created, &completed, &winner, &t.PointCount, &t.GuessCount, &t.ArtifactStatus, &artifactURI, &manifestURI, &artifactErr); err != nil { return nil, err } t.Paused = paused != 0 @@ -1173,13 +1181,15 @@ func (s *Store) TransferArtifact(ctx context.Context, taskID, targetClientID, re // an admin drop stays observable even when the UI is currently showing only // active hunt tasks. type AdminArtifactQueueItem struct { - TaskID string `json:"task_id"` - ArtifactOrigin string `json:"artifact_origin"` - ArtifactStatus string `json:"artifact_status"` - ArtifactError *string `json:"artifact_error,omitempty"` - ArtifactURI *string `json:"artifact_uri,omitempty"` - OwnerClientID string `json:"owner_client_id"` - CreatedAtMS int64 `json:"created_at_ms"` + TaskID string `json:"task_id"` + ArtifactOrigin string `json:"artifact_origin"` + ArtifactRarityOverride string `json:"artifact_rarity_override"` + ArtifactRarity string `json:"artifact_rarity"` + ArtifactStatus string `json:"artifact_status"` + ArtifactError *string `json:"artifact_error,omitempty"` + ArtifactURI *string `json:"artifact_uri,omitempty"` + OwnerClientID string `json:"owner_client_id"` + CreatedAtMS int64 `json:"created_at_ms"` } func (s *Store) AdminArtifactQueueItems(ctx context.Context, ids []string) ([]AdminArtifactQueueItem, error) { @@ -1208,7 +1218,7 @@ func (s *Store) AdminArtifactQueueItems(ctx context.Context, ids []string) ([]Ad marks[i] = "?" args[i] = id } - rows, err := s.DB.QueryContext(ctx, `SELECT id,artifact_origin,artifact_status,artifact_error,artifact_uri,COALESCE(artifact_owner_client_id,winner_client_id,''),created_at + rows, err := s.DB.QueryContext(ctx, `SELECT id,artifact_origin,artifact_rarity_override,artifact_rarity,artifact_status,artifact_error,artifact_uri,COALESCE(artifact_owner_client_id,winner_client_id,''),created_at FROM tasks WHERE id IN (`+strings.Join(marks, ",")+`) ORDER BY created_at`, args...) if err != nil { return nil, err @@ -1218,7 +1228,7 @@ func (s *Store) AdminArtifactQueueItems(ctx context.Context, ids []string) ([]Ad for rows.Next() { var x AdminArtifactQueueItem var aerr, uri sql.NullString - if err := rows.Scan(&x.TaskID, &x.ArtifactOrigin, &x.ArtifactStatus, &aerr, &uri, &x.OwnerClientID, &x.CreatedAtMS); err != nil { + if err := rows.Scan(&x.TaskID, &x.ArtifactOrigin, &x.ArtifactRarityOverride, &x.ArtifactRarity, &x.ArtifactStatus, &aerr, &uri, &x.OwnerClientID, &x.CreatedAtMS); err != nil { return nil, err } if aerr.Valid { @@ -1234,12 +1244,36 @@ func (s *Store) AdminArtifactQueueItems(ctx context.Context, ids []string) ([]Ad return out, rows.Err() } +func normalizeArtifactRarity(v string) (string, error) { + switch strings.ToUpper(strings.TrimSpace(v)) { + case "": + return "", nil + case "COMMON": + return "COMMON", nil + case "UNCOMMON": + return "UNCOMMON", nil + case "RARE": + return "RARE", nil + case "ULTRA RARE", "ULTRA_RARE", "ULTRARARE": + return "ULTRA RARE", nil + case "SPECIAL ILLUSTRATION RARE", "SPECIAL_ILLUSTRATION_RARE", "SIR": + return "SPECIAL ILLUSTRATION RARE", nil + default: + return "", fmt.Errorf("rarity must be empty/random, COMMON, UNCOMMON, RARE, ULTRA RARE or SPECIAL ILLUSTRATION RARE") + } +} + // CreateAdminArtifactDrop queues a collectible for an existing client without // recording a game win. A random task is used as the art-direction template // when templateTaskID is empty; the new seed/id still randomize collection traits. -func (s *Store) CreateAdminArtifactDrop(ctx context.Context, targetClientID, templateTaskID string, fallbackBits int) (Task, error) { +func (s *Store) CreateAdminArtifactDrop(ctx context.Context, targetClientID, templateTaskID, rarityOverride string, fallbackBits int) (Task, error) { targetClientID = strings.TrimSpace(targetClientID) templateTaskID = strings.TrimSpace(templateTaskID) + var err error + rarityOverride, err = normalizeArtifactRarity(rarityOverride) + if err != nil { + return Task{}, err + } if targetClientID == "" { return Task{}, fmt.Errorf("target client is required") } @@ -1301,10 +1335,10 @@ func (s *Store) CreateAdminArtifactDrop(ctx context.Context, targetClientID, tem _, err = tx.ExecContext(ctx, `INSERT INTO tasks( id,secret,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id, display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,retire_after_completion, - artifact_owner_client_id,artifact_origin,created_at,completed_at,winner_signature,winning_guess,artifact_status) - VALUES(?,?,?,?,'completed',0,?,?,0,NULL,?,?,?,?,?,1,?,'admin_drop',?,?,?,?, 'pending')`, + artifact_owner_client_id,artifact_origin,artifact_rarity_override,created_at,completed_at,winner_signature,winning_guess,artifact_status) + VALUES(?,?,?,?,'completed',0,?,?,0,NULL,?,?,?,?,?,1,?,'admin_drop',?,?,?,?,?, 'pending')`, id, secret, seed, bits, guessMin, clientSubmit, name, template.Description, template.NFTPromptInstructions, template.NFTNegativePrompt, template.NFTStyleReference, - targetClientID, now, now, "admin-drop:"+NewID("sig_"), "") + targetClientID, rarityOverride, now, now, "admin-drop:"+NewID("sig_"), "") if err != nil { return Task{}, err } diff --git a/internal/server/server.go b/internal/server/server.go index 4ad8065..a064209 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1549,6 +1549,7 @@ func (s *Server) adminArtifactDrop(w http.ResponseWriter, r *http.Request) { var in struct { TargetClientID string `json:"target_client_id"` TemplateTaskID string `json:"template_task_id"` + Rarity string `json:"rarity"` Count int `json:"count"` } if err := decode(r, &in); err != nil || strings.TrimSpace(in.TargetClientID) == "" { @@ -1564,7 +1565,7 @@ func (s *Server) adminArtifactDrop(w http.ResponseWriter, r *http.Request) { } created := make([]string, 0, in.Count) for i := 0; i < in.Count; i++ { - t, err := s.store.CreateAdminArtifactDrop(r.Context(), in.TargetClientID, in.TemplateTaskID, s.settings.Get().TaskRangeBits) + t, err := s.store.CreateAdminArtifactDrop(r.Context(), in.TargetClientID, in.TemplateTaskID, in.Rarity, s.settings.Get().TaskRangeBits) if err != nil { jsonOut(w, 400, map[string]any{"error": err.Error(), "created_task_ids": created}) return @@ -1574,8 +1575,8 @@ func (s *Server) adminArtifactDrop(w http.ResponseWriter, r *http.Request) { if s.artifactWorker != nil { s.artifactWorker.Notify() } - log.Printf("admin artifact drop queued: owner=%s count=%d tasks=%s", strings.TrimSpace(in.TargetClientID), len(created), strings.Join(created, ",")) - jsonOut(w, 201, map[string]any{"ok": true, "created_task_ids": created, "artifact_status": "pending"}) + log.Printf("admin artifact drop queued: owner=%s count=%d rarity=%q tasks=%s", strings.TrimSpace(in.TargetClientID), len(created), strings.TrimSpace(in.Rarity), strings.Join(created, ",")) + jsonOut(w, 201, map[string]any{"ok": true, "created_task_ids": created, "artifact_status": "pending", "rarity": strings.TrimSpace(in.Rarity)}) } func (s *Server) adminArtifactStatus(w http.ResponseWriter, r *http.Request) { diff --git a/internal/servicecontroller/server.go b/internal/servicecontroller/server.go new file mode 100644 index 0000000..00570dd --- /dev/null +++ b/internal/servicecontroller/server.go @@ -0,0 +1,573 @@ +package servicecontroller + +import ( + "bytes" + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "neuralhunt/internal/customer" +) + +type Config struct { + ID string + Name string + MasterURL string + AdvertiseURL string + SharedSecret string + ControlAddr string + AdminAddr string + AdminUser string + AdminPassword string + AdminCookieSecureMode string + MaxWorkers int + MaxRunning int + HeartbeatInterval time.Duration + DefaultWorkerImage string + WorkerNetworkOverride string + WorkerRegisterURLOverride string + GameURLOverride string + RegistryUsername string + RegistryPassword string + RegistryServer string +} + +type Service struct { + docker *customer.DockerClient + cfg Config + hc *http.Client + adminSessions sync.Map + regMu sync.RWMutex + lastRegOK bool + lastRegAt time.Time + lastRegErr string +} + +func New(docker *customer.DockerClient, cfg Config) *Service { + if cfg.MaxWorkers <= 0 { + cfg.MaxWorkers = 1000 + } + if cfg.MaxRunning <= 0 { + cfg.MaxRunning = 100 + } + if cfg.HeartbeatInterval < 5*time.Second { + cfg.HeartbeatInterval = 15 * time.Second + } + return &Service{docker: docker, cfg: cfg, hc: &http.Client{Timeout: 10 * time.Second}} +} + +func jsonOut(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} +func decode(r *http.Request, v any, limit int64) error { + if limit <= 0 { + limit = 1 << 20 + } + d := json.NewDecoder(io.LimitReader(r.Body, limit)) + d.DisallowUnknownFields() + return d.Decode(v) +} + +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) + return strings.EqualFold(proto, "https") +} + +func (s *Service) security(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'") + if requestIsHTTPS(r) { + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + } + next.ServeHTTP(w, r) + }) +} + +func (s *Service) authorized(r *http.Request) bool { + provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + secret := strings.TrimSpace(s.cfg.SharedSecret) + return len(provided) == len(secret) && len(secret) >= 24 && subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) == 1 +} +func (s *Service) requireControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.authorized(r) { + jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Service) counts(ctx context.Context) (total, running int, err error) { + items, err := s.docker.ManagedContainers(ctx) + if err != nil { + return 0, 0, err + } + for _, item := range items { + if strings.TrimSpace(item.WorkerID) == "" { + continue + } + total++ + if item.Running { + running++ + } + } + return total, running, nil +} + +func (s *Service) ControlRoutes() http.Handler { + r := chi.NewRouter() + r.Use(s.security) + r.Group(func(r chi.Router) { + r.Use(s.requireControl) + r.Get("/internal/health", s.health) + r.Post("/internal/image/ensure", s.ensureImage) + r.Post("/internal/image/pull", s.pullImage) + r.Post("/internal/volumes", s.createVolume) + r.Delete("/internal/volumes/{name}", s.removeVolume) + r.Post("/internal/workers", s.createWorker) + r.Post("/internal/workers/{id}/start", s.startWorker) + r.Post("/internal/workers/{id}/stop", s.stopWorker) + r.Delete("/internal/workers/{id}", s.removeWorker) + r.Get("/internal/workers/{id}/running", s.runningWorker) + r.Post("/internal/workers/{id}/file/get", s.getWorkerFile) + r.Post("/internal/workers/{id}/file/put", s.putWorkerFile) + }) + return r +} + +func (s *Service) health(w http.ResponseWriter, r *http.Request) { + total, running, err := s.counts(r.Context()) + if err != nil { + jsonOut(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": err.Error()}) + return + } + jsonOut(w, http.StatusOK, map[string]any{"ok": true, "controller_id": s.cfg.ID, "name": s.cfg.Name, "workers": total, "running": running, "max_workers": s.cfg.MaxWorkers, "max_running": s.cfg.MaxRunning}) +} + +func (s *Service) ensureImage(w http.ResponseWriter, r *http.Request) { + var in struct { + Image string `json:"image"` + AutoPull bool `json:"auto_pull"` + RegistryAuth string `json:"registry_auth"` + } + if decode(r, &in, 256<<10) != nil || strings.TrimSpace(in.Image) == "" { + jsonOut(w, 400, map[string]string{"error": "image required"}) + return + } + if err := s.docker.EnsureImage(r.Context(), in.Image, in.AutoPull, in.RegistryAuth); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) pullImage(w http.ResponseWriter, r *http.Request) { + var in struct { + Image string `json:"image"` + RegistryAuth string `json:"registry_auth"` + } + if decode(r, &in, 256<<10) != nil || strings.TrimSpace(in.Image) == "" { + jsonOut(w, 400, map[string]string{"error": "image required"}) + return + } + if err := s.docker.PullImage(r.Context(), in.Image, in.RegistryAuth); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) createVolume(w http.ResponseWriter, r *http.Request) { + var in struct { + Name string `json:"name"` + } + if decode(r, &in, 64<<10) != nil || strings.TrimSpace(in.Name) == "" { + jsonOut(w, 400, map[string]string{"error": "name required"}) + return + } + if err := s.docker.CreateVolume(r.Context(), in.Name); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) removeVolume(w http.ResponseWriter, r *http.Request) { + if err := s.docker.RemoveVolume(r.Context(), chi.URLParam(r, "name")); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) createWorker(w http.ResponseWriter, r *http.Request) { + var cfg customer.WorkerContainerConfig + if decode(r, &cfg, 1<<20) != nil || strings.TrimSpace(cfg.WorkerID) == "" || strings.TrimSpace(cfg.Image) == "" { + jsonOut(w, 400, map[string]string{"error": "invalid worker config"}) + return + } + if v := strings.TrimSpace(s.cfg.WorkerNetworkOverride); v != "" { + cfg.Network = v + } + if v := strings.TrimSpace(s.cfg.WorkerRegisterURLOverride); v != "" { + cfg.RegisterURL = v + } + if v := strings.TrimSpace(s.cfg.GameURLOverride); v != "" { + cfg.GameURL = v + } + total, _, err := s.counts(r.Context()) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + if total >= s.cfg.MaxWorkers { + jsonOut(w, 503, map[string]string{"error": "service controller worker capacity reached"}) + return + } + id, err := s.docker.CreateWorker(r.Context(), cfg) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 201, map[string]string{"id": id}) +} +func (s *Service) startWorker(w http.ResponseWriter, r *http.Request) { + _, running, err := s.counts(r.Context()) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + if running >= s.cfg.MaxRunning { + jsonOut(w, 503, map[string]string{"error": "service controller running capacity reached"}) + return + } + if err := s.docker.Start(r.Context(), chi.URLParam(r, "id")); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) stopWorker(w http.ResponseWriter, r *http.Request) { + var in struct { + Seconds int `json:"seconds"` + } + _ = decode(r, &in, 64<<10) + if err := s.docker.Stop(r.Context(), chi.URLParam(r, "id"), in.Seconds); err != nil && !strings.Contains(err.Error(), "304") { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) removeWorker(w http.ResponseWriter, r *http.Request) { + if err := s.docker.Remove(r.Context(), chi.URLParam(r, "id")); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) runningWorker(w http.ResponseWriter, r *http.Request) { + running, err := s.docker.Running(r.Context(), chi.URLParam(r, "id")) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"running": running}) +} +func (s *Service) getWorkerFile(w http.ResponseWriter, r *http.Request) { + var in struct { + Path string `json:"path"` + } + if decode(r, &in, 64<<10) != nil || strings.TrimSpace(in.Path) == "" { + jsonOut(w, 400, map[string]string{"error": "path required"}) + return + } + b, err := s.docker.GetFile(r.Context(), chi.URLParam(r, "id"), in.Path) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]any{"data": b}) +} +func (s *Service) putWorkerFile(w http.ResponseWriter, r *http.Request) { + var in struct { + Dir string `json:"dir"` + Name string `json:"name"` + Data []byte `json:"data"` + } + if decode(r, &in, 2<<20) != nil || strings.TrimSpace(in.Dir) == "" || strings.TrimSpace(in.Name) == "" || len(in.Data) == 0 { + jsonOut(w, 400, map[string]string{"error": "dir/name/data required"}) + return + } + if err := s.docker.PutFile(r.Context(), chi.URLParam(r, "id"), in.Dir, in.Name, in.Data); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} + +const adminCookie = "neuralhunt_service_controller_admin" + +func (s *Service) adminCookieSecure(r *http.Request) bool { + switch strings.ToLower(strings.TrimSpace(s.cfg.AdminCookieSecureMode)) { + case "0", "false", "no", "off": + return false + case "1", "true", "yes", "on": + return true + case "", "auto": + return requestIsHTTPS(r) + default: + return true + } +} +func (s *Service) setAdminCookie(w http.ResponseWriter, r *http.Request, value string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: value, Path: "/", HttpOnly: true, Secure: s.adminCookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: int(ttl.Seconds()), Expires: time.Now().UTC().Add(ttl)}) +} +func (s *Service) clearAdminCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.adminCookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0).UTC()}) +} +func (s *Service) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(adminCookie) + if err != nil { + jsonOut(w, 401, map[string]string{"error": "unauthorized"}) + return + } + v, ok := s.adminSessions.Load(c.Value) + exp, _ := v.(time.Time) + if !ok || time.Now().After(exp) { + s.adminSessions.Delete(c.Value) + jsonOut(w, 401, map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Service) AdminRoutes(ui http.Handler) http.Handler { + r := chi.NewRouter() + r.Use(s.security) + r.Post("/api/admin/login", s.adminLogin) + r.Post("/api/admin/logout", s.adminLogout) + r.Group(func(r chi.Router) { + r.Use(s.requireAdmin) + r.Get("/api/admin/overview", s.adminOverview) + r.Post("/api/admin/workers/{id}/start", s.adminStart) + r.Post("/api/admin/workers/{id}/stop", s.adminStop) + r.Post("/api/admin/workers/{id}/restart", s.adminRestart) + r.Post("/api/admin/workers/stop-all", s.adminStopAll) + r.Post("/api/admin/image/pull", s.adminPullImage) + }) + r.Mount("/", ui) + return r +} +func (s *Service) adminLogin(w http.ResponseWriter, r *http.Request) { + var in struct{ Username, Password string } + if decode(r, &in, 64<<10) != nil { + jsonOut(w, 400, map[string]string{"error": "bad json"}) + return + } + if subtle.ConstantTimeCompare([]byte(in.Username), []byte(s.cfg.AdminUser)) != 1 || subtle.ConstantTimeCompare([]byte(in.Password), []byte(s.cfg.AdminPassword)) != 1 { + time.Sleep(250 * time.Millisecond) + jsonOut(w, 401, map[string]string{"error": "invalid credentials"}) + return + } + sid := customer.RandomToken(32) + s.adminSessions.Store(sid, time.Now().Add(12*time.Hour)) + s.setAdminCookie(w, r, sid, 12*time.Hour) + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) adminLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(adminCookie); err == nil { + s.adminSessions.Delete(c.Value) + } + s.clearAdminCookie(w, r) + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) { + items, err := s.docker.ManagedContainers(r.Context()) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + total, running := 0, 0 + var workers []customer.ManagedContainer + for _, item := range items { + if item.WorkerID == "" { + continue + } + total++ + if item.Running { + running++ + } + workers = append(workers, item) + } + regOK, regAt, regErr := s.registrationStatus() + jsonOut(w, 200, map[string]any{"controller": map[string]any{"id": s.cfg.ID, "name": s.cfg.Name, "advertise_url": s.cfg.AdvertiseURL, "master_url": s.cfg.MasterURL, "max_workers": s.cfg.MaxWorkers, "max_running": s.cfg.MaxRunning, "worker_image": s.cfg.DefaultWorkerImage, "master_connected": regOK, "master_last_attempt": regAt, "master_last_error": regErr}, "workers": workers, "total": total, "running": running}) +} +func (s *Service) adminStart(w http.ResponseWriter, r *http.Request) { + if err := s.docker.Start(r.Context(), chi.URLParam(r, "id")); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) adminStop(w http.ResponseWriter, r *http.Request) { + if err := s.docker.Stop(r.Context(), chi.URLParam(r, "id"), 5); err != nil && !strings.Contains(err.Error(), "304") { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) adminRestart(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + _ = s.docker.Stop(r.Context(), id, 5) + if err := s.docker.Start(r.Context(), id); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Service) adminStopAll(w http.ResponseWriter, r *http.Request) { + items, err := s.docker.ManagedContainers(r.Context()) + if err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + stopped := 0 + var warnings []string + for _, item := range items { + if item.WorkerID == "" || !item.Running { + continue + } + if err := s.docker.Stop(r.Context(), item.ID, 5); err != nil && !strings.Contains(err.Error(), "304") { + warnings = append(warnings, item.WorkerID+": "+err.Error()) + continue + } + stopped++ + } + jsonOut(w, 200, map[string]any{"ok": len(warnings) == 0, "stopped": stopped, "warnings": warnings}) +} +func (s *Service) adminPullImage(w http.ResponseWriter, r *http.Request) { + image := strings.TrimSpace(s.cfg.DefaultWorkerImage) + if image == "" { + jsonOut(w, 400, map[string]string{"error": "SC_WORKER_IMAGE is empty"}) + return + } + auth, err := customer.RegistryAuthHeader(s.cfg.RegistryUsername, s.cfg.RegistryPassword, s.cfg.RegistryServer) + if err != nil { + jsonOut(w, 400, map[string]string{"error": err.Error()}) + return + } + if err := s.docker.PullImage(r.Context(), image, auth); err != nil { + jsonOut(w, 502, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "image": image}) +} + +func (s *Service) setRegistrationStatus(ok bool, err error) { + s.regMu.Lock() + defer s.regMu.Unlock() + s.lastRegOK = ok + s.lastRegAt = time.Now().UTC() + if err != nil { + s.lastRegErr = err.Error() + } else { + s.lastRegErr = "" + } +} + +func (s *Service) registrationStatus() (bool, time.Time, string) { + s.regMu.RLock() + defer s.regMu.RUnlock() + return s.lastRegOK, s.lastRegAt, s.lastRegErr +} + +func (s *Service) registerOnce(ctx context.Context) error { + total, running, err := s.counts(ctx) + if err != nil { + return err + } + payload := map[string]any{"id": s.cfg.ID, "name": s.cfg.Name, "base_url": strings.TrimRight(s.cfg.AdvertiseURL, "/"), "protocol_version": 1, "max_workers": s.cfg.MaxWorkers, "max_running": s.cfg.MaxRunning, "reported_workers": total, "reported_running": running} + b, _ := json.Marshal(payload) + endpoint := strings.TrimRight(s.cfg.MasterURL, "/") + "/internal/controllers/register" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret) + resp, err := s.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10)) + if resp.StatusCode/100 != 2 { + return fmt.Errorf("master register HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +func (s *Service) RunRegistration(ctx context.Context) { + ticker := time.NewTicker(s.cfg.HeartbeatInterval) + defer ticker.Stop() + for { + callCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + err := s.registerOnce(callCtx) + cancel() + s.setRegistrationStatus(err == nil, err) + if err != nil && !errors.Is(err, context.Canceled) { + log.Printf("service-controller registration: %v", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func ValidateConfig(cfg Config) error { + if strings.TrimSpace(cfg.ID) == "" { + return errors.New("SC_ID is required and must remain stable for this host") + } + if strings.TrimSpace(cfg.MasterURL) == "" { + return errors.New("SC_MASTER_URL is required") + } + if strings.TrimSpace(cfg.AdvertiseURL) == "" { + return errors.New("SC_ADVERTISE_URL is required") + } + for _, raw := range []string{cfg.MasterURL, cfg.AdvertiseURL} { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("invalid controller URL %q", raw) + } + } + secret := strings.TrimSpace(cfg.SharedSecret) + if len(secret) < 32 || strings.Contains(strings.ToLower(secret), "replace-with") || strings.Contains(strings.ToLower(secret), "change-me") { + return errors.New("SERVICE_CONTROLLER_SHARED_SECRET must be a unique random value of at least 32 characters") + } + adminPass := strings.TrimSpace(cfg.AdminPassword) + if len(adminPass) < 16 || strings.Contains(strings.ToLower(adminPass), "replace-with") || strings.Contains(strings.ToLower(adminPass), "change-me") { + return errors.New("SC_ADMIN_PASSWORD must be a unique value of at least 16 characters") + } + return nil +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index b6a2bbc..f6eef3e 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "math" "os" "strconv" "strings" @@ -13,32 +14,37 @@ import ( ) type Runtime struct { - GuessMinIntervalSec int `json:"guess_min_interval_sec"` - ClientSubmitIntervalSec int `json:"client_submit_interval_sec"` - GuessLotteryWindowSec int `json:"guess_lottery_window_sec"` - GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"` - BeaconHuntEnabled int `json:"beacon_hunt_enabled"` - BeaconBonusWeight int `json:"beacon_bonus_weight"` - SybilProofOfWorkBits int `json:"sybil_pow_bits"` - SybilWarmupSec int `json:"sybil_warmup_sec"` - OpenAIMaxCalls1H int `json:"openai_max_calls_1h"` - OpenAIMaxCalls24H int `json:"openai_max_calls_24h"` - OpenAIMaxCost24HUSD float64 `json:"openai_max_cost_24h_usd"` - OpenAIBudgetReserveUSD float64 `json:"openai_budget_reserve_usd"` - TaskRangeBits int `json:"task_range_bits"` - ActiveTaskCount int `json:"active_task_count"` - PresenceTTLSec int `json:"presence_ttl_sec"` - DefaultMaxNodes int `json:"default_max_nodes"` - PublicScorePrecision int `json:"public_score_precision"` - ArtifactPreset string `json:"artifact_preset"` - ArtifactProvider string `json:"artifact_provider"` - ArtifactModel string `json:"artifact_model"` - ArtifactPrompt string `json:"artifact_prompt"` - ArtifactNegativePrompt string `json:"artifact_negative_prompt"` - ArtifactWidth int `json:"artifact_width"` - ArtifactHeight int `json:"artifact_height"` - ArtifactSteps int `json:"artifact_steps"` - ArtifactQuality string `json:"artifact_quality"` + GuessMinIntervalSec int `json:"guess_min_interval_sec"` + ClientSubmitIntervalSec int `json:"client_submit_interval_sec"` + GuessLotteryWindowSec int `json:"guess_lottery_window_sec"` + GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"` + BeaconHuntEnabled int `json:"beacon_hunt_enabled"` + BeaconBonusWeight int `json:"beacon_bonus_weight"` + SybilProofOfWorkBits int `json:"sybil_pow_bits"` + SybilWarmupSec int `json:"sybil_warmup_sec"` + OpenAIMaxCalls1H int `json:"openai_max_calls_1h"` + OpenAIMaxCalls24H int `json:"openai_max_calls_24h"` + OpenAIMaxCost24HUSD float64 `json:"openai_max_cost_24h_usd"` + OpenAIBudgetReserveUSD float64 `json:"openai_budget_reserve_usd"` + TaskRangeBits int `json:"task_range_bits"` + ActiveTaskCount int `json:"active_task_count"` + PresenceTTLSec int `json:"presence_ttl_sec"` + DefaultMaxNodes int `json:"default_max_nodes"` + PublicScorePrecision int `json:"public_score_precision"` + ArtifactPreset string `json:"artifact_preset"` + ArtifactProvider string `json:"artifact_provider"` + ArtifactModel string `json:"artifact_model"` + ArtifactPrompt string `json:"artifact_prompt"` + ArtifactNegativePrompt string `json:"artifact_negative_prompt"` + ArtifactWidth int `json:"artifact_width"` + ArtifactHeight int `json:"artifact_height"` + ArtifactSteps int `json:"artifact_steps"` + ArtifactQuality string `json:"artifact_quality"` + ArtifactRarityCommonPct float64 `json:"artifact_rarity_common_pct"` + ArtifactRarityUncommonPct float64 `json:"artifact_rarity_uncommon_pct"` + ArtifactRarityRarePct float64 `json:"artifact_rarity_rare_pct"` + ArtifactRarityUltraRarePct float64 `json:"artifact_rarity_ultra_rare_pct"` + ArtifactRaritySpecialIllustrationPct float64 `json:"artifact_rarity_special_illustration_pct"` } type Manager struct { @@ -74,32 +80,37 @@ func envFloat(k string, def float64) float64 { func Defaults() Runtime { return Runtime{ - GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10), - ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11), - GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60), - GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0), - BeaconHuntEnabled: envInt("DEFAULT_BEACON_HUNT_ENABLED", 0), - BeaconBonusWeight: envInt("DEFAULT_BEACON_BONUS_WEIGHT", 2), - SybilProofOfWorkBits: envInt("DEFAULT_SYBIL_POW_BITS", 15), - SybilWarmupSec: envInt("DEFAULT_SYBIL_WARMUP_SEC", 15), - OpenAIMaxCalls1H: envInt("DEFAULT_OPENAI_MAX_CALLS_1H", 20), - OpenAIMaxCalls24H: envInt("DEFAULT_OPENAI_MAX_CALLS_24H", 100), - OpenAIMaxCost24HUSD: envFloat("DEFAULT_OPENAI_MAX_COST_24H_USD", 5.00), - OpenAIBudgetReserveUSD: envFloat("DEFAULT_OPENAI_BUDGET_RESERVE_USD", 0.20), - TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28), - ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1), - PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35), - DefaultMaxNodes: envInt("DEFAULT_MAX_NODES", 2000), - PublicScorePrecision: envInt("DEFAULT_PUBLIC_SCORE_PRECISION", 2), - ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")), - ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")), - ArtifactModel: envString("ARTIFACT_MODEL", "gpt-image-2"), - ArtifactPrompt: envString("ARTIFACT_PROMPT", "Optional global creative override for legacy artifact providers. The raccoon_full_art_v1 preset uses its built-in structured master prompt and task-specific NFT instructions."), - ArtifactNegativePrompt: envString("ARTIFACT_NEGATIVE_PROMPT", "text, watermark, logo, signature, low quality, blurry, malformed"), - ArtifactWidth: envInt("ARTIFACT_WIDTH", 1024), - ArtifactHeight: envInt("ARTIFACT_HEIGHT", 1536), - ArtifactSteps: envInt("ARTIFACT_STEPS", 28), - ArtifactQuality: strings.ToLower(envString("ARTIFACT_QUALITY", "medium")), + GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10), + ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11), + GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60), + GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0), + BeaconHuntEnabled: envInt("DEFAULT_BEACON_HUNT_ENABLED", 0), + BeaconBonusWeight: envInt("DEFAULT_BEACON_BONUS_WEIGHT", 2), + SybilProofOfWorkBits: envInt("DEFAULT_SYBIL_POW_BITS", 15), + SybilWarmupSec: envInt("DEFAULT_SYBIL_WARMUP_SEC", 15), + OpenAIMaxCalls1H: envInt("DEFAULT_OPENAI_MAX_CALLS_1H", 20), + OpenAIMaxCalls24H: envInt("DEFAULT_OPENAI_MAX_CALLS_24H", 100), + OpenAIMaxCost24HUSD: envFloat("DEFAULT_OPENAI_MAX_COST_24H_USD", 5.00), + OpenAIBudgetReserveUSD: envFloat("DEFAULT_OPENAI_BUDGET_RESERVE_USD", 0.20), + TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28), + ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1), + PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35), + DefaultMaxNodes: envInt("DEFAULT_MAX_NODES", 2000), + PublicScorePrecision: envInt("DEFAULT_PUBLIC_SCORE_PRECISION", 2), + ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")), + ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")), + ArtifactModel: envString("ARTIFACT_MODEL", "gpt-image-2"), + ArtifactPrompt: envString("ARTIFACT_PROMPT", "Optional global creative override for legacy artifact providers. The raccoon_full_art_v1 preset uses its built-in structured master prompt and task-specific NFT instructions."), + ArtifactNegativePrompt: envString("ARTIFACT_NEGATIVE_PROMPT", "text, watermark, logo, signature, low quality, blurry, malformed"), + ArtifactWidth: envInt("ARTIFACT_WIDTH", 1024), + ArtifactHeight: envInt("ARTIFACT_HEIGHT", 1536), + ArtifactSteps: envInt("ARTIFACT_STEPS", 28), + ArtifactQuality: strings.ToLower(envString("ARTIFACT_QUALITY", "medium")), + ArtifactRarityCommonPct: envFloat("DEFAULT_ARTIFACT_RARITY_COMMON_PCT", 58.00), + ArtifactRarityUncommonPct: envFloat("DEFAULT_ARTIFACT_RARITY_UNCOMMON_PCT", 29.50), + ArtifactRarityRarePct: envFloat("DEFAULT_ARTIFACT_RARITY_RARE_PCT", 9.90), + ArtifactRarityUltraRarePct: envFloat("DEFAULT_ARTIFACT_RARITY_ULTRA_RARE_PCT", 2.25), + ArtifactRaritySpecialIllustrationPct: envFloat("DEFAULT_ARTIFACT_RARITY_SPECIAL_ILLUSTRATION_PCT", 0.35), } } @@ -303,6 +314,26 @@ func Validate(v Runtime) error { default: return fmt.Errorf("artifact_quality must be low, medium, high or auto") } + rarityValues := []struct { + name string + v float64 + }{ + {"artifact_rarity_common_pct", v.ArtifactRarityCommonPct}, + {"artifact_rarity_uncommon_pct", v.ArtifactRarityUncommonPct}, + {"artifact_rarity_rare_pct", v.ArtifactRarityRarePct}, + {"artifact_rarity_ultra_rare_pct", v.ArtifactRarityUltraRarePct}, + {"artifact_rarity_special_illustration_pct", v.ArtifactRaritySpecialIllustrationPct}, + } + total := 0.0 + for _, x := range rarityValues { + if x.v < 0 || x.v > 100 { + return fmt.Errorf("%s must be 0..100", x.name) + } + total += x.v + } + if math.Abs(total-100.0) > 0.001 { + return fmt.Errorf("artifact rarity percentages must sum to exactly 100 (currently %.4f)", total) + } return nil } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..b1a4fba --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,22 @@ +package settings + +import "testing" + +func TestDefaultRarityDistributionIsValid(t *testing.T) { + v := Defaults() + if err := Validate(v); err != nil { + t.Fatalf("defaults invalid: %v", err) + } + total := v.ArtifactRarityCommonPct + v.ArtifactRarityUncommonPct + v.ArtifactRarityRarePct + v.ArtifactRarityUltraRarePct + v.ArtifactRaritySpecialIllustrationPct + if total != 100 { + t.Fatalf("rarity total = %.4f, want 100", total) + } +} + +func TestRarityDistributionMustSumTo100(t *testing.T) { + v := Defaults() + v.ArtifactRarityCommonPct = 50 + if err := Validate(v); err == nil { + t.Fatal("expected invalid rarity total") + } +} diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js index a0bd079..c1350fe 100644 --- a/internal/webui/dist/app.js +++ b/internal/webui/dist/app.js @@ -397,7 +397,7 @@ async function runUser(){ $('toggleMobile').onclick=()=>toggleMobileMode();$('toggleDetails').onclick=()=>{detailsOpen=!detailsOpen;$('signalPanel').classList.toggle('expanded',detailsOpen);setActive('toggleDetails',detailsOpen)};window.addEventListener('neuralhunt-mobile-mode',syncMobile); $('toggleProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('toggleProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('toggleProximity',map.proximityFocus)};$('toggleRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('toggleRotate',map.autoRotate)};$('toggleLabels').onclick=()=>{map.labels=!map.labels;setActive('toggleLabels',map.labels)};$('toggleEdges').onclick=()=>{map.edges=!map.edges;setActive('toggleEdges',map.edges)};$('toggleShells').onclick=()=>{map.shells=!map.shells;setActive('toggleShells',map.shells)};$('toggleLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('toggleLOD',map.lodEnabled)};$('toggleEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('toggleEco',map.eco)};$('resetView').onclick=()=>map.resetView(); async function downloadMyNFT(n){const r=await fetch(n.download_uri,{headers:{Authorization:'Bearer '+getToken()},credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Original konnte nicht geladen werden'));const blob=await r.blob(),a=document.createElement('a'),ext=(blob.type==='image/svg+xml'?'.svg':blob.type==='image/png'?'.png':blob.type==='image/webp'?'.webp':blob.type==='image/jpeg'?'.jpg':'');a.href=URL.createObjectURL(blob);a.download=`neuralhunt-${n.task_id}${ext}`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)} - async function renderOwnedNFTs(host){host.innerHTML='lade …';try{const items=await api('/api/me/artifacts?limit=24');host.innerHTML=items?.length?items.map(n=>`
${esc(n.display_name||(n.artifact_origin==='admin_drop'?'ADMIN DROP':'WINNER NFT'))}${n.artifact_origin==='admin_drop'?'ADMIN DROP · ':''}${esc(shortID(n.task_id,12))} · ${Number(n.range_bits||0)} Bit · ${esc(fmtDate(n.completed_at))}
`).join(''):'Noch keine fertigen Gewinner-Artefakte für diese Identität.';host.querySelectorAll('[data-my-nft]').forEach(b=>b.onclick=async()=>{const n=items.find(x=>x.task_id===b.dataset.myNft);if(!n)return;b.disabled=true;try{await downloadMyNFT(n)}catch(e){status(e.message||'Download fehlgeschlagen')}finally{b.disabled=false}})}catch(e){host.innerHTML=`${esc(e.message||'NFTs konnten nicht geladen werden')}`}} + async function renderOwnedNFTs(host){host.innerHTML='lade …';try{const items=await api('/api/me/artifacts?limit=24');host.innerHTML=items?.length?items.map(n=>`
${esc(n.display_name||(n.artifact_origin==='admin_drop'?'ADMIN DROP':'WINNER NFT'))}${n.artifact_origin==='admin_drop'?'ADMIN DROP · ':''}${n.rarity?esc(n.rarity)+' · ':''}${esc(shortID(n.task_id,12))} · ${Number(n.range_bits||0)} Bit · ${esc(fmtDate(n.completed_at))}
`).join(''):'Noch keine fertigen Gewinner-Artefakte für diese Identität.';host.querySelectorAll('[data-my-nft]').forEach(b=>b.onclick=async()=>{const n=items.find(x=>x.task_id===b.dataset.myNft);if(!n)return;b.disabled=true;try{await downloadMyNFT(n)}catch(e){status(e.message||'Download fehlgeschlagen')}finally{b.disabled=false}})}catch(e){host.innerHTML=`${esc(e.message||'NFTs konnten nicht geladen werden')}`}} async function toggleOwnedNFTs(host){if(!host)return;if(!host.classList.contains('hidden')){host.classList.add('hidden');return}host.classList.remove('hidden');await renderOwnedNFTs(host)} async function performIdentityExport(){const p=prompt('Passphrase für den verschlüsselten Identitäts-Export (mindestens 12 Zeichen). Bewahre Export und Passphrase getrennt auf.');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status('Identität verschlüsselt gesichert')}catch(e){status(e.message||'Export fehlgeschlagen')}} async function performHostedLinkCode(){try{const x=await api('/api/me/customer-link',{method:'POST',body:JSON.stringify({})});const code=x.code||'';if(!code)throw new Error('Kein Hosted-Code erhalten');try{await navigator.clipboard?.writeText(code)}catch{}prompt('Einmaliger Hosted-Code (10 Minuten gültig). Im Customer-Service-Portal unter Haupt-Identität einfügen:',code);status('Hosted-Code erzeugt · nur einmal verwendbar')}catch(e){status(e.message||'Hosted-Code konnte nicht erzeugt werden')}} @@ -424,7 +424,7 @@ function leaderboardShell(){ async function runLeaderboard(){ leaderboardShell();let mode='live',rows=[],artifacts=[],ws=null,refreshTimer=null; - const openNFT=a=>{if(!a?.preview_uri)return;$('nftLarge').src=a.preview_uri;$('nftLargeTitle').textContent=`Task ${shortID(a.task_id?.slice(-14),14)}`;$('nftLargeMeta').textContent=`Winner ${shortID(a.winner_client_id,18)} · ${a.range_bits} Bit · WATERMARKED PREVIEW`;$('nftLightbox').classList.remove('hidden')}; + const openNFT=a=>{if(!a?.preview_uri)return;$('nftLarge').src=a.preview_uri;$('nftLargeTitle').textContent=`Task ${shortID(a.task_id?.slice(-14),14)}`;$('nftLargeMeta').textContent=`Winner ${shortID(a.winner_client_id,18)} · ${a.range_bits} Bit${a.rarity?' · '+a.rarity:''} · WATERMARKED PREVIEW`;$('nftLightbox').classList.remove('hidden')}; const closeNFT=()=>{$('nftLightbox').classList.add('hidden');$('nftLarge').removeAttribute('src')}; const render=()=>{ const q=$('lbSearch').value.trim().toLowerCase(),filtered=rows.filter(x=>!q||String(x.client_id).toLowerCase().includes(q)||String(x.nft_task_id||'').toLowerCase().includes(q)); @@ -434,7 +434,7 @@ async function runLeaderboard(){ $('lbTable').innerHTML=filtered.map((x,i)=>`
#${i+1}${esc(shortID(x.client_id,22))}${(x.unlocks||[]).slice(0,3).map(esc).join(' · ')||'keine Unlocks'}
LIVE${fmtScore(x.live_score)}BEST${fmtScore(x.best_score)}WINS${x.wins||0}TIPPS${Number(x.guess_count||0).toLocaleString('de-DE')}
${x.nft_preview_uri?``:'—'}
`).join('')||'
Noch keine Teilnehmer
'; const shownArtifacts=artifacts.filter(a=>!q||String(a.winner_client_id).toLowerCase().includes(q)||String(a.task_id).toLowerCase().includes(q)); $('nftCount').textContent=shownArtifacts.length.toLocaleString('de-DE'); - $('nftGallery').innerHTML=shownArtifacts.length?shownArtifacts.map(a=>``).join(''):'
Noch keine fertigen Gewinner-Artefakte
'; + $('nftGallery').innerHTML=shownArtifacts.length?shownArtifacts.map(a=>``).join(''):'
Noch keine fertigen Gewinner-Artefakte
'; document.querySelectorAll('[data-gallery-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.galleryTask))); document.querySelectorAll('[data-nft-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.nftTask)||{task_id:b.dataset.nftTask,winner_client_id:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.client_id,range_bits:'—',preview_uri:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.nft_preview_uri})); }; @@ -468,7 +468,7 @@ async function runAdmin(){ const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision']; const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (1–10)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'}; const msg=s=>$('adminstatus').textContent=s; - const renderAdminDropWatch=items=>{const host=$('adminDropResult');if(!host)return;const rows=Array.isArray(items)?items:[];if(!rows.length){host.textContent=adminDropWatchIDs.length?'Queue-Einträge werden gesucht …':'';return}const order={error:0,generating:1,pending:2,ready:3};rows.sort((a,b)=>(order[a.artifact_status]??9)-(order[b.artifact_status]??9));host.innerHTML=`
${rows.map(x=>{const st=String(x.artifact_status||'unknown'),err=String(x.artifact_error||'').trim(),id=String(x.task_id||'');let info=st==='pending'?'wartet auf Artifact-Worker':st==='generating'?'Bild wird gerade erzeugt':st==='ready'?'Collectible fertig':st==='error'?(err||'Generierung fehlgeschlagen'):st;return `
${esc(st.toUpperCase())}${esc(id.slice(-14))}${esc(info.length>180?info.slice(0,179)+'…':info)}${st==='ready'?``:''}
`}).join('')}
`;host.querySelectorAll('[data-drop-open]').forEach(b=>b.onclick=()=>openAdminFile(b.dataset.dropOpen,'artifact'))}; + const renderAdminDropWatch=items=>{const host=$('adminDropResult');if(!host)return;const rows=Array.isArray(items)?items:[];if(!rows.length){host.textContent=adminDropWatchIDs.length?'Queue-Einträge werden gesucht …':'';return}const order={error:0,generating:1,pending:2,ready:3};rows.sort((a,b)=>(order[a.artifact_status]??9)-(order[b.artifact_status]??9));host.innerHTML=`
${rows.map(x=>{const st=String(x.artifact_status||'unknown'),err=String(x.artifact_error||'').trim(),id=String(x.task_id||'');let info=st==='pending'?'wartet auf Artifact-Worker':st==='generating'?'Bild wird gerade erzeugt':st==='ready'?'Collectible fertig':st==='error'?(err||'Generierung fehlgeschlagen'):st;return `
${esc(st.toUpperCase())}${esc(id.slice(-14))}${(x.artifact_rarity||x.artifact_rarity_override)?`${esc(x.artifact_rarity||x.artifact_rarity_override)}`:''}${esc(info.length>180?info.slice(0,179)+'…':info)}${st==='ready'?``:''}
`}).join('')}
`;host.querySelectorAll('[data-drop-open]').forEach(b=>b.onclick=()=>openAdminFile(b.dataset.dropOpen,'artifact'))}; const refreshAdminDropWatch=async()=>{clearTimeout(adminDropWatchTimer);adminDropWatchTimer=null;if(!adminDropWatchIDs.length)return;try{const rows=await api(`/api/admin/artifacts/status?ids=${encodeURIComponent(adminDropWatchIDs.join(','))}`,{},true);renderAdminDropWatch(rows);const active=(rows||[]).some(x=>x.artifact_status==='pending'||x.artifact_status==='generating');if(active){adminDropWatchTimer=setTimeout(refreshAdminDropWatch,2000)}else{adminDropWatchIDs=[];setTimeout(()=>load(true,false),0)}}catch(e){const host=$('adminDropResult');if(host)host.textContent='Queue-Status konnte nicht geladen werden: '+(e.message||e);adminDropWatchTimer=setTimeout(refreshAdminDropWatch,4000)}}; const watchAdminDrops=ids=>{adminDropWatchIDs=(Array.isArray(ids)?ids:[]).map(String).filter(Boolean).slice(0,20);clearTimeout(adminDropWatchTimer);adminDropWatchTimer=null;if(adminDropWatchIDs.length)refreshAdminDropWatch()}; let adminSignalWS=null,adminSignalTimer=null,adminSignalBackoff=500,adminSignalClosed=false; @@ -484,7 +484,7 @@ async function runAdmin(){ function renderOverview(o){$('overview').innerHTML=`${o.connected} verbunden${o.clients} Identitäten${o.active_tasks} aktive Tasks${o.completed_tasks} abgeschlossen${Number(o.guesses||0).toLocaleString('de-DE')} Tipps${o.artifacts_ready} Artefakte`} function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`${Number(r.guesses_per_sec||0).toFixed(1)} Guess/s${Number(r.rejected_per_sec||0).toFixed(1)} Reject/s${Number(r.improvements_per_sec||0).toFixed(1)} Improve/s${Number(r.sqlite_writes_per_sec||0).toFixed(1)} SQLite W/s${Number(w.frames_per_sec||0).toFixed(0)} WS Frames/s${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)} WS MB/s${Number(w.dropped_per_sec||0).toFixed(1)} Drops/s${Number(x.goroutines||0).toLocaleString('de-DE')} Goroutines${(Number(x.heap_bytes||0)/1048576).toFixed(1)} Heap MB`} async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}} - function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return ``}).join(''):'
Keine Tasks
';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})} + function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return ``}).join(''):'
Keine Tasks
';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})} function renderRuntime(){ $('settingfields').innerHTML=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Die Tipp-Lotterie gilt getrennt pro aktivem Task. Bei einem Wert > 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. 0 = Lotterie aus. Änderungen greifen für neu beginnende Fenster.

Beacon Hunt: Optional wählen Spieler PULSE, FLUX oder ORBIT. Der erste öffentliche drand-Round nach Fensterschluss bestimmt reproduzierbar den Boost-Pfad und die gewichtete Ziehung. Alle Reveal-Daten werden gespeichert und über die Public API nachvollziehbar gemacht.

Anti-Sybil: Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.

OpenAI Circuit Breaker: Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.

Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

ALTE PROFILE BEREINIGEN

Löscht ausschließlich Accounts, die seit mindestens X Zeit inaktiv, aktuell nicht verbunden und niemals Gewinner eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.

@@ -507,16 +507,18 @@ async function runAdmin(){
RIFT CHARACTER ANCHOR

Der Anchor definiert ausschließlich, wer RIFT ist. Er wird einmalig als neutrales Character-Referenzbild erzeugt und anschließend für alle Tasks verwendet. Die visuelle Stilrichtung kommt getrennt aus der Style-Referenz des jeweiligen Tasks.

${anchorReady?'

Anchor vorhanden · Identität ist gesperrt. Es gibt absichtlich keinen Überschreiben-Button.

':'

Du kannst den Anchor jetzt kontrolliert erzeugen. Falls du das nicht tust, erzeugt der Worker ihn weiterhin automatisch beim ersten Gewinnerbild als Sicherheits-Fallback.

'}
${anchorReady?'RIFT Character Anchor':'NO ANCHOR'}
${anchorReady?'':`
`} -
PIPELINE

Provider OpenAI · Ausgabe 1024 × 1536 · Quality ${esc(settings?.artifact_quality||'medium')} · Preset raccoon_full_art_v1.

Jede Karten-Generierung sendet zwei getrennte Referenzen: Image 1 = globaler RIFT-Character-Anchor, Image 2 = Style-Referenz des gewählten Tasks. Ohne eigenen Task-Style wird das eingebettete internal/artifact/assets/style_reference.jpg nur als Default-Style verwendet.

Theme, Kleidung, Accessoires, Szene, Pose, Stimmung, Farb-Akzente und Rarity werden deterministisch aus Task/Winner/Seed gewählt. Das Modell erzeugt nur die Full-Art-Illustration; das finale Kartenlayout wird anschließend programmgesteuert aufgebaut.

-
ADMIN NFT DROP

Erzeugt 1–20 zufällige RIFT-Collectibles für eine vorhandene Client-Identität, ohne einen Spielgewinn zu buchen. Die Karten laufen normal durch Artifact-Queue und OpenAI-Circuit-Breaker. Bei leerem Template wird pro Karte zufällig eine bestehende Task-Serie als Art-Direction verwendet.

${clients.map(c=>``).join('')}
+
PIPELINE

Provider OpenAI · Ausgabe 1024 × 1536 · Quality ${esc(settings?.artifact_quality||'medium')} · Preset raccoon_full_art_v1.

Jede Karten-Generierung sendet zwei getrennte Referenzen: Image 1 = globaler RIFT-Character-Anchor, Image 2 = Style-Referenz des gewählten Tasks. Ohne eigenen Task-Style wird das eingebettete internal/artifact/assets/style_reference.jpg nur als Default-Style verwendet.

Theme, Kleidung, Accessoires, Szene, Pose, Stimmung, Farb-Akzente und Rarity werden deterministisch aus Task/Winner/Seed gewählt. Die Rarity-Stufen Common, Uncommon, Rare, Ultra Rare und Special Illustration Rare steuern zusätzlich Farbigkeit und Karten-Effekte des programmgesteuerten Layouts. Das Modell erzeugt nur die Full-Art-Illustration; das finale Kartenlayout wird anschließend programmgesteuert aufgebaut.

+
RARITY-CHANCEN

Globale Wahrscheinlichkeiten für Gewinnerkarten und Admin-Drops mit ZUFÄLLIG NACH CHANCEN. Die Summe muss exakt 100 % ergeben. Bereits erzeugte Karten bleiben unverändert.

+
ADMIN NFT DROP

Erzeugt 1–20 zufällige RIFT-Collectibles für eine vorhandene Client-Identität, ohne einen Spielgewinn zu buchen. Die Karten laufen normal durch Artifact-Queue und OpenAI-Circuit-Breaker. Bei leerem Template wird pro Karte zufällig eine bestehende Task-Serie als Art-Direction verwendet. Die Rarity kann nach den globalen Chancen gezogen oder für das Geschenk fest vorgegeben werden.

${clients.map(c=>``).join('')}
OPENAI NUTZUNG & KOSTEN
KOSTEN HEUTE${usd(u.today_cost_usd,4)}${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}
Ø KOSTEN PRO KARTE${usd(u.avg_card_cost_usd,5)}${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen
KOSTEN PRO 1.000 KARTEN${usd(u.cost_per_1000_usd,2)}hochgerechnet aus dem bisherigen Kartenmittel
CIRCUIT BREAKER${cb.blocked?'BLOCKED':'READY'}${Number(cb.calls_1h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_1h||0).toLocaleString('de-DE')} Calls 1h · ${Number(cb.calls_24h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_24h||0).toLocaleString('de-DE')} Calls 24h · ${usd(cb.cost_24h_usd,3)} / ${usd(cb.max_cost_24h_usd,2)}
Die Token-Nutzung stammt direkt aus der OpenAI-Antwort. Die USD-Werte werden lokal daraus mit fest hinterlegten öffentlichen Standardpreisen berechnet; sie sind kein Rechnungsabgleich. Anchor-Kosten zählen in „heute“, aber nicht in den Karten-Durchschnitt.
${usageRows}
ZeitTypModellTokens (Text + Bild → Output)KostenRequest

Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab TASK ACTIONS.

`; if(anchorReady){const img=$('anchorPreview');loadProtectedImage('/api/admin/artifact/character-anchor?ts='+Date.now(),img,true).catch(()=>{if(img)img.alt='Anchor konnte nicht geladen werden'})} if(adminDropWatchIDs.length)setTimeout(refreshAdminDropWatch,0); + const updateRarityChanceTotal=()=>{const inputs=[...document.querySelectorAll('[data-rarity-pct]')],total=inputs.reduce((sum,i)=>sum+Number(i.value||0),0),host=$('rarityChanceTotal');if(host)host.innerHTML=`Summe: ${total.toFixed(2)} %${Math.abs(total-100)>.001?' · muss 100 % ergeben':' · READY'}`};document.querySelectorAll('[data-rarity-pct]').forEach(i=>i.addEventListener('input',updateRarityChanceTotal));updateRarityChanceTotal(); const create=$('createCharacterAnchor');if(create)create.onclick=async()=>{if(!confirm('RIFT Character Anchor jetzt einmalig erzeugen? Danach wird er absichtlich nicht automatisch überschrieben.'))return;create.disabled=true;create.textContent='ANCHOR WIRD ERZEUGT …';try{await api('/api/admin/artifact/character-anchor',{method:'POST'},true);msg('RIFT Character Anchor erzeugt und gesperrt');await load(true,true)}catch(e){msg(e.message);create.disabled=false;create.textContent='RIFT-ANCHOR JETZT ERZEUGEN'}}; - const drop=$('createAdminDrop');if(drop)drop.onclick=async()=>{const target=String($('dropTargetClient')?.value||'').trim(),count=Number($('dropCount')?.value||1),template=String($('dropTemplateTask')?.value||'').trim();if(!target){msg('Ziel Client-ID fehlt');return}if(!Number.isInteger(count)||count<1||count>20){msg('Anzahl muss 1–20 sein');return}if(!confirm(`${count} Admin-NFT${count===1?'':'s'} für ${target.slice(0,18)}… erzeugen?\n\nDies kann API-Kosten auslösen; der Circuit-Breaker bleibt aktiv.`))return;drop.disabled=true;drop.textContent='WIRD EINGEREIHT …';try{const r=await api('/api/admin/artifacts/drop',{method:'POST',body:JSON.stringify({target_client_id:target,template_task_id:template,count})},true);const ids=r.created_task_ids||[];$('adminDropResult').textContent=`${ids.length} Collectible(s) eingereiht · Status wird verfolgt …`;watchAdminDrops(ids);msg('Admin NFT-Drop eingereiht · Artifact-Worker wurde geweckt');await load(true,false)}catch(e){msg(e.message)}finally{drop.disabled=false;drop.textContent='NFT-DROP IN QUEUE STELLEN'}}; + const drop=$('createAdminDrop');if(drop)drop.onclick=async()=>{const target=String($('dropTargetClient')?.value||'').trim(),count=Number($('dropCount')?.value||1),template=String($('dropTemplateTask')?.value||'').trim(),rarity=String($('dropRarity')?.value||'').trim();if(!target){msg('Ziel Client-ID fehlt');return}if(!Number.isInteger(count)||count<1||count>20){msg('Anzahl muss 1–20 sein');return}const rarityLabel=rarity||'ZUFÄLLIG NACH CHANCEN';if(!confirm(`${count} Admin-NFT${count===1?'':'s'} für ${target.slice(0,18)}… erzeugen?\nRarity: ${rarityLabel}\n\nDies kann API-Kosten auslösen; der Circuit-Breaker bleibt aktiv.`))return;drop.disabled=true;drop.textContent='WIRD EINGEREIHT …';try{const r=await api('/api/admin/artifacts/drop',{method:'POST',body:JSON.stringify({target_client_id:target,template_task_id:template,rarity,count})},true);const ids=r.created_task_ids||[];$('adminDropResult').textContent=`${ids.length} Collectible(s) eingereiht · ${rarityLabel} · Status wird verfolgt …`;watchAdminDrops(ids);msg('Admin NFT-Drop eingereiht · Artifact-Worker wurde geweckt');await load(true,false)}catch(e){msg(e.message)}finally{drop.disabled=false;drop.textContent='NFT-DROP IN QUEUE STELLEN'}}; }else{ $('settingfields').innerHTML=`
LEGACY ARTIFACT GENERATION
${['local','openai','comfyui','a1111'].map(k=>`${k.toUpperCase()} · ${ps[k]?'READY':'ENV FEHLT'}`).join('')}