From ca6a172cdd69b7c136c69b3c4fa3b9d3f112d54b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 12 Jun 2026 12:42:33 +0100 Subject: [PATCH] Initial Vili animation daemon --- .gitignore | 3 + README.md | 50 ++++ docs/verse-service-contract.md | 54 +++++ package-lock.json | 36 +++ package.json | 16 ++ scripts/health-vili-daemon.cmd | 2 + scripts/restart-vili-daemon.cmd | 2 + scripts/restart-vili-daemon.ps1 | 21 ++ scripts/smoke-vili.mjs | 36 +++ scripts/start-vili-daemon.ps1 | 42 ++++ scripts/vili-daemon.mjs | 394 ++++++++++++++++++++++++++++++++ 11 files changed, 656 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/verse-service-contract.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/health-vili-daemon.cmd create mode 100644 scripts/restart-vili-daemon.cmd create mode 100644 scripts/restart-vili-daemon.ps1 create mode 100644 scripts/smoke-vili.mjs create mode 100644 scripts/start-vili-daemon.ps1 create mode 100644 scripts/vili-daemon.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25d4231 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vili/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..98e0757 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Vili + +Vili is the GameCult Persona animation daemon. + +It owns the bridge from Persona performance intent to body-motion generation. Its +first backend is Kimodo on Raven, running through Docker Engine inside +`Ubuntu-24.04` WSL with NVIDIA GPU passthrough. + +## Authority + +- Vili owns animation job intake, Kimodo runtime checks, generated motion + artifact references, and its operator/provider surface. +- Personas and Weksa may propose performance intent, gesture prompts, and + constraints. +- Kimodo owns motion synthesis. +- Future renderers own retargeting, playback, facial performance, camera, and + lip sync. +- Odin discovers Vili; Odin does not own Vili's state. +- Idunn keeps Vili alive; Idunn does not own animation decisions. + +## Local Runtime + +On Raven: + +```powershell +E:\Projects\Vili\scripts\restart-vili-daemon.cmd +``` + +Default compatibility endpoint: + +```text +http://10.77.0.4:8824 +``` + +Important routes: + +- `GET /health` +- `GET /provider-advertisement` +- `GET /operator-state` +- `GET /eve/operator` +- `GET /eve/deck/providers` +- `WS /eve/deck/vili.animation` +- `GET /smoke` +- `POST /motion/generate` + +## Current Blocker + +Actual Kimodo generation requires a Hugging Face token with access to gated +`meta-llama/Meta-Llama-3-8B-Instruct`. Without it, Vili can verify Kimodo and +CUDA, but generation fails at model fetch. diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md new file mode 100644 index 0000000..dca8152 --- /dev/null +++ b/docs/verse-service-contract.md @@ -0,0 +1,54 @@ +# Vili Verse Service Contract + +## Objective + +Vili turns Persona performance intent into animation work. The near-term body is +Kimodo on Raven; the durable contract is a service surface that can accept a +gesture/action prompt, run or queue motion synthesis, and publish inspectable +state for Odin and Idunn. + +## CultMesh Verses + +- `vili.service`: service identity, provider advertisement, health, and runtime. +- `vili.animation`: animation jobs, prompts, backend choices, and artifact refs. +- `vili.operator`: daemon status, backend readiness, and Eve/CultUI surface. + +## Witnesses + +- `.vili/provider-advertisement.json`: compatibility provider advertisement. +- `.vili/operator-state.json`: daemon health and backend readiness. +- `.vili/eve-operator-surface.json`: current operator surface. +- `.vili/jobs/{jobId}.json`: accepted animation job records. +- `.vili/artifacts/{jobId}/`: generated motion outputs when generation succeeds. + +JSON is currently a compatibility witness because Raven's first Vili body is a +small Node daemon. Promotion to `.cc` CultCache witnesses should happen when the +animation job schema stabilizes. + +## Command Boundary + +`POST /motion/generate` accepts an explicit animation request: + +```json +{ + "prompt": "a person gives a small friendly wave", + "duration": 2, + "diffusionSteps": 20, + "seed": 123, + "output": "wave-test", + "bvh": true +} +``` + +The daemon may reject or defer requests when: + +- Kimodo image is unavailable. +- Docker/GPU backend is unhealthy. +- Hugging Face token is absent and the selected backend requires gated model + access. +- The request omits a concrete prompt. + +## First Runtime Invariant + +Health checks must be cheap. They may verify process, port, backend metadata, +and cached readiness, but they must not launch a Kimodo generation job. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cdb6d24 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "@gamecult/vili", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gamecult/vili", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c585dbd --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "@gamecult/vili", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Vili is the GameCult Persona animation daemon: speech intent in, embodied motion affordance out.", + "scripts": { + "start": "node scripts/vili-daemon.mjs", + "health": "node scripts/vili-daemon.mjs --health", + "smoke": "node scripts/smoke-vili.mjs", + "provider": "node scripts/vili-daemon.mjs --print-provider-advertisement" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/scripts/health-vili-daemon.cmd b/scripts/health-vili-daemon.cmd new file mode 100644 index 0000000..25d8362 --- /dev/null +++ b/scripts/health-vili-daemon.cmd @@ -0,0 +1,2 @@ +@echo off +node "%~dp0vili-daemon.mjs" --health --host 127.0.0.1 --port 8824 --state-root "%~dp0..\.vili" diff --git a/scripts/restart-vili-daemon.cmd b/scripts/restart-vili-daemon.cmd new file mode 100644 index 0000000..79f5628 --- /dev/null +++ b/scripts/restart-vili-daemon.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0restart-vili-daemon.ps1" %* diff --git a/scripts/restart-vili-daemon.ps1 b/scripts/restart-vili-daemon.ps1 new file mode 100644 index 0000000..ee9874e --- /dev/null +++ b/scripts/restart-vili-daemon.ps1 @@ -0,0 +1,21 @@ +param( + [int]$Port = 8824, + [string]$HostName = "0.0.0.0", + [string]$StateRoot = "E:\Projects\Vili\.vili" +) + +$ErrorActionPreference = "Stop" +$PidPath = Join-Path $StateRoot "vili-daemon.pid" + +if (Test-Path $PidPath) { + $existingPid = (Get-Content $PidPath -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($existingPid) { + $process = Get-Process -Id ([int]$existingPid) -ErrorAction SilentlyContinue + if ($process) { + Stop-Process -Id $process.Id -Force + Start-Sleep -Seconds 1 + } + } +} + +& (Join-Path $PSScriptRoot "start-vili-daemon.ps1") -Port $Port -HostName $HostName -StateRoot $StateRoot diff --git a/scripts/smoke-vili.mjs b/scripts/smoke-vili.mjs new file mode 100644 index 0000000..30cc401 --- /dev/null +++ b/scripts/smoke-vili.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +const baseUrl = process.env.VILI_URL || process.argv[2] || "http://10.77.0.4:8824"; + +async function get(path) { + const response = await fetch(`${baseUrl}${path}`); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + if (!response.ok) { + throw new Error(`${path} returned ${response.status}: ${text.slice(0, 500)}`); + } + return body; +} + +const health = await get("/health"); +const providers = await get("/eve/deck/providers"); +const smoke = await get("/smoke"); + +console.log(JSON.stringify({ + ok: true, + baseUrl, + health: { + service: health.service, + providerId: health.providerId, + backend: health.state?.backend, + }, + providers, + smoke: { + ok: smoke.ok, + backend: smoke.backendStatus, + }, +}, null, 2)); diff --git a/scripts/start-vili-daemon.ps1 b/scripts/start-vili-daemon.ps1 new file mode 100644 index 0000000..3d49de7 --- /dev/null +++ b/scripts/start-vili-daemon.ps1 @@ -0,0 +1,42 @@ +param( + [int]$Port = 8824, + [string]$HostName = "0.0.0.0", + [string]$StateRoot = "E:\Projects\Vili\.vili" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Node = (Get-Command node -ErrorAction Stop).Source +$LogDir = Join-Path $StateRoot "logs" +$PidPath = Join-Path $StateRoot "vili-daemon.pid" +$OutLog = Join-Path $LogDir "vili-daemon.out.log" +$ErrLog = Join-Path $LogDir "vili-daemon.err.log" + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +if (Test-Path $PidPath) { + $existingPid = (Get-Content $PidPath -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($existingPid -and (Get-Process -Id ([int]$existingPid) -ErrorAction SilentlyContinue)) { + & $Node (Join-Path $Root "scripts\vili-daemon.mjs") --health --host 127.0.0.1 --port $Port --state-root $StateRoot + if ($LASTEXITCODE -eq 0) { + Write-Host "Vili already healthy on port $Port with PID $existingPid" + exit 0 + } + } +} + +$args = @( + (Join-Path $Root "scripts\vili-daemon.mjs"), + "--host", $HostName, + "--port", "$Port", + "--state-root", $StateRoot +) + +$process = Start-Process -FilePath $Node -ArgumentList $args -WorkingDirectory $Root -WindowStyle Hidden -RedirectStandardOutput $OutLog -RedirectStandardError $ErrLog -PassThru +Set-Content -Path $PidPath -Value $process.Id +Start-Sleep -Seconds 2 +& $Node (Join-Path $Root "scripts\vili-daemon.mjs") --health --host 127.0.0.1 --port $Port --state-root $StateRoot +if ($LASTEXITCODE -ne 0) { + throw "Vili did not become healthy. See $ErrLog" +} +Write-Host "Vili started on $HostName`:$Port with PID $($process.Id)" diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs new file mode 100644 index 0000000..8bf7d44 --- /dev/null +++ b/scripts/vili-daemon.mjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node +import http from "node:http"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { WebSocketServer } from "ws"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const ravenBaseUrl = process.env.VILI_PUBLIC_BASE_URL || "http://10.77.0.4:8824"; +const ravenDeckUrl = process.env.VILI_PUBLIC_DECK_URL || "ws://10.77.0.4:8824/eve/deck"; +const providerId = "vili.animation"; + +function parseArgs(argv) { + const args = { + host: process.env.VILI_DAEMON_HOST || "127.0.0.1", + port: Number(process.env.VILI_DAEMON_PORT || 8824), + stateRoot: process.env.VILI_STATE_ROOT || path.join(projectRoot, ".vili"), + health: false, + printProviderAdvertisement: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--host") args.host = argv[++index]; + else if (arg === "--port") args.port = Number(argv[++index]); + else if (arg === "--state-root") args.stateRoot = path.resolve(argv[++index]); + else if (arg === "--health") args.health = true; + else if (arg === "--print-provider-advertisement") args.printProviderAdvertisement = true; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); +const startedAt = new Date().toISOString(); +let backendStatus = { + checkedAt: null, + dockerImage: "unknown", + nvidiaRuntime: "unknown", + huggingFaceToken: "unknown", + kimodoHelp: "unknown", + lastError: null, +}; + +function run(command, commandArgs, options = {}) { + return new Promise((resolve) => { + execFile(command, commandArgs, { + cwd: projectRoot, + timeout: options.timeout ?? 20000, + env: { ...process.env, ...(options.env || {}) }, + windowsHide: true, + maxBuffer: 1024 * 1024 * 4, + }, (error, stdout, stderr) => { + resolve({ + ok: !error, + code: error?.code ?? 0, + signal: error?.signal ?? null, + stdout: String(stdout || "").trim(), + stderr: String(stderr || "").trim(), + error: error ? String(error.message || error) : null, + }); + }); + }); +} + +function wsl(script, options = {}) { + return run("wsl.exe", ["-d", "Ubuntu-24.04", "-u", "root", "--", "bash", "-lc", script], options); +} + +function json(response, statusCode, body) { + const payload = JSON.stringify(body, null, 2); + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "access-control-allow-origin": "*", + }); + response.end(payload); +} + +function text(response, statusCode, body) { + response.writeHead(statusCode, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + "access-control-allow-origin": "*", + }); + response.end(body); +} + +function providerAdvertisement() { + return { + schema: "gamecult.eve.provider_advertisement.v1", + providerId, + title: "Vili Animation", + description: "Persona animation and Kimodo motion generation daemon on Raven.", + canonicalService: "asgard.vili", + locatedService: "asgard.raven.vili", + verseId: "raven.local", + cultMeshAddress: "asgard.raven.vili/eve/operator", + updatedAt: new Date().toISOString(), + endpoints: { + http: ravenBaseUrl, + health: `${ravenBaseUrl}/health`, + smoke: `${ravenBaseUrl}/smoke`, + providerAdvertisement: `${ravenBaseUrl}/provider-advertisement`, + operatorState: `${ravenBaseUrl}/operator-state`, + operatorSurface: `${ravenBaseUrl}/eve/operator`, + deckBroker: ravenDeckUrl, + providerDeck: `${ravenDeckUrl}/${providerId}`, + }, + capabilities: [ + "kimodo.motion.generate", + "persona.performance.intent", + "eve.operator.surface", + "cultmesh.provider.discovery", + ], + }; +} + +function operatorState() { + return { + schema: "gamecult.vili.operator_state.v0", + service: "vili", + providerId, + verseId: "raven.local", + host: "raven", + startedAt, + updatedAt: new Date().toISOString(), + backend: backendStatus, + authority: { + owns: [ + "animation job intake", + "Kimodo runtime checks", + "motion artifact references", + "Vili provider and operator surfaces", + ], + doesNotOwn: [ + "Persona speech generation", + "Weksa interlingua lowering", + "retargeting and playback", + "Odin discovery truth", + ], + }, + routes: { + health: "/health", + providerAdvertisement: "/provider-advertisement", + operatorState: "/operator-state", + operatorSurface: "/eve/operator", + deckProviders: "/eve/deck/providers", + smoke: "/smoke", + generate: "/motion/generate", + }, + }; +} + +function eveSurface() { + const state = operatorState(); + return { + schema: "gamecult.eve.surface.v1", + providerId, + title: "Vili Animation", + updatedAt: state.updatedAt, + surface: { + root: { + type: "panel", + title: "Vili Animation", + children: [ + { type: "stat", label: "Host", value: "Raven" }, + { type: "stat", label: "Verse", value: "raven.local" }, + { type: "stat", label: "Docker image", value: state.backend.dockerImage }, + { type: "stat", label: "NVIDIA runtime", value: state.backend.nvidiaRuntime }, + { type: "stat", label: "Kimodo", value: state.backend.kimodoHelp }, + { type: "stat", label: "HF token", value: state.backend.huggingFaceToken }, + { type: "command", label: "Smoke", method: "GET", href: `${ravenBaseUrl}/smoke` }, + { type: "command", label: "Generate motion", method: "POST", href: `${ravenBaseUrl}/motion/generate` }, + ], + }, + }, + state, + }; +} + +async function persistSurfaces() { + await mkdir(args.stateRoot, { recursive: true }); + await writeFile(path.join(args.stateRoot, "vili-daemon.pid"), `${process.pid}\n`); + await writeFile(path.join(args.stateRoot, "provider-advertisement.json"), JSON.stringify(providerAdvertisement(), null, 2)); + await writeFile(path.join(args.stateRoot, "operator-state.json"), JSON.stringify(operatorState(), null, 2)); + await writeFile(path.join(args.stateRoot, "eve-operator-surface.json"), JSON.stringify(eveSurface(), null, 2)); +} + +async function refreshBackendStatus({ includeKimodoHelp = false } = {}) { + const docker = await wsl([ + "set -e", + "service docker start >/dev/null 2>&1 || true", + "docker images gamecult/kimodo:latest --format '{{.Repository}}:{{.Tag}}' | head -n 1", + ].join("; "), { timeout: 20000 }); + + let nvidia = await wsl([ + "set -e", + "service docker start >/dev/null 2>&1 || true", + "docker info --format '{{json .Runtimes}}' | grep -q nvidia && echo configured || echo missing", + ].join("; "), { timeout: 20000 }); + + const token = await wsl("test -f /root/.cache/huggingface/token && echo present || echo missing", { timeout: 10000 }); + let help = { ok: true, stdout: backendStatus.kimodoHelp === "ok" ? "ok" : "not checked", stderr: "" }; + if (includeKimodoHelp) { + nvidia = await wsl([ + "set -e", + "service docker start >/dev/null 2>&1 || true", + "docker run --rm --runtime=nvidia --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi --query-gpu=name --format=csv,noheader | head -n 1", + ].join("; "), { timeout: 45000 }); + help = await wsl([ + "set -e", + "service docker start >/dev/null 2>&1 || true", + "docker run --rm --runtime=nvidia --gpus all gamecult/kimodo:latest kimodo_gen --help | sed -n '1,6p'", + ].join("; "), { timeout: 60000 }); + } + + backendStatus = { + checkedAt: new Date().toISOString(), + dockerImage: docker.ok && docker.stdout ? docker.stdout : "missing", + nvidiaRuntime: nvidia.ok && nvidia.stdout ? nvidia.stdout : "unavailable", + huggingFaceToken: token.ok ? token.stdout : "unknown", + kimodoHelp: includeKimodoHelp ? (help.ok ? "ok" : "failed") : backendStatus.kimodoHelp, + lastError: [docker, nvidia, token, help].find((result) => !result.ok)?.stderr || null, + }; + await persistSurfaces(); + return { docker, nvidia, token, help, backendStatus }; +} + +async function readRequestBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) : {}; +} + +async function generateMotion(request, response) { + let body; + try { + body = await readRequestBody(request); + } catch (error) { + json(response, 400, { ok: false, error: `Invalid JSON: ${error.message}` }); + return; + } + + const prompt = String(body.prompt || body.utterance || "").trim(); + if (!prompt) { + json(response, 400, { ok: false, error: "Missing prompt or utterance." }); + return; + } + + const jobId = `vili-${Date.now()}`; + const artifactDir = path.join(args.stateRoot, "artifacts", jobId); + await mkdir(artifactDir, { recursive: true }); + const duration = Number(body.durationSeconds || body.duration || 4); + const linuxOutput = `/mnt/e/Projects/Vili/.vili/artifacts/${jobId}`; + const command = [ + "set -e", + "service docker start >/dev/null 2>&1 || true", + `mkdir -p ${linuxOutput}`, + [ + "docker run --rm --runtime=nvidia --gpus all", + "-e TEXT_ENCODER_DEVICE=cpu", + `-v /mnt/e/Projects/Vili/.vili/artifacts/${jobId}:/outputs`, + "gamecult/kimodo:latest", + "kimodo_gen", + "--output_dir /outputs", + `--duration ${duration}`, + "\"$VILI_PROMPT\"", + ].join(" "), + ].join("; "); + + const result = await wsl(command, { + timeout: Number(body.timeoutMs || 1000 * 60 * 30), + env: { VILI_PROMPT: prompt }, + }); + + const job = { + schema: "gamecult.vili.motion_job.v0", + jobId, + createdAt: new Date().toISOString(), + prompt, + durationSeconds: duration, + artifactDir, + ok: result.ok, + stdout: result.stdout, + stderr: result.stderr, + error: result.error, + }; + await writeFile(path.join(args.stateRoot, "jobs", `${jobId}.json`), JSON.stringify(job, null, 2)).catch(async () => { + await mkdir(path.join(args.stateRoot, "jobs"), { recursive: true }); + await writeFile(path.join(args.stateRoot, "jobs", `${jobId}.json`), JSON.stringify(job, null, 2)); + }); + json(response, result.ok ? 200 : 502, job); +} + +async function handleRequest(request, response) { + const url = new URL(request.url, `http://${request.headers.host || `${args.host}:${args.port}`}`); + if (request.method === "OPTIONS") { + response.writeHead(204, { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,OPTIONS", + "access-control-allow-headers": "content-type", + }); + response.end(); + return; + } + + if (url.pathname === "/" || url.pathname === "/health") { + await refreshBackendStatus({ includeKimodoHelp: false }); + json(response, 200, { ok: true, service: "vili", providerId, state: operatorState() }); + } else if (url.pathname === "/provider-advertisement") { + json(response, 200, providerAdvertisement()); + } else if (url.pathname === "/operator-state") { + json(response, 200, operatorState()); + } else if (url.pathname === "/eve/operator") { + json(response, 200, eveSurface()); + } else if (url.pathname === "/eve/deck/providers") { + json(response, 200, { + providers: [{ + id: providerId, + title: "Vili Animation", + description: "Persona animation and Kimodo motion generation daemon.", + endpoint: `${ravenDeckUrl}/${providerId}`, + }], + }); + } else if (url.pathname === "/smoke") { + const result = await refreshBackendStatus({ includeKimodoHelp: true }); + json(response, result.backendStatus.kimodoHelp === "ok" ? 200 : 502, { ok: result.backendStatus.kimodoHelp === "ok", ...result }); + } else if (url.pathname === "/motion/generate" && request.method === "POST") { + await generateMotion(request, response); + } else { + text(response, 404, "not found\n"); + } +} + +async function healthCheck() { + const host = args.host === "0.0.0.0" ? "127.0.0.1" : args.host; + const response = await fetch(`http://${host}:${args.port}/health`); + if (!response.ok) throw new Error(`health returned ${response.status}`); + const body = await response.json(); + if (!body.ok) throw new Error("health returned ok=false"); +} + +if (args.printProviderAdvertisement) { + console.log(JSON.stringify(providerAdvertisement(), null, 2)); + process.exit(0); +} + +if (args.health) { + healthCheck().then(() => process.exit(0), (error) => { + console.error(error.message); + process.exit(1); + }); +} else { + await mkdir(path.join(args.stateRoot, "jobs"), { recursive: true }); + await persistSurfaces(); + if (!existsSync(path.join(args.stateRoot, "backend-status.json"))) { + await writeFile(path.join(args.stateRoot, "backend-status.json"), JSON.stringify(backendStatus, null, 2)); + } + + const server = http.createServer((request, response) => { + handleRequest(request, response).catch((error) => { + json(response, 500, { ok: false, error: error.message, stack: process.env.NODE_ENV === "development" ? error.stack : undefined }); + }); + }); + + const wss = new WebSocketServer({ noServer: true }); + wss.on("connection", (socket) => { + const sendSurface = () => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(eveSurface())); + }; + sendSurface(); + const timer = setInterval(sendSurface, 5000); + socket.on("close", () => clearInterval(timer)); + }); + + server.on("upgrade", (request, socket, head) => { + const url = new URL(request.url, `http://${request.headers.host || `${args.host}:${args.port}`}`); + if (url.pathname === `/eve/deck/${providerId}` || url.pathname === "/eve/deck") { + wss.handleUpgrade(request, socket, head, (ws) => wss.emit("connection", ws, request)); + } else { + socket.destroy(); + } + }); + + server.listen(args.port, args.host, () => { + console.log(`Vili listening on http://${args.host}:${args.port}`); + }); + + setInterval(() => persistSurfaces().catch((error) => console.error(error)), 5000); +}