feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# boot-public-runtime.ps1
|
||||
# 목적: PC 재부팅/재로그온 후 퍼블릭 런타임을 자동으로 복구한다.
|
||||
# 순서: Docker Desktop 데몬 대기 -> postgres(vignette-dev-db) 기동 ->
|
||||
# API 가 이미 healthy 면 스킵, 아니면 start-public-runtime.ps1(-SkipWebRestart) 로
|
||||
# 엔진 게이트웨이(9099) + API(8001) + cloudflared 터널을 올린다.
|
||||
# 관리자/인증 API 가 이미 healthy 이고 엔진도 healthy 면 스킵, 아니면
|
||||
# start-public-runtime.ps1(-SkipWebRestart) 로 필요한 프로세스만 복구한다.
|
||||
# 멱등: 어느 단계든 이미 살아있으면 건드리지 않는다. 수동으로 여러 번 실행해도 안전.
|
||||
#
|
||||
# 등록(로그온 시 자동 실행, 숨김 창):
|
||||
|
|
@ -46,7 +46,7 @@ function Test-Tcp([string]$Host_, [int]$Port) {
|
|||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Test-ApiHealthy {
|
||||
function Test-ApiControlPlaneHealthy {
|
||||
# HttpWebRequest + Proxy=$null: WININET/시스템 프록시에 영향받지 않는 가장 직결적인 검사.
|
||||
# 비대화형 스케줄러 컨텍스트에서도 127.0.0.1 로 직접 연결한다. 3회 재시도.
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
|
|
@ -60,7 +60,7 @@ function Test-ApiHealthy {
|
|||
$body = $reader.ReadToEnd()
|
||||
$reader.Close(); $resp.Close()
|
||||
$h = $body | ConvertFrom-Json
|
||||
if ($h.environment -eq "prod" -and $h.db -eq $true -and $h.engine -eq $true) { return $true }
|
||||
if ($h.environment -eq "prod" -and $h.db -eq $true) { return $true }
|
||||
Write-BootLog (" health probe attempt {0}: not-healthy body={1}" -f $i, $body)
|
||||
return $false
|
||||
} catch {
|
||||
|
|
@ -71,6 +71,15 @@ function Test-ApiHealthy {
|
|||
return $false
|
||||
}
|
||||
|
||||
function Test-EngineHealthy {
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri "http://127.0.0.1:9099/health" -TimeoutSec 10
|
||||
return $response.ok -eq $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Write-BootLog "================ boot start ================"
|
||||
|
||||
# 1) Docker 데몬(내려가 있으면 Docker Desktop 기동 후 대기)
|
||||
|
|
@ -109,8 +118,8 @@ if (-not (Test-Tcp -Host_ "127.0.0.1" -Port $DbPort)) {
|
|||
Write-BootLog "postgres 127.0.0.1:$DbPort up"
|
||||
|
||||
# 3) 엔진/API/cloudflared — 이미 healthy 면 스킵(불필요한 재시작/다운타임 방지)
|
||||
if (Test-ApiHealthy) {
|
||||
Write-BootLog "API already healthy on $ApiPort; skipping engine/api/cloudflared restart"
|
||||
if ((Test-ApiControlPlaneHealthy) -and (Test-EngineHealthy)) {
|
||||
Write-BootLog "control plane and engine already healthy; skipping runtime restart"
|
||||
} else {
|
||||
$pub = Join-Path $Workspace "scripts\start-public-runtime.ps1"
|
||||
if (-not (Test-Path -LiteralPath $pub)) {
|
||||
|
|
@ -127,10 +136,14 @@ if (Test-ApiHealthy) {
|
|||
}
|
||||
|
||||
# 4) 최종 확인
|
||||
if (Test-ApiHealthy) {
|
||||
Write-BootLog "boot OK: API healthy on $ApiPort"
|
||||
if (Test-ApiControlPlaneHealthy) {
|
||||
if (Test-EngineHealthy) {
|
||||
Write-BootLog "boot OK: control plane and engine healthy"
|
||||
} else {
|
||||
Write-BootLog "boot OK: admin/auth control plane healthy; engine remains degraded"
|
||||
}
|
||||
exit 0
|
||||
} else {
|
||||
Write-BootLog "WARN: boot finished but API health check failed — see apps/api/api.public.err.log"
|
||||
Write-BootLog "WARN: boot finished but admin/auth control plane failed — see apps/api/api.public.err.log"
|
||||
exit 2
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,14 +16,20 @@
|
|||
vite 웹 서버를 띄우지 않는다(API만 필요할 때).
|
||||
.PARAMETER NoDb
|
||||
Docker Postgres 보장을 건너뛰고 in-memory degraded 기동을 허용한다.
|
||||
.PARAMETER UseHiggsVoice
|
||||
로컬 synthetic seed 전용 Higgs TTS 서버를 준비하고 dev API의 P1 음성 공급자로 연결한다.
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 -UseHiggsVoice
|
||||
#>
|
||||
param(
|
||||
[int]$ApiPort = 8000,
|
||||
[switch]$NoGateway,
|
||||
[switch]$NoWeb,
|
||||
[switch]$NoDb
|
||||
[switch]$NoDb,
|
||||
[switch]$UseHiggsVoice,
|
||||
[int]$HiggsPort = 9881
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = Split-Path -Parent $PSScriptRoot
|
||||
|
|
@ -245,6 +251,14 @@ if (-not $NoDb) {
|
|||
Ensure-DevDb
|
||||
}
|
||||
|
||||
if ($UseHiggsVoice) {
|
||||
Write-Host '[voice] 로컬 Higgs TTS 준비 (synthetic seed only)...'
|
||||
$higgsLauncher = Join-Path $PSScriptRoot 'start-higgs-tts.ps1'
|
||||
& $higgsLauncher -Port $HiggsPort -WaitReadySeconds 180
|
||||
$env:VIGNETTE_VOICE_TTS_PROVIDER = 'higgs'
|
||||
$env:VIGNETTE_HIGGS_TTS_URL = "http://127.0.0.1:$HiggsPort"
|
||||
}
|
||||
|
||||
if (-not $NoGateway) {
|
||||
Write-Host '[2/4] 엔진 게이트웨이 :9099 (claude_cli)...'
|
||||
$gatewayArgs = @($PyPre) + @('-m', 'uvicorn', 'engine_gateway.gateway:app', '--host', '127.0.0.1', '--port', '9099')
|
||||
|
|
@ -256,6 +270,7 @@ if (-not $NoGateway) {
|
|||
Write-Host ('[3/4] API :{0} (단일 프로세스, degraded in-memory OK, seed 페르소나)...' -f $ApiPort)
|
||||
$env:AUTO_SEED_PERSONAS = 'true'
|
||||
$env:ALLOW_SEED_PERSONA_FALLBACK = 'true'
|
||||
$env:VIGNETTE_LIVE_CLIENT_PROVIDER = 'claude_cli'
|
||||
$env:VITE_API_PROXY_TARGET = "http://127.0.0.1:$ApiPort"
|
||||
$apiArgs = @($PyPre) + @('-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', "$ApiPort")
|
||||
Start-Process -FilePath $Python -ArgumentList $apiArgs -WorkingDirectory $api -WindowStyle Hidden -RedirectStandardOutput (Join-Path $logs 'api.out.log') -RedirectStandardError (Join-Path $logs 'api.err.log')
|
||||
|
|
|
|||
194
scripts/higgs-tts-server.py
Normal file
194
scripts/higgs-tts-server.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""권리 안전한 Vignette P1용 Higgs Audio v3 상주 TTS 서버.
|
||||
|
||||
Higgs 모델은 한 번만 GPU에 올리고, 저장소의 무참조 synthetic seed만 화자
|
||||
reference로 사용한다. 실존 인물/성우 음성은 읽지 않는다. 모델 라이선스 때문에
|
||||
127.0.0.1 로컬 개발 환경에서만 실행한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_COMFY_ROOT = Path(r"C:\Users\encep\Tools\ComfyUI_windows_portable")
|
||||
DEFAULT_REFERENCE_DIR = (
|
||||
REPO_ROOT / "docs" / "voice-art" / "p1-seoyeon-higgs-v3-20260627"
|
||||
)
|
||||
MODEL_ID = "higgs-audio-v3-tts-4b"
|
||||
MAX_TEXT_CHARS = 2_000
|
||||
|
||||
|
||||
def _load_reference(reference_dir: Path) -> tuple[dict[str, Any], str]:
|
||||
manifest = json.loads((reference_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
seed = manifest["synthetic_seed"]
|
||||
reference_path = reference_dir / seed["wav"]
|
||||
samples, sample_rate = sf.read(reference_path, dtype="float32")
|
||||
if samples.ndim == 2:
|
||||
samples = samples.mean(axis=1)
|
||||
reference = {
|
||||
"waveform": torch.from_numpy(samples[None, None, :]).float(),
|
||||
"sample_rate": int(sample_rate),
|
||||
}
|
||||
return reference, str(seed["text"])
|
||||
|
||||
|
||||
def _load_bundle(comfy_root: Path):
|
||||
node_root = comfy_root / "ComfyUI" / "custom_nodes"
|
||||
if not node_root.is_dir():
|
||||
raise RuntimeError(f"ComfyUI custom_nodes 경로가 없습니다: {node_root}")
|
||||
sys.path.insert(0, str(node_root))
|
||||
loader = importlib.import_module("Higgs_v3-TTS-ComfyUI.loader")
|
||||
native = importlib.import_module("Higgs_v3-TTS-ComfyUI.native")
|
||||
choices = list(loader.get_model_choices())
|
||||
choice = MODEL_ID if MODEL_ID in choices else next(
|
||||
(item for item in choices if "higgs" in item.casefold()), None
|
||||
)
|
||||
if choice is None:
|
||||
raise RuntimeError("설치된 Higgs Audio v3 TTS 모델을 찾지 못했습니다.")
|
||||
bundle = loader.load_higgs_bundle(
|
||||
model_choice=choice,
|
||||
dtype_name="auto",
|
||||
device_name="auto",
|
||||
attention="auto",
|
||||
download_if_missing=False,
|
||||
)
|
||||
return native, bundle, choice
|
||||
|
||||
|
||||
class HiggsRuntime:
|
||||
def __init__(self, comfy_root: Path, reference_dir: Path) -> None:
|
||||
started = time.perf_counter()
|
||||
self.native, self.bundle, self.model_choice = _load_bundle(comfy_root)
|
||||
self.reference_audio, self.reference_text = _load_reference(reference_dir)
|
||||
self.loaded_seconds = round(time.perf_counter() - started, 3)
|
||||
|
||||
def synthesize_wav(self, text: str) -> bytes:
|
||||
generated = self.native.generate_higgs_audio(
|
||||
self.bundle,
|
||||
text=text,
|
||||
reference_audio=self.reference_audio,
|
||||
reference_audio_path="",
|
||||
reference_text=self.reference_text,
|
||||
max_new_tokens=2048,
|
||||
temperature=0.8,
|
||||
top_p=0.95,
|
||||
top_k=50,
|
||||
seed=0,
|
||||
trim_reference_audio=True,
|
||||
silence_threshold_db=-42.0,
|
||||
max_reference_seconds=12.0,
|
||||
progress_callback=None,
|
||||
)
|
||||
waveform = generated["waveform"]
|
||||
if not isinstance(waveform, torch.Tensor):
|
||||
waveform = torch.as_tensor(waveform)
|
||||
data = waveform.detach().float().cpu()
|
||||
if data.ndim == 3:
|
||||
data = data[0]
|
||||
if data.ndim == 2:
|
||||
data = data.numpy().T
|
||||
else:
|
||||
data = data.numpy()
|
||||
output = io.BytesIO()
|
||||
sf.write(output, data, int(generated["sample_rate"]), format="WAV")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def make_handler(runtime: HiggsRuntime):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "VignetteHiggsTTS/1.0"
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
print(f"[higgs-tts] {self.address_string()} {format % args}", flush=True)
|
||||
|
||||
def _send_json(self, status: int, body: dict[str, Any]) -> None:
|
||||
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path != "/health":
|
||||
self._send_json(404, {"detail": "not found"})
|
||||
return
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"model": MODEL_ID,
|
||||
"model_choice": runtime.model_choice,
|
||||
"loaded_seconds": runtime.loaded_seconds,
|
||||
"reference_policy": "synthetic-seed-only",
|
||||
},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path != "/tts":
|
||||
self._send_json(404, {"detail": "not found"})
|
||||
return
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > 32_768:
|
||||
raise ValueError("invalid content length")
|
||||
body = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||||
text = str(body.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ValueError("text is required")
|
||||
if len(text) > MAX_TEXT_CHARS:
|
||||
raise ValueError(f"text exceeds {MAX_TEXT_CHARS} characters")
|
||||
started = time.perf_counter()
|
||||
audio = runtime.synthesize_wav(text)
|
||||
elapsed = time.perf_counter() - started
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "audio/wav")
|
||||
self.send_header("Content-Length", str(len(audio)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Higgs-Generation-Seconds", f"{elapsed:.3f}")
|
||||
self.end_headers()
|
||||
self.wfile.write(audio)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._send_json(400, {"detail": str(exc)})
|
||||
except Exception as exc: # 모델 오류는 본문에 민감정보 없이 타입만 노출
|
||||
print(f"[higgs-tts] generation failed: {exc!r}", flush=True)
|
||||
self._send_json(500, {"detail": type(exc).__name__})
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="127.0.0.1", choices=["127.0.0.1", "localhost"])
|
||||
parser.add_argument("--port", type=int, default=9881)
|
||||
parser.add_argument("--comfy-root", type=Path, default=DEFAULT_COMFY_ROOT)
|
||||
parser.add_argument("--reference-dir", type=Path, default=DEFAULT_REFERENCE_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("[higgs-tts] Higgs 모델과 synthetic seed를 로드합니다...", flush=True)
|
||||
runtime = HiggsRuntime(args.comfy_root, args.reference_dir)
|
||||
print(
|
||||
f"[higgs-tts] ready model={runtime.model_choice} load={runtime.loaded_seconds}s "
|
||||
f"url=http://{args.host}:{args.port}",
|
||||
flush=True,
|
||||
)
|
||||
HTTPServer((args.host, args.port), make_handler(runtime)).serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
103
scripts/preserve-pages-assets.ps1
Normal file
103
scripts/preserve-pages-assets.ps1
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
param(
|
||||
[string]$DistPath = "D:\workspace\vignette\apps\web\dist",
|
||||
[string[]]$DeploymentBases = @(
|
||||
"https://1b3b6d5b.vignette-b1q.pages.dev",
|
||||
"https://0a339b77.vignette-b1q.pages.dev",
|
||||
"https://f323a41d.vignette-b1q.pages.dev",
|
||||
"https://1332ccbe.vignette-b1q.pages.dev",
|
||||
"https://564dbed9.vignette-b1q.pages.dev"
|
||||
)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
$distRoot = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $DistPath).Path)
|
||||
$distPrefix = $distRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
||||
$totalAdded = 0
|
||||
|
||||
function Resolve-DistTarget {
|
||||
param([string]$AssetPath)
|
||||
|
||||
$relative = $AssetPath.TrimStart("/").Replace("/", [IO.Path]::DirectorySeparatorChar)
|
||||
$target = [IO.Path]::GetFullPath((Join-Path $distRoot $relative))
|
||||
if (!$target.StartsWith($distPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Asset target escaped the deployment directory: $AssetPath"
|
||||
}
|
||||
return $target
|
||||
}
|
||||
|
||||
foreach ($base in $DeploymentBases) {
|
||||
$origin = $base.TrimEnd("/")
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new(
|
||||
[System.StringComparer]::OrdinalIgnoreCase
|
||||
)
|
||||
$queue = [System.Collections.Generic.Queue[string]]::new()
|
||||
$html = (
|
||||
Invoke-WebRequest `
|
||||
-Uri "$origin/login?preserve=$([guid]::NewGuid().ToString('N'))" `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30
|
||||
).Content
|
||||
|
||||
foreach ($match in [regex]::Matches(
|
||||
$html,
|
||||
'(?:src|href)="(/assets/[^\"]+\.(?:js|css))"'
|
||||
)) {
|
||||
$queue.Enqueue($match.Groups[1].Value)
|
||||
}
|
||||
|
||||
$added = 0
|
||||
while ($queue.Count -gt 0) {
|
||||
$asset = $queue.Dequeue()
|
||||
if (!$seen.Add($asset)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$target = Resolve-DistTarget -AssetPath $asset
|
||||
$parent = Split-Path -Parent $target
|
||||
if (!(Test-Path -LiteralPath $parent)) {
|
||||
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
||||
}
|
||||
|
||||
$download = "$target.download"
|
||||
$response = Invoke-WebRequest `
|
||||
-Uri "$origin$asset" `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-OutFile $download `
|
||||
-PassThru
|
||||
$contentType = [string]$response.Headers["Content-Type"]
|
||||
if ($asset.EndsWith(".js") -and $contentType -notmatch "javascript") {
|
||||
Remove-Item -LiteralPath $download -Force
|
||||
throw "Expected JavaScript response: $origin$asset ($contentType)"
|
||||
}
|
||||
if ($asset.EndsWith(".css") -and $contentType -notmatch "text/css") {
|
||||
Remove-Item -LiteralPath $download -Force
|
||||
throw "Expected CSS response: $origin$asset ($contentType)"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
Remove-Item -LiteralPath $download -Force
|
||||
} else {
|
||||
Move-Item -LiteralPath $download -Destination $target
|
||||
$added += 1
|
||||
$totalAdded += 1
|
||||
}
|
||||
|
||||
if ($asset -match '\.(?:js|css)$') {
|
||||
$source = Get-Content -LiteralPath $target -Raw -Encoding UTF8
|
||||
foreach ($match in [regex]::Matches(
|
||||
$source,
|
||||
'(?:(?:/assets/)|(?:\./))([A-Za-z0-9_.-]+\.(?:js|css))'
|
||||
)) {
|
||||
$queue.Enqueue("/assets/$($match.Groups[1].Value)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "Preserved $origin (graph=$($seen.Count), added=$added)"
|
||||
}
|
||||
|
||||
Write-Output "Total previous deployment assets added: $totalAdded"
|
||||
73
scripts/start-higgs-tts.ps1
Normal file
73
scripts/start-higgs-tts.ps1
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
param(
|
||||
[int]$Port = 9881,
|
||||
[int]$WaitReadySeconds = 180
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$serverScript = Join-Path $PSScriptRoot 'higgs-tts-server.py'
|
||||
$embeddedPython = 'C:\Users\encep\Tools\ComfyUI_windows_portable\python_embeded\python.exe'
|
||||
$healthUrl = "http://127.0.0.1:$Port/health"
|
||||
$runtimeLogDir = Join-Path $env:TEMP 'Vignette\higgs-tts'
|
||||
$stdoutLog = Join-Path $runtimeLogDir 'server.out.log'
|
||||
$stderrLog = Join-Path $runtimeLogDir 'server.err.log'
|
||||
|
||||
if (!(Test-Path -LiteralPath $embeddedPython)) {
|
||||
throw "ComfyUI embedded Python을 찾지 못했습니다: $embeddedPython"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $serverScript)) {
|
||||
throw "Higgs 서버 스크립트를 찾지 못했습니다: $serverScript"
|
||||
}
|
||||
|
||||
try {
|
||||
$currentHealth = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 3
|
||||
if ($currentHealth.status -eq 'ok' -and $currentHealth.reference_policy -eq 'synthetic-seed-only') {
|
||||
Write-Output "Higgs TTS가 이미 준비됐습니다: $healthUrl"
|
||||
$currentHealth | ConvertTo-Json -Depth 4 -Compress
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
# 아직 서버가 없으면 아래에서 시작한다.
|
||||
}
|
||||
|
||||
$listener = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue
|
||||
if ($null -ne $listener) {
|
||||
throw "포트 $Port 를 다른 프로세스가 사용 중입니다. 임의 종료하지 않았습니다."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $runtimeLogDir -Force | Out-Null
|
||||
$args = @('-X', 'utf8', $serverScript, '--host', '127.0.0.1', '--port', "$Port")
|
||||
$process = Start-Process -WindowStyle Hidden -FilePath $embeddedPython `
|
||||
-ArgumentList $args `
|
||||
-WorkingDirectory $repoRoot `
|
||||
-RedirectStandardOutput $stdoutLog `
|
||||
-RedirectStandardError $stderrLog `
|
||||
-PassThru
|
||||
|
||||
Write-Output "Higgs TTS 로드를 시작했습니다. PID=$($process.Id)"
|
||||
Write-Output "로그: $stdoutLog"
|
||||
if ($WaitReadySeconds -le 0) {
|
||||
return
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($WaitReadySeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if ($process.HasExited) {
|
||||
throw "Higgs TTS가 준비되기 전에 종료됐습니다. stderr=$stderrLog"
|
||||
}
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 3
|
||||
if ($health.status -eq 'ok' -and $health.reference_policy -eq 'synthetic-seed-only') {
|
||||
Write-Output "Higgs TTS 준비 완료: $healthUrl"
|
||||
$health | ConvertTo-Json -Depth 4 -Compress
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
|
||||
throw "Higgs TTS 준비 시간이 ${WaitReadySeconds}초를 넘었습니다. 로그: $stdoutLog"
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
|
||||
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
|
||||
[switch]$SkipEngineRestart,
|
||||
[switch]$ForceApiRestart,
|
||||
[switch]$SkipWebRestart,
|
||||
[switch]$RouteCloudflareDns,
|
||||
[string]$CloudflareTunnelName = "vignette",
|
||||
|
|
@ -181,17 +182,21 @@ if ($SkipEngineRestart) {
|
|||
-RedirectStandardError $EngineErrLog `
|
||||
-PassThru | Out-Null
|
||||
|
||||
$engineHealth = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$EnginePort/health" `
|
||||
-IsHealthy { param($health) $health.ok -eq $true } `
|
||||
-TimeoutSec 30
|
||||
try {
|
||||
$engineHealth = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$EnginePort/health" `
|
||||
-IsHealthy { param($health) $health.ok -eq $true } `
|
||||
-TimeoutSec 30
|
||||
} catch {
|
||||
Write-Warning "Engine gateway is still degraded; continuing admin/auth recovery"
|
||||
$engineHealth = Get-JsonHealth -Uri "http://127.0.0.1:$EnginePort/health"
|
||||
}
|
||||
}
|
||||
|
||||
Stop-UvicornByPort -AppImport "app.main:app" -Port $ApiPort
|
||||
|
||||
$env:ENVIRONMENT = "prod"
|
||||
$env:ENGINE_URL = "http://127.0.0.1:$EnginePort"
|
||||
$env:ENGINE_MODE = "claude_cli"
|
||||
$env:VIGNETTE_LIVE_CLIENT_PROVIDER = "claude_cli"
|
||||
$env:AUTH_DEV_LOGIN_ENABLED = "false"
|
||||
$env:AUTO_SEED_PERSONAS = "false"
|
||||
$env:ALLOW_SEED_PERSONA_FALLBACK = "false"
|
||||
|
|
@ -209,20 +214,29 @@ $env:FRONTEND_ORIGIN_MAP = ConvertTo-CompactJson -Value ([ordered]@{
|
|||
"api-vnet.18ka.net" = "https://vnet.18ka.net"
|
||||
})
|
||||
|
||||
$proc = Start-Process -WindowStyle Hidden -FilePath $Python `
|
||||
-ArgumentList @("-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$ApiPort") `
|
||||
-WorkingDirectory $ApiDir `
|
||||
-RedirectStandardOutput $OutLog `
|
||||
-RedirectStandardError $ErrLog `
|
||||
-PassThru
|
||||
$health = Get-JsonHealth -Uri "http://127.0.0.1:$ApiPort/health"
|
||||
$apiControlPlaneReady = $null -ne $health -and $health.environment -eq "prod" -and $health.db
|
||||
$proc = $null
|
||||
if ($apiControlPlaneReady -and -not $ForceApiRestart) {
|
||||
Write-Output "Admin/auth control plane already healthy; skipping API restart"
|
||||
} else {
|
||||
Stop-UvicornByPort -AppImport "app.main:app" -Port $ApiPort
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
$health = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-TimeoutSec 30
|
||||
if ($health.environment -ne "prod" -or -not $health.db -or -not $health.engine) {
|
||||
throw "Public API health is not production-safe: $($health | ConvertTo-Json -Compress)"
|
||||
$proc = Start-Process -WindowStyle Hidden -FilePath $Python `
|
||||
-ArgumentList @("-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$ApiPort") `
|
||||
-WorkingDirectory $ApiDir `
|
||||
-RedirectStandardOutput $OutLog `
|
||||
-RedirectStandardError $ErrLog `
|
||||
-PassThru
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
$health = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
|
||||
-TimeoutSec 30
|
||||
}
|
||||
if ($health.environment -ne "prod" -or -not $health.db) {
|
||||
throw "Admin/auth control plane is not production-safe: $($health | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
if (!$SkipWebRestart) {
|
||||
|
|
@ -275,20 +289,30 @@ if (!$SkipCloudflaredRestart) {
|
|||
-ApiPortValue $ApiPort `
|
||||
-WebPortValue $WebPort
|
||||
|
||||
Get-CimInstance Win32_Process |
|
||||
$cloudflaredProcess = Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.Name -eq "cloudflared.exe" -and $_.CommandLine -like "*vignette-config.yml*" } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
Start-Process -WindowStyle Hidden -FilePath $Cloudflared `
|
||||
-ArgumentList @("tunnel", "--config", $CloudflaredConfig, "run") `
|
||||
-RedirectStandardOutput (Join-Path $Workspace "cloudflared.public.out.log") `
|
||||
-RedirectStandardError (Join-Path $Workspace "cloudflared.public.err.log") `
|
||||
-PassThru | Out-Null
|
||||
Select-Object -First 1
|
||||
if ($null -eq $cloudflaredProcess) {
|
||||
Start-Process -WindowStyle Hidden -FilePath $Cloudflared `
|
||||
-ArgumentList @("tunnel", "--config", $CloudflaredConfig, "run") `
|
||||
-RedirectStandardOutput (Join-Path $Workspace "cloudflared.public.out.log") `
|
||||
-RedirectStandardError (Join-Path $Workspace "cloudflared.public.err.log") `
|
||||
-PassThru | Out-Null
|
||||
} else {
|
||||
Write-Output "Cloudflared already running; skipping tunnel restart"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "Engine gateway healthy on http://127.0.0.1:$EnginePort"
|
||||
Write-Output "Public API running on http://127.0.0.1:$ApiPort with PID $($proc.Id)"
|
||||
if ($null -ne $engineHealth -and $engineHealth.ok) {
|
||||
Write-Output "Engine gateway healthy on http://127.0.0.1:$EnginePort"
|
||||
} else {
|
||||
Write-Warning "Engine gateway degraded; admin/auth control plane remains available"
|
||||
}
|
||||
if ($null -ne $proc) {
|
||||
Write-Output "Public API running on http://127.0.0.1:$ApiPort with PID $($proc.Id)"
|
||||
} else {
|
||||
Write-Output "Public API kept running on http://127.0.0.1:$ApiPort"
|
||||
}
|
||||
if (!$SkipWebRestart) {
|
||||
Write-Output "Public vnet web preview running on http://127.0.0.1:$WebPort"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ $checks = @(
|
|||
(Test-JsonHealth `
|
||||
-Name "api" `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine }),
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db }),
|
||||
(Test-JsonHealth `
|
||||
-Name "web-preview" `
|
||||
-Uri "http://127.0.0.1:$WebPort/" `
|
||||
|
|
@ -122,13 +122,13 @@ if (!$SkipPublicHealth) {
|
|||
$checks += Test-JsonHealth `
|
||||
-Name "public-api" `
|
||||
-Uri $PublicHealthUrl `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
|
||||
-TimeoutSec 30
|
||||
foreach ($url in $AdditionalPublicHealthUrls) {
|
||||
$checks += Test-JsonHealth `
|
||||
-Name "public-api:$url" `
|
||||
-Uri $url `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
|
||||
-TimeoutSec 30
|
||||
}
|
||||
}
|
||||
|
|
@ -164,6 +164,9 @@ $startArgs = @{
|
|||
Cloudflared = $Cloudflared
|
||||
CloudflaredConfig = $CloudflaredConfig
|
||||
}
|
||||
if (($checks | Where-Object { $_.Name -eq "web-preview" }).Ok) {
|
||||
$startArgs["SkipWebRestart"] = $true
|
||||
}
|
||||
if ($SkipCloudflaredRestart) {
|
||||
$startArgs["SkipCloudflaredRestart"] = $true
|
||||
}
|
||||
|
|
@ -180,7 +183,7 @@ try {
|
|||
$apiAfter = Test-JsonHealth `
|
||||
-Name "api" `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
|
||||
-TimeoutSec 20
|
||||
if (!$apiAfter.Ok) {
|
||||
throw "Public API still unhealthy after restart: $($apiAfter.Detail)"
|
||||
|
|
@ -195,11 +198,20 @@ if (!$webAfter.Ok) {
|
|||
throw "Public web preview still unhealthy after restart: $($webAfter.Detail)"
|
||||
}
|
||||
|
||||
$engineAfter = Test-JsonHealth `
|
||||
-Name "engine" `
|
||||
-Uri "http://127.0.0.1:$EnginePort/health" `
|
||||
-IsHealthy { param($health) $health.ok -eq $true } `
|
||||
-TimeoutSec 20
|
||||
if (!$engineAfter.Ok) {
|
||||
throw "Engine gateway still unhealthy after isolated restart: $($engineAfter.Detail)"
|
||||
}
|
||||
|
||||
if (!$SkipPublicHealth) {
|
||||
$publicAfter = Test-JsonHealth `
|
||||
-Name "public-api" `
|
||||
-Uri $PublicHealthUrl `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
|
||||
-TimeoutSec 30
|
||||
if (!$publicAfter.Ok) {
|
||||
throw "Public API tunnel still unhealthy after restart: $($publicAfter.Detail)"
|
||||
|
|
@ -208,7 +220,7 @@ if (!$SkipPublicHealth) {
|
|||
$publicExtraAfter = Test-JsonHealth `
|
||||
-Name "public-api:$url" `
|
||||
-Uri $url `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
|
||||
-TimeoutSec 30
|
||||
if (!$publicExtraAfter.Ok) {
|
||||
throw "Public API tunnel still unhealthy after restart: $($publicExtraAfter.Detail)"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue