feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -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
}

View file

@ -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
View 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())

View 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"

View 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"

View file

@ -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"
}

View file

@ -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)"