vignette/scripts/start-public-runtime.ps1
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

373 lines
12 KiB
PowerShell

param(
[string]$Workspace = "D:\workspace\vignette",
[int]$ApiPort = 8001,
[int]$WebPort = 5174,
[int]$EnginePort = 9099,
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
[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",
[switch]$SkipCloudflaredRestart
)
$ErrorActionPreference = "Stop"
# 엔진 readiness 캐시 TTL. 기본 30초는 워치독 주기(5분)보다 짧아 매 헬스체크마다
# 실제 claude -p 생성을 새로 돌리게 만든다(재시작 폭풍의 근본 원인). 크게 늘려
# /ready 가 거의 항상 캐시를 반환하게 한다 → 헬스체크가 LLM 호출에 묶이지 않는다.
if (-not $env:ENGINE_READY_TTL_SECONDS) {
$env:ENGINE_READY_TTL_SECONDS = "1800"
}
$ApiDir = Join-Path $Workspace "apps\api"
$WebDir = Join-Path $Workspace "apps\web"
$OutLog = Join-Path $ApiDir "api.public.out.log"
$ErrLog = Join-Path $ApiDir "api.public.err.log"
$EngineOutLog = Join-Path $ApiDir "engine.public.out.log"
$EngineErrLog = Join-Path $ApiDir "engine.public.err.log"
$WebOutLog = Join-Path $Workspace "web.public.out.log"
$WebErrLog = Join-Path $Workspace "web.public.err.log"
function Get-JsonHealth {
param(
[string]$Uri,
[int]$TimeoutSec = 5
)
try {
Invoke-RestMethod -Uri $Uri -TimeoutSec $TimeoutSec
} catch {
$null
}
}
function Wait-JsonHealth {
param(
[string]$Uri,
[scriptblock]$IsHealthy,
[int]$TimeoutSec = 30
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$health = Get-JsonHealth -Uri $Uri -TimeoutSec 5
if ($null -ne $health -and (& $IsHealthy $health)) {
return $health
}
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $deadline)
throw "Timed out waiting for healthy response from $Uri"
}
function Test-EngineReady {
param(
[int]$Port,
[int]$TimeoutSec = 45
)
$headers = $null
if ($env:ENGINE_GATEWAY_SHARED_SECRET) {
$headers = @{ "X-Vignette-Engine-Token" = $env:ENGINE_GATEWAY_SHARED_SECRET }
}
try {
if ($headers) {
$response = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/ready" -TimeoutSec $TimeoutSec -Headers $headers
} else {
$response = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/ready" -TimeoutSec $TimeoutSec
}
return [bool]($response.ok -eq $true)
} catch {
return $false
}
}
function Wait-EngineReady {
param(
[int]$Port,
[int]$TimeoutSec = 90
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
if (Test-EngineReady -Port $Port) {
return $true
}
Start-Sleep -Seconds 3
} while ((Get-Date) -lt $deadline)
return $false
}
function Wait-HttpStatus {
param(
[string]$Uri,
[int]$TimeoutSec = 30
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 5
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) {
return $response
}
} catch {
Start-Sleep -Seconds 1
}
} while ((Get-Date) -lt $deadline)
throw "Timed out waiting for HTTP response from $Uri"
}
function Stop-UvicornByPort {
param(
[string]$AppImport,
[int]$Port
)
# Name 조건이 없으면 같은 문자열을 인자로 들고 있는 셸/래퍼 프로세스까지 매칭해
# 호출자 자신을 죽일 수 있다. 대상은 항상 python 프로세스다.
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -like "python*" -and
$_.CommandLine -and
$_.CommandLine -like "*uvicorn $AppImport*" -and
$_.CommandLine -like "*--port $Port*"
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
}
function Stop-NodeByPortHint {
param([int]$Port)
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "node.exe" -and
$_.CommandLine -and
$_.CommandLine -like "*vite*preview*" -and
$_.CommandLine -like "*$Port*"
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
}
function ConvertTo-CompactJson {
param([object]$Value)
ConvertTo-Json -InputObject $Value -Compress
}
function Set-CloudflaredIngress {
param(
[string]$ConfigPath,
[string[]]$ApiHostnames,
[string[]]$WebHostnames,
[int]$ApiPortValue,
[int]$WebPortValue
)
$lines = Get-Content -Encoding UTF8 -Path $ConfigPath
$ingressIndex = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match "^\s*ingress:\s*$") {
$ingressIndex = $i
break
}
}
if ($ingressIndex -lt 0) {
throw "Could not find ingress: in $ConfigPath"
}
$nextLines = @()
if ($ingressIndex -gt 0) {
$nextLines += $lines[0..($ingressIndex - 1)]
}
$nextLines += "ingress:"
foreach ($hostname in $WebHostnames) {
$nextLines += " - hostname: $hostname"
$nextLines += " service: http://127.0.0.1:$WebPortValue"
}
foreach ($hostname in $ApiHostnames) {
$nextLines += " - hostname: $hostname"
$nextLines += " service: http://127.0.0.1:$ApiPortValue"
}
$nextLines += " - service: http_status:404"
Set-Content -Encoding UTF8 -Path $ConfigPath -Value $nextLines
}
if (!(Test-Path $Python)) {
throw "Python 3.11 not found at $Python"
}
if (!(Test-Path $ApiDir)) {
throw "API directory not found at $ApiDir"
}
if (!(Test-Path $WebDir)) {
throw "Web directory not found at $WebDir"
}
# 재기동 판정은 /health(프로세스 liveness)가 아니라 /ready(실제 claude -p 생성)로 한다.
# 프로세스는 살아 있는데 그 프로세스의 claude 세션만 죽은 상태는 /health를 통과하므로,
# /health 기준으로는 복구가 필요한 순간에 오히려 재기동을 건너뛴다(2026-08-07 사고).
$engineHealth = Get-JsonHealth -Uri "http://127.0.0.1:$EnginePort/health"
$engineReady = $false
if ($null -ne $engineHealth -and $engineHealth.ok) {
$engineReady = Test-EngineReady -Port $EnginePort
}
if ($SkipEngineRestart) {
if (-not $engineReady) {
throw "Engine gateway is not ready on http://127.0.0.1:$EnginePort/ready"
}
} elseif (-not $engineReady) {
Stop-UvicornByPort -AppImport "engine_gateway.gateway:app" -Port $EnginePort
# Start-Process는 리다이렉트 대상 로그를 덮어쓴다. 직전 사고 로그를 보존해야
# 재기동 후에도 원인을 추적할 수 있다.
$rotateStamp = Get-Date -Format "yyyyMMdd-HHmmss"
foreach ($logFile in @($EngineOutLog, $EngineErrLog)) {
if (Test-Path $logFile) {
Move-Item -LiteralPath $logFile -Destination "$logFile.$rotateStamp.bak" -Force -ErrorAction SilentlyContinue
}
}
Start-Process -WindowStyle Hidden -FilePath $Python `
-ArgumentList @("-m", "uvicorn", "engine_gateway.gateway:app", "--host", "127.0.0.1", "--port", "$EnginePort") `
-WorkingDirectory $ApiDir `
-RedirectStandardOutput $EngineOutLog `
-RedirectStandardError $EngineErrLog `
-PassThru | Out-Null
$engineReady = Wait-EngineReady -Port $EnginePort -TimeoutSec 90
if (-not $engineReady) {
Write-Warning "Engine gateway is still degraded; continuing admin/auth recovery"
}
$engineHealth = Get-JsonHealth -Uri "http://127.0.0.1:$EnginePort/health"
}
$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"
$env:VIGNETTE_VOICE_POC_SAMPLE_TTS = "false"
$env:FRONTEND_BASE_URL = "https://vignette.chanpaca.net"
$frontendOrigins = @("https://vignette.chanpaca.net", "https://vnet.18ka.net", "https://vignette-b1q.pages.dev")
$localViteOrigins = @()
foreach ($port in 5170..5180) {
$localViteOrigins += "http://localhost:$port"
$localViteOrigins += "http://127.0.0.1:$port"
}
$env:CORS_ORIGINS = ConvertTo-CompactJson -Value ($frontendOrigins + $localViteOrigins)
$env:FRONTEND_ORIGIN_MAP = ConvertTo-CompactJson -Value ([ordered]@{
"api-vignette.chanpaca.net" = "https://vignette.chanpaca.net"
"api-vnet.18ka.net" = "https://vnet.18ka.net"
})
$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
$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) {
Stop-NodeByPortHint -Port $WebPort
$build = Start-Process -FilePath "cmd.exe" `
-ArgumentList @("/c", "npm run build") `
-WorkingDirectory $WebDir `
-NoNewWindow `
-Wait `
-PassThru
if ($build.ExitCode -ne 0) {
throw "Web build failed with exit code $($build.ExitCode)"
}
Start-Process -WindowStyle Hidden -FilePath "cmd.exe" `
-ArgumentList @("/c", "npm run preview -- --host 127.0.0.1 --port $WebPort") `
-WorkingDirectory $WebDir `
-RedirectStandardOutput $WebOutLog `
-RedirectStandardError $WebErrLog `
-PassThru | Out-Null
Wait-HttpStatus -Uri "http://127.0.0.1:$WebPort/" -TimeoutSec 30 | Out-Null
}
if (!$SkipCloudflaredRestart) {
if (!(Test-Path $Cloudflared)) {
throw "cloudflared not found at $Cloudflared"
}
if (!(Test-Path $CloudflaredConfig)) {
throw "cloudflared config not found at $CloudflaredConfig"
}
$apiHostnames = @("api-vignette.chanpaca.net", "api-vnet.18ka.net")
$webHostnames = @("vnet.18ka.net")
if ($RouteCloudflareDns) {
foreach ($hostname in ($webHostnames + $apiHostnames)) {
& $Cloudflared tunnel route dns $CloudflareTunnelName $hostname
if ($LASTEXITCODE -ne 0) {
Write-Warning "cloudflared DNS route failed for $hostname"
}
}
}
Set-CloudflaredIngress `
-ConfigPath $CloudflaredConfig `
-ApiHostnames $apiHostnames `
-WebHostnames $webHostnames `
-ApiPortValue $ApiPort `
-WebPortValue $WebPort
$cloudflaredProcess = Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq "cloudflared.exe" -and $_.CommandLine -like "*vignette-config.yml*" } |
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"
}
}
if ($engineReady) {
Write-Output "Engine gateway ready (real generation proven) 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"
}
Write-Output "Health: $($health | ConvertTo-Json -Compress)"