vignette/scripts/start-public-runtime.ps1
Yun Chan 6369f29439 CLI 자식 프로세스 환경에 Windows 필수 변수 백필
공개 런타임 승격 체인이 psutil로 이전 프로세스 환경을 통째로 이식하는 과정에서
SystemRoot 가 유실됐고, Go 계열 CLI(agy)는 시스템 인증서 풀/홈 해석에 SystemRoot 가
필요해 agy models 가 조용히 빈 목록을 반환했다(관리자 AI 운영 화면의
'Agy가 선택 가능한 모델을 반환하지 않았습니다' 두 번째 원인).
- _cli_subprocess_env(): 상속 환경에서 빠진 SystemRoot/SystemDrive/ComSpec 만
  기본값으로 백필해 모든 CLI 스폰(_run_process·codex app-server·agy stream·claude 세션)에 적용.
  os.environ 의 Windows 대문자 정규화를 고려한 대소문자 무시 조회
- Set-CompleteProcessEnvironment: 이식본에 빠진 Windows 필수 키를 Machine 스코프
  표준값으로 병합해 승격 체인 자체의 유실을 원천 보강
- 검증: 신규 2단위 RED→GREEN, engine_gateway 65 passed, app 924 passed,
  PS 5.1 parser OK, SystemRoot 제거 환경에서 실제 agy CLI 14모델 live 조회 확인
2026-08-18 13:25:08 +09:00

1736 lines
59 KiB
PowerShell

param(
[string]$Workspace = "D:\workspace\vignette",
[int]$ApiPort = 8001,
[int]$WebPort = 5174,
[int]$EnginePort = 9099,
[int]$WhisperPort = 9882,
[int]$MeloTtsPort = 9883,
[int]$VoiceSidecarReadySeconds = 300,
[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,
[string]$PublicHealthUrl = "https://api-vignette.chanpaca.net/health",
[switch]$RequireFreshPublicProvenance,
[string]$ExpectedSourceCommit = "",
[string]$ExpectedSourceTree = "",
[string]$ExpectedPythonSha256 = "",
[string]$ExpectedCloudflaredSha256 = "",
[string]$ExpectedCloudflaredConfigSha256 = "",
[string]$RuntimeProvenancePath = "",
[ValidateRange(1, 60)]
[int]$ProcessStopTimeoutSeconds = 15
)
$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"
$WhisperStartScript = Join-Path $Workspace "scripts\start-local-whisper-stt.ps1"
$MeloTtsStartScript = Join-Path $Workspace "scripts\start-melotts.ps1"
$VoiceSidecarProbe = Join-Path $Workspace "scripts\probe-public-voice-sidecars.py"
$WhisperModel = "small"
$WhisperLanguage = "ko"
$WhisperDevice = "cpu"
$MeloTtsModel = "melotts-korean"
$MeloTtsLanguage = "KR"
$CanonicalPublicHealthUrl = "https://api-vignette.chanpaca.net/health"
$CanonicalPublicVoiceHealthUrl = "https://api-vignette.chanpaca.net/voice/health"
$CanonicalPublicOpenApiUrl = "https://api-vignette.chanpaca.net/openapi.json"
$RequiredPublicApiPaths = @(
"/health",
"/voice/health",
"/voice/speech",
"/admin/voice-runtime"
)
$PublicApiHostnames = @("api-vignette.chanpaca.net", "api-vnet.18ka.net")
$PublicWebHostnames = @("vnet.18ka.net")
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-PortListener {
param([int]$Port)
$listener = Get-NetTCPConnection `
-State Listen `
-LocalPort $Port `
-ErrorAction SilentlyContinue `
| Select-Object -First 1
return $null -ne $listener
}
function Get-ListenerProcessIds {
param([int]$Port)
return @(
Get-NetTCPConnection `
-State Listen `
-LocalPort $Port `
-ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique
)
}
function Test-VoiceSidecarReady {
param(
[ValidateSet("stt", "tts")]
[string]$Component
)
$probeArgs = @(
"-X", "utf8", $VoiceSidecarProbe,
"--component", $Component,
"--stt-url", "ws://127.0.0.1:$WhisperPort/v1/listen",
"--stt-provider", "local_whisper",
"--stt-model", $WhisperModel,
"--stt-language", $WhisperLanguage,
"--stt-device", $WhisperDevice,
"--tts-url", "http://127.0.0.1:$MeloTtsPort",
"--tts-provider", "melotts",
"--tts-model", $MeloTtsModel,
"--tts-language", $MeloTtsLanguage,
"--timeout-seconds", "5"
)
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
& $Python @probeArgs 1>$null 2>$null
$probeExit = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
return $probeExit -eq 0
}
function Wait-VoiceSidecarReady {
param(
[ValidateSet("stt", "tts")]
[string]$Component,
[int]$TimeoutSec
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
if (Test-VoiceSidecarReady -Component $Component) {
return $true
}
Start-Sleep -Seconds 2
} while ((Get-Date) -lt $deadline)
return $false
}
function Test-VoiceApiReady {
param([object]$Health)
return (
$null -ne $Health -and
$Health.status -eq "ok" -and
$Health.available -eq $true -and
$Health.stt_available -eq $true -and
$Health.tts_available -eq $true -and
$Health.stt_provider -eq "local_whisper" -and
$Health.stt_model -eq $WhisperModel -and
$Health.tts_provider -eq "melotts" -and
$Health.tts_model -eq $MeloTtsModel -and
$Health.limits.uvicorn_ws_max_queue -eq 4
)
}
function Test-PriorVoiceApiReady {
param([object]$Health)
return (
$null -ne $Health -and
$Health.status -eq "ok" -and
$Health.available -eq $true -and
$Health.stt_available -eq $true -and
$Health.tts_available -eq $true -and
-not [string]::IsNullOrWhiteSpace([string]$Health.stt_provider) -and
-not [string]::IsNullOrWhiteSpace([string]$Health.stt_model) -and
-not [string]::IsNullOrWhiteSpace([string]$Health.tts_provider) -and
-not [string]::IsNullOrWhiteSpace([string]$Health.tts_model)
)
}
function ConvertTo-SafeVoiceHealthContract {
param([object]$Health)
return [ordered]@{
status = [string]$Health.status
available = [bool]$Health.available
stt_available = [bool]$Health.stt_available
tts_available = [bool]$Health.tts_available
stt_provider = [string]$Health.stt_provider
stt_model = [string]$Health.stt_model
tts_provider = [string]$Health.tts_provider
tts_model = [string]$Health.tts_model
uvicorn_ws_max_queue = [int]$Health.limits.uvicorn_ws_max_queue
}
}
function Test-VoiceHealthContract {
param(
[object]$Health,
[System.Collections.IDictionary]$Expected
)
if (-not (Test-PriorVoiceApiReady -Health $Health)) {
return $false
}
$actual = ConvertTo-SafeVoiceHealthContract -Health $Health
foreach ($name in $Expected.Keys) {
if ($actual[$name].ToString() -cne $Expected[$name].ToString()) {
return $false
}
}
return $true
}
function Test-RequiredOpenApiPaths {
param(
[object]$Document,
[string[]]$RequiredPaths
)
if ($null -eq $Document -or $null -eq $Document.paths) {
return $false
}
$actualPaths = @($Document.paths.PSObject.Properties.Name)
foreach ($requiredPath in $RequiredPaths) {
if ($actualPaths -notcontains $requiredPath) {
return $false
}
}
return $true
}
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-ProcessesBounded {
param(
[object[]]$Processes,
[int]$TimeoutSec,
[string]$Role
)
$processIds = @(
$Processes |
ForEach-Object { [int]$_.ProcessId } |
Sort-Object -Unique
)
foreach ($processId in $processIds) {
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
}
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$remaining = @(
$processIds |
Where-Object { $null -ne (Get-Process -Id $_ -ErrorAction SilentlyContinue) }
)
if ($remaining.Count -eq 0) {
return $processIds
}
Start-Sleep -Milliseconds 200
} while ((Get-Date) -lt $deadline)
throw "Timed out stopping $Role process IDs: $($remaining -join ',')"
}
function Get-UvicornProcessesByPort {
param(
[string]$AppImport,
[int]$Port
)
# Name 조건이 없으면 같은 문자열을 인자로 들고 있는 셸/래퍼 프로세스까지 매칭해
# 호출자 자신을 죽일 수 있다. 대상은 항상 python 프로세스다.
return @(
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -like "python*" -and
$_.CommandLine -and
$_.CommandLine -like "*uvicorn $AppImport*" -and
$_.CommandLine -like "*--port $Port*"
}
)
}
function Stop-UvicornByPort {
param(
[string]$AppImport,
[int]$Port,
[int]$TimeoutSec = 15
)
$processes = @(Get-UvicornProcessesByPort -AppImport $AppImport -Port $Port)
return @(
Stop-ProcessesBounded `
-Processes $processes `
-TimeoutSec $TimeoutSec `
-Role "uvicorn $AppImport on port $Port"
)
}
function Get-CloudflaredProcessesForConfig {
param(
[string]$ConfigPath,
[switch]$ExactPath
)
$configLeaf = Split-Path -Leaf $ConfigPath
return @(
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "cloudflared.exe" -and
$_.CommandLine -and
$_.CommandLine -like "*--config*" -and
(
$_.CommandLine.IndexOf($ConfigPath, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -or
(-not $ExactPath -and $_.CommandLine -like "*$configLeaf*")
)
}
)
}
function Wait-ProcessIdentity {
param(
[int]$ProcessId,
[string]$Role,
[string]$ExpectedCwd = "",
[int]$TimeoutSec = 15
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$process = Get-CimInstance Win32_Process `
-Filter "ProcessId = $ProcessId" `
-ErrorAction SilentlyContinue
if (
$null -ne $process -and
$process.ExecutablePath -and
$process.CommandLine
) {
$identityProbeArgs = @(
"-X", "utf8", "-c",
"import hashlib,json,psutil,sys; from datetime import UTC,datetime; p=psutil.Process(int(sys.argv[1])); argv=p.cmdline(); print(p.cwd()); print(datetime.fromtimestamp(p.create_time(), UTC).isoformat().replace('+00:00', 'Z')); print(hashlib.sha256(chr(0).join(argv).encode('utf-8', errors='strict')).hexdigest()); print(json.dumps(argv[1:], ensure_ascii=True, separators=(',', ':'))); print(json.dumps(p.environ(), ensure_ascii=True, separators=(',', ':')))",
"$ProcessId"
)
$identityProbe = @(& $Python @identityProbeArgs)
if ($LASTEXITCODE -ne 0 -or $identityProbe.Count -ne 5) {
throw "Could not prove $Role psutil identity for PID $ProcessId"
}
$actualCwd = $identityProbe[0].Trim()
$startedAtUtc = $identityProbe[1].Trim()
$commandLineSha256 = $identityProbe[2].Trim().ToLowerInvariant()
$argumentList = @($identityProbe[3] | ConvertFrom-Json)
$environmentObject = $identityProbe[4] | ConvertFrom-Json
$processEnvironment = [ordered]@{}
foreach ($property in $environmentObject.PSObject.Properties) {
$processEnvironment[$property.Name] = [string]$property.Value
}
if (-not $actualCwd -or $startedAtUtc -notmatch "Z$" -or $commandLineSha256 -notmatch "^[0-9a-f]{64}$") {
throw "$Role psutil identity is incomplete for PID $ProcessId"
}
if ($ExpectedCwd -and -not [string]::Equals(
[System.IO.Path]::GetFullPath($actualCwd),
[System.IO.Path]::GetFullPath($ExpectedCwd),
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "$Role working directory drift: expected=$ExpectedCwd actual=$actualCwd"
}
return [ordered]@{
role = $Role
pid = [int]$process.ProcessId
started_at_utc = $startedAtUtc
executable_path = $process.ExecutablePath
executable_name = Split-Path -Leaf $process.ExecutablePath
executable_sha256 = (Get-FileHash -LiteralPath $process.ExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant()
command_line = $process.CommandLine
command_line_sha256 = $commandLineSha256
argument_list = $argumentList
environment = $processEnvironment
cwd = $actualCwd
}
}
Start-Sleep -Milliseconds 200
} while ((Get-Date) -lt $deadline)
throw "Timed out reading $Role process identity for PID $ProcessId"
}
function ConvertTo-SafeProcessIdentity {
param([System.Collections.IDictionary]$Identity)
# Raw command line이나 executable full path는 config/token을 우발적으로
# 영구 보존할 수 있다. topology 결속에 필요한 비밀 비포함 투영만 기록한다.
return [ordered]@{
pid = [int]$Identity.pid
started_at_utc = $Identity.started_at_utc
executable_name = $Identity.executable_name
executable_sha256 = $Identity.executable_sha256
command_line_sha256 = $Identity.command_line_sha256
cwd = $Identity.cwd
}
}
function Save-ManagedEnvironment {
param([string[]]$Names)
$snapshot = [ordered]@{}
foreach ($name in $Names) {
$value = [System.Environment]::GetEnvironmentVariable(
$name,
[System.EnvironmentVariableTarget]::Process
)
$snapshot[$name] = [ordered]@{
present = $null -ne $value
value = $value
}
}
return $snapshot
}
function Restore-ManagedEnvironment {
param([System.Collections.IDictionary]$Snapshot)
foreach ($name in $Snapshot.Keys) {
$entry = $Snapshot[$name]
if ($entry.present) {
[System.Environment]::SetEnvironmentVariable(
$name,
[string]$entry.value,
[System.EnvironmentVariableTarget]::Process
)
} else {
[System.Environment]::SetEnvironmentVariable(
$name,
$null,
[System.EnvironmentVariableTarget]::Process
)
}
}
}
function Save-CompleteProcessEnvironment {
$snapshot = [ordered]@{}
$environment = [System.Environment]::GetEnvironmentVariables(
[System.EnvironmentVariableTarget]::Process
)
foreach ($name in $environment.Keys) {
$snapshot[[string]$name] = [string]$environment[$name]
}
return $snapshot
}
function Set-CompleteProcessEnvironment {
param([System.Collections.IDictionary]$Environment)
# psutil 환경 캡색에서 SystemRoot 같은 Windows 필수 변수가 유실되면 Go 계열 CLI(agy)가
# 시스템 인증서 풀/홈 해석에 실패해 조용히 빈 결과를 낸다(2026-08-18 실측). 이식본에
# 빠진 필수 키는 Machine 스코프 표준값으로 되살린다.
$windowsEssentials = @('SystemRoot', 'windir', 'SystemDrive', 'ComSpec')
foreach ($name in $windowsEssentials) {
$missing = -not $Environment.Contains($name) -or [string]::IsNullOrWhiteSpace([string]$Environment[$name])
if ($missing) {
$machineValue = [System.Environment]::GetEnvironmentVariable($name, 'Machine')
if ($machineValue) {
$Environment[$name] = $machineValue
}
}
}
$current = [System.Environment]::GetEnvironmentVariables(
[System.EnvironmentVariableTarget]::Process
)
foreach ($name in @($current.Keys)) {
[System.Environment]::SetEnvironmentVariable(
[string]$name,
$null,
[System.EnvironmentVariableTarget]::Process
)
}
foreach ($name in $Environment.Keys) {
[System.Environment]::SetEnvironmentVariable(
[string]$name,
[string]$Environment[$name],
[System.EnvironmentVariableTarget]::Process
)
}
}
function ConvertTo-WindowsCommandLineArgument {
param([AllowEmptyString()][string]$Argument)
if ($Argument.Length -gt 0 -and $Argument -notmatch '[\s"]') {
return $Argument
}
$builder = New-Object System.Text.StringBuilder
$null = $builder.Append('"')
$backslashes = 0
foreach ($character in $Argument.ToCharArray()) {
if ($character -eq '\') {
$backslashes++
continue
}
if ($character -eq '"') {
$null = $builder.Append(('\' * (($backslashes * 2) + 1)))
$null = $builder.Append('"')
$backslashes = 0
continue
}
if ($backslashes -gt 0) {
$null = $builder.Append(('\' * $backslashes))
$backslashes = 0
}
$null = $builder.Append($character)
}
if ($backslashes -gt 0) {
$null = $builder.Append(('\' * ($backslashes * 2)))
}
$null = $builder.Append('"')
return $builder.ToString()
}
function Join-WindowsArgumentList {
param([object[]]$ArgumentList)
return (@(
foreach ($argument in $ArgumentList) {
ConvertTo-WindowsCommandLineArgument -Argument ([string]$argument)
}
) -join ' ')
}
function Start-PinnedPriorProcess {
param(
[System.Collections.IDictionary]$Identity,
[string]$Role,
[string]$StdoutLog,
[string]$StderrLog
)
if (-not (Test-Path -LiteralPath $Identity.executable_path -PathType Leaf)) {
throw "Prior $Role executable is unavailable"
}
$actualExecutableSha256 = (
Get-FileHash -LiteralPath $Identity.executable_path -Algorithm SHA256
).Hash.ToLowerInvariant()
if ($actualExecutableSha256 -ne $Identity.executable_sha256) {
throw "Prior $Role executable SHA256 drift"
}
if (-not (Test-Path -LiteralPath $Identity.cwd -PathType Container)) {
throw "Prior $Role working directory is unavailable"
}
if (@($Identity.argument_list).Count -eq 0) {
throw "Prior $Role argument list is unavailable"
}
if ($null -eq $Identity.environment -or $Identity.environment.Count -eq 0) {
throw "Prior $Role environment is unavailable"
}
$callerEnvironment = Save-CompleteProcessEnvironment
try {
Set-CompleteProcessEnvironment -Environment $Identity.environment
$argumentString = Join-WindowsArgumentList -ArgumentList @($Identity.argument_list)
return Start-Process -WindowStyle Hidden `
-FilePath $Identity.executable_path `
-ArgumentList $argumentString `
-WorkingDirectory $Identity.cwd `
-RedirectStandardOutput $StdoutLog `
-RedirectStandardError $StderrLog `
-PassThru
} finally {
Set-CompleteProcessEnvironment -Environment $callerEnvironment
}
}
function Restore-PriorPublicRuntime {
param(
[System.Collections.IDictionary]$PriorApi,
[System.Collections.IDictionary]$PriorCloudflared,
[System.Collections.IDictionary]$PriorLocalVoiceContract,
[System.Collections.IDictionary]$PriorPublicVoiceContract,
[System.Collections.IDictionary]$EnvironmentSnapshot,
[string]$ConfigPath,
[int]$ApiPortValue,
[string]$HealthUrl,
[string]$VoiceHealthUrl,
[int]$TimeoutSec
)
$null = @(
Stop-UvicornByPort `
-AppImport "app.main:app" `
-Port $ApiPortValue `
-TimeoutSec $TimeoutSec
)
Restore-ManagedEnvironment -Snapshot $EnvironmentSnapshot
$priorApiProcess = Start-PinnedPriorProcess `
-Identity $PriorApi `
-Role "api" `
-StdoutLog (Join-Path $PriorApi.cwd "api.public.rollback.out.log") `
-StderrLog (Join-Path $PriorApi.cwd "api.public.rollback.err.log")
$priorApiIdentity = Wait-ProcessIdentity `
-ProcessId $priorApiProcess.Id `
-Role "restored prior api" `
-ExpectedCwd $PriorApi.cwd `
-TimeoutSec $TimeoutSec
foreach ($field in @("executable_sha256", "command_line_sha256", "cwd")) {
if ($PriorApi[$field].ToString() -cne $priorApiIdentity[$field].ToString()) {
throw "Restored prior API identity drift: $field"
}
}
$null = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPortValue/health" `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true
} `
-TimeoutSec 60
$null = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPortValue/voice/health" `
-IsHealthy {
param($health)
Test-VoiceHealthContract -Health $health -Expected $PriorLocalVoiceContract
} `
-TimeoutSec 30
if (-not (Test-VoiceSidecarReady -Component "stt") -or -not (Test-VoiceSidecarReady -Component "tts")) {
throw "Restored prior runtime does not have the exact local voice sidecars"
}
$resolvedConfigPath = (Resolve-Path -LiteralPath $ConfigPath).Path
$currentCloudflared = @(
Get-CloudflaredProcessesForConfig `
-ConfigPath $resolvedConfigPath `
-ExactPath
)
$null = @(
Stop-ProcessesBounded `
-Processes $currentCloudflared `
-TimeoutSec $TimeoutSec `
-Role "failed fresh cloudflared"
)
$priorCloudflaredProcess = Start-PinnedPriorProcess `
-Identity $PriorCloudflared `
-Role "cloudflared" `
-StdoutLog (Join-Path $PriorCloudflared.cwd "cloudflared.public.rollback.out.log") `
-StderrLog (Join-Path $PriorCloudflared.cwd "cloudflared.public.rollback.err.log")
$priorCloudflaredIdentity = Wait-ProcessIdentity `
-ProcessId $priorCloudflaredProcess.Id `
-Role "restored prior cloudflared" `
-ExpectedCwd $PriorCloudflared.cwd `
-TimeoutSec $TimeoutSec
foreach ($field in @("executable_sha256", "command_line_sha256", "cwd")) {
if ($PriorCloudflared[$field].ToString() -cne $priorCloudflaredIdentity[$field].ToString()) {
throw "Restored prior cloudflared identity drift: $field"
}
}
$null = Wait-JsonHealth `
-Uri $HealthUrl `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true
} `
-TimeoutSec 60
$null = Wait-JsonHealth `
-Uri $VoiceHealthUrl `
-IsHealthy {
param($health)
Test-VoiceHealthContract -Health $health -Expected $PriorPublicVoiceContract
} `
-TimeoutSec 30
return [ordered]@{
api = ConvertTo-SafeProcessIdentity -Identity $priorApiIdentity
cloudflared = ConvertTo-SafeProcessIdentity -Identity $priorCloudflaredIdentity
local_health = $true
local_voice_health = $true
public_health = $true
public_voice_health = $true
}
}
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 Invoke-StableGitText {
param(
[string]$SourceRoot,
[string[]]$Arguments
)
$value = & git.exe -C $SourceRoot @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
}
return (@($value) -join [Environment]::NewLine).Trim()
}
function Initialize-RuntimeProvenanceOutput {
param([string]$OutputPath)
if (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
throw "Fresh public provenance output path must be absolute"
}
try {
$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path -Parent $resolvedOutputPath
if (-not $outputDirectory) {
throw "Fresh public provenance output path has no parent directory"
}
if (Test-Path -LiteralPath $resolvedOutputPath -PathType Container) {
throw "Fresh public provenance output path is a directory: $resolvedOutputPath"
}
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
throw "Fresh public provenance output directory is unavailable: $outputDirectory"
}
# 기존 receipt가 잠겨 있거나 read-only라면 프로세스 교체 전에 실패해야 한다.
# sibling probe 두 개를 atomic replace해 디렉터리의 create/flush/replace/delete
# 권한도 미리 검증한다. 실제 receipt 내용은 이 단계에서 건드리지 않는다.
if (Test-Path -LiteralPath $resolvedOutputPath -PathType Leaf) {
$attributes = [System.IO.File]::GetAttributes($resolvedOutputPath)
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
throw "Fresh public provenance output is read-only: $resolvedOutputPath"
}
$existingStream = [System.IO.File]::Open(
$resolvedOutputPath,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::Read
)
$existingStream.Dispose()
}
$probeId = [Guid]::NewGuid().ToString("N")
$probeSource = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.source.tmp"
$probeTarget = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.target.tmp"
$probeBackup = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.backup.tmp"
try {
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($probeSource, "probe-source", $encoding)
[System.IO.File]::WriteAllText($probeTarget, "probe-target", $encoding)
[System.IO.File]::Replace($probeSource, $probeTarget, $probeBackup)
[System.IO.File]::Delete($probeTarget)
[System.IO.File]::Delete($probeBackup)
} finally {
foreach ($probePath in @($probeSource, $probeTarget, $probeBackup)) {
if ($probePath -and [System.IO.File]::Exists($probePath)) {
[System.IO.File]::Delete($probePath)
}
}
}
} catch {
throw "Fresh public provenance output preflight failed before runtime mutation: $($_.Exception.Message)"
}
return $resolvedOutputPath
}
function Write-Utf8TextAtomically {
param(
[string]$OutputPath,
[string]$Value
)
$outputDirectory = Split-Path -Parent $OutputPath
$temporaryPath = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($OutputPath)).$([Guid]::NewGuid().ToString('N')).tmp"
$backupPath = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($OutputPath)).$([Guid]::NewGuid().ToString('N')).backup.tmp"
$published = $false
try {
$encoding = [System.Text.UTF8Encoding]::new($false)
$bytes = $encoding.GetBytes($Value)
$stream = [System.IO.FileStream]::new(
$temporaryPath,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
} finally {
$stream.Dispose()
}
if ([System.IO.File]::Exists($OutputPath)) {
[System.IO.File]::Replace($temporaryPath, $OutputPath, $backupPath)
} elseif (Test-Path -LiteralPath $OutputPath) {
throw "Fresh public provenance output became a non-file before commit: $OutputPath"
} else {
[System.IO.File]::Move($temporaryPath, $OutputPath)
}
$published = $true
} finally {
if ([System.IO.File]::Exists($temporaryPath)) {
[System.IO.File]::Delete($temporaryPath)
}
if ([System.IO.File]::Exists($backupPath)) {
try {
[System.IO.File]::Delete($backupPath)
} catch {
if ($published) {
Write-Warning "Atomic provenance receipt was published, but its temporary backup could not be removed: $backupPath"
} else {
throw
}
}
}
}
}
function Write-FailedFreshPromotionEvidence {
param(
[string]$OutputPath,
[string]$FailureStage,
[bool]$RollbackSucceeded,
[object]$RollbackResult,
[string]$SourceCommit,
[string]$SourceTree
)
# Default receipt는 stable detached root의 *.log 경계에 놓일 수 있다. 실패 증거도
# 최종 suffix를 .log로 유지해야 rollback 직후 source-clean provenance를 깨지 않는다.
$failedPath = "$OutputPath.failed.log"
$payload = [ordered]@{
schema_version = "vignette.public-runtime-launch-failure.v1"
status = if ($RollbackSucceeded) { "failed_rolled_back" } else { "failed_rollback" }
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
failure_stage = $FailureStage
source = [ordered]@{
git_commit = $SourceCommit.ToLowerInvariant()
git_tree = $SourceTree.ToLowerInvariant()
}
rollback = [ordered]@{
attempted = $true
succeeded = $RollbackSucceeded
result = $RollbackResult
}
}
$json = ConvertTo-Json -InputObject $payload -Depth 8
Write-Utf8TextAtomically -OutputPath $failedPath -Value ($json + [Environment]::NewLine)
return $failedPath
}
function Assert-FreshPublicProvenanceContract {
param(
[string]$SourceRoot,
[string]$SourceCommit,
[string]$SourceTree,
[string]$PythonPath,
[string]$PythonSha256,
[string]$CloudflaredPath,
[string]$CloudflaredSha256,
[string]$ConfigPath,
[string]$ConfigSha256
)
if (-not $ForceApiRestart) {
throw "-RequireFreshPublicProvenance requires -ForceApiRestart"
}
if ($SkipCloudflaredRestart) {
throw "-RequireFreshPublicProvenance forbids -SkipCloudflaredRestart"
}
if (-not $SkipEngineRestart) {
throw "-RequireFreshPublicProvenance requires -SkipEngineRestart; engine is an unchanged precondition"
}
if (-not $SkipWebRestart) {
throw "-RequireFreshPublicProvenance requires -SkipWebRestart; web preview is outside the API/tunnel transaction"
}
if ($RouteCloudflareDns) {
throw "-RequireFreshPublicProvenance forbids DNS route mutation"
}
if (-not [string]::Equals(
$PublicHealthUrl,
$CanonicalPublicHealthUrl,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Fresh public promotion requires the canonical HTTPS public health URL"
}
foreach ($sourcePin in @($SourceCommit, $SourceTree)) {
if ($sourcePin -notmatch "^[0-9a-fA-F]{40}$") {
throw "Fresh public provenance requires exact source commit and tree pins"
}
}
foreach ($shaPin in @($PythonSha256, $CloudflaredSha256, $ConfigSha256)) {
if ($shaPin -notmatch "^[0-9a-fA-F]{64}$") {
throw "Fresh public provenance requires exact Python, cloudflared, and config SHA256 pins"
}
}
$resolvedSourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path
$expectedStartScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
$runningStartScript = (Resolve-Path -LiteralPath $PSCommandPath).Path
if (-not [string]::Equals(
$runningStartScript,
(Resolve-Path -LiteralPath $expectedStartScript).Path,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Fresh public promotion must execute the launcher from the pinned stable source root"
}
$gitRoot = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--show-toplevel")
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
if (-not [string]::Equals(
$resolvedGitRoot,
$resolvedSourceRoot,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Fresh public promotion source root does not match its Git toplevel"
}
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
$symbolicHeadExit = $LASTEXITCODE
if ($symbolicHeadExit -eq 0) {
throw "Fresh public promotion requires detached HEAD, not branch $symbolicHead"
}
if ($symbolicHeadExit -ne 1) {
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
}
$actualCommit = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--verify", "HEAD")
$actualTree = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
if ($actualCommit -ne $SourceCommit.ToLowerInvariant()) {
throw "Fresh public source commit drift: expected=$SourceCommit actual=$actualCommit"
}
if ($actualTree -ne $SourceTree.ToLowerInvariant()) {
throw "Fresh public source tree drift: expected=$SourceTree actual=$actualTree"
}
$dirty = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
if ($dirty) {
throw "Fresh public promotion requires a clean stable source"
}
foreach ($pin in @(
[pscustomobject]@{ Path = $PythonPath; Sha256 = $PythonSha256; Label = "Python" },
[pscustomobject]@{ Path = $CloudflaredPath; Sha256 = $CloudflaredSha256; Label = "cloudflared" },
[pscustomobject]@{ Path = $ConfigPath; Sha256 = $ConfigSha256; Label = "cloudflared config" }
)) {
if (-not (Test-Path -LiteralPath $pin.Path -PathType Leaf)) {
throw "Pinned $($pin.Label) file not found at $($pin.Path)"
}
$actualSha256 = (Get-FileHash -LiteralPath $pin.Path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -ne $pin.Sha256.ToLowerInvariant()) {
throw "Pinned $($pin.Label) SHA256 drift"
}
}
}
function Set-CloudflaredIngress {
param(
[string]$ConfigPath,
[string[]]$ApiHostnames,
[string[]]$WebHostnames,
[int]$ApiPortValue,
[int]$WebPortValue,
[switch]$RequireUnchanged
)
$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"
$matches = $lines.Count -eq $nextLines.Count
if ($matches) {
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -cne $nextLines[$i]) {
$matches = $false
break
}
}
}
if ($RequireUnchanged -and -not $matches) {
throw "Pinned cloudflared config ingress does not match the requested public topology"
}
if (-not $matches) {
Set-Content -Encoding UTF8 -Path $ConfigPath -Value $nextLines
}
}
$freshMutationStarted = $false
$freshPromotionCommitted = $false
$freshFailureStage = "preflight"
$freshPriorApiIdentity = $null
$freshPriorCloudflaredIdentity = $null
$freshPriorLocalVoiceContract = $null
$freshPriorPublicVoiceContract = $null
$freshEnvironmentSnapshot = $null
$resolvedRuntimeProvenancePath = $null
$freshManagedEnvironmentNames = @(
"ENVIRONMENT",
"ENGINE_URL",
"ENGINE_MODE",
"VIGNETTE_LIVE_CLIENT_PROVIDER",
"AUTH_DEV_LOGIN_ENABLED",
"AUTO_SEED_PERSONAS",
"ALLOW_SEED_PERSONA_FALLBACK",
"VIGNETTE_VOICE_POC_SAMPLE_TTS",
"VIGNETTE_VOICE_STT_PROVIDER",
"VIGNETTE_LOCAL_WHISPER_STT_URL",
"VIGNETTE_LOCAL_WHISPER_STT_MODEL",
"VIGNETTE_LOCAL_WHISPER_STT_LANGUAGE",
"VIGNETTE_VOICE_TTS_PROVIDER",
"VIGNETTE_MELOTTS_TTS_URL",
"FRONTEND_BASE_URL",
"CORS_ORIGINS",
"FRONTEND_ORIGIN_MAP"
)
trap {
$caught = $_
if (
$RequireFreshPublicProvenance -and
$freshMutationStarted -and
-not $freshPromotionCommitted
) {
$rollbackResult = $null
$rollbackSucceeded = $false
$rollbackFailureType = "none"
try {
$rollbackResult = Restore-PriorPublicRuntime `
-PriorApi $freshPriorApiIdentity `
-PriorCloudflared $freshPriorCloudflaredIdentity `
-PriorLocalVoiceContract $freshPriorLocalVoiceContract `
-PriorPublicVoiceContract $freshPriorPublicVoiceContract `
-EnvironmentSnapshot $freshEnvironmentSnapshot `
-ConfigPath $CloudflaredConfig `
-ApiPortValue $ApiPort `
-HealthUrl $PublicHealthUrl `
-VoiceHealthUrl $CanonicalPublicVoiceHealthUrl `
-TimeoutSec $ProcessStopTimeoutSeconds
$rollbackSucceeded = $true
} catch {
$rollbackFailureType = $_.Exception.GetType().Name
}
$failedEvidencePath = ""
if ($resolvedRuntimeProvenancePath) {
try {
$failedEvidencePath = Write-FailedFreshPromotionEvidence `
-OutputPath $resolvedRuntimeProvenancePath `
-FailureStage $freshFailureStage `
-RollbackSucceeded $rollbackSucceeded `
-RollbackResult $rollbackResult `
-SourceCommit $ExpectedSourceCommit `
-SourceTree $ExpectedSourceTree
} catch {
$failedEvidencePath = "unavailable"
}
}
if ($rollbackSucceeded) {
throw "Fresh public promotion failed at $freshFailureStage; the pinned prior API and tunnel were restored. failure_evidence=$failedEvidencePath cause=$($caught.Exception.Message)"
}
throw "Fresh public promotion failed at $freshFailureStage and prior-runtime rollback failed closed ($rollbackFailureType). failure_evidence=$failedEvidencePath cause=$($caught.Exception.Message)"
}
throw $caught
}
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"
}
foreach ($voiceScript in @($WhisperStartScript, $MeloTtsStartScript, $VoiceSidecarProbe)) {
if (!(Test-Path -LiteralPath $voiceScript)) {
throw "Voice sidecar prerequisite not found at $voiceScript"
}
}
if ($RequireFreshPublicProvenance) {
if (!(Test-Path -LiteralPath $Cloudflared -PathType Leaf)) {
throw "cloudflared not found at $Cloudflared"
}
if (!(Test-Path -LiteralPath $CloudflaredConfig -PathType Leaf)) {
throw "cloudflared config not found at $CloudflaredConfig"
}
if (-not $RuntimeProvenancePath) {
$RuntimeProvenancePath = Join-Path $Workspace "public-runtime-launch-provenance.log"
}
Assert-FreshPublicProvenanceContract `
-SourceRoot $Workspace `
-SourceCommit $ExpectedSourceCommit `
-SourceTree $ExpectedSourceTree `
-PythonPath $Python `
-PythonSha256 $ExpectedPythonSha256 `
-CloudflaredPath $Cloudflared `
-CloudflaredSha256 $ExpectedCloudflaredSha256 `
-ConfigPath $CloudflaredConfig `
-ConfigSha256 $ExpectedCloudflaredConfigSha256
$resolvedRuntimeProvenancePath = Initialize-RuntimeProvenanceOutput `
-OutputPath $RuntimeProvenancePath
# 승격 모드에서 config를 재작성하면 사전 pin과 실제 tunnel 입력이 달라진다.
# exact ingress가 이미 들어 있는 경우에만 이후 프로세스 mutation으로 진행한다.
Set-CloudflaredIngress `
-ConfigPath $CloudflaredConfig `
-ApiHostnames $PublicApiHostnames `
-WebHostnames $PublicWebHostnames `
-ApiPortValue $ApiPort `
-WebPortValue $WebPort `
-RequireUnchanged
$priorApiProcesses = @(
Get-UvicornProcessesByPort -AppImport "app.main:app" -Port $ApiPort
)
if ($priorApiProcesses.Count -ne 1) {
throw "Fresh public promotion requires exactly one prior API process for transactional rollback"
}
$priorCloudflaredProcesses = @(
Get-CloudflaredProcessesForConfig `
-ConfigPath (Resolve-Path -LiteralPath $CloudflaredConfig).Path `
-ExactPath
)
if ($priorCloudflaredProcesses.Count -ne 1) {
throw "Fresh public promotion requires exactly one prior cloudflared process for transactional rollback"
}
$freshPriorApiIdentity = Wait-ProcessIdentity `
-ProcessId $priorApiProcesses[0].ProcessId `
-Role "prior api" `
-TimeoutSec $ProcessStopTimeoutSeconds
$freshPriorCloudflaredIdentity = Wait-ProcessIdentity `
-ProcessId $priorCloudflaredProcesses[0].ProcessId `
-Role "prior cloudflared" `
-TimeoutSec $ProcessStopTimeoutSeconds
$null = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPort/health" `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true
} `
-TimeoutSec 30
$priorLocalVoiceHealth = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPort/voice/health" `
-IsHealthy { param($health) Test-PriorVoiceApiReady -Health $health } `
-TimeoutSec 30
$freshPriorLocalVoiceContract = ConvertTo-SafeVoiceHealthContract `
-Health $priorLocalVoiceHealth
if (-not (Test-VoiceSidecarReady -Component "stt") -or -not (Test-VoiceSidecarReady -Component "tts")) {
throw "Fresh public promotion requires exact healthy voice sidecars as an unchanged precondition"
}
$null = Wait-JsonHealth `
-Uri $CanonicalPublicHealthUrl `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true
} `
-TimeoutSec 30
$priorPublicVoiceHealth = Wait-JsonHealth `
-Uri $CanonicalPublicVoiceHealthUrl `
-IsHealthy { param($health) Test-PriorVoiceApiReady -Health $health } `
-TimeoutSec 30
$freshPriorPublicVoiceContract = ConvertTo-SafeVoiceHealthContract `
-Health $priorPublicVoiceHealth
$freshEnvironmentSnapshot = Save-ManagedEnvironment `
-Names $freshManagedEnvironmentNames
}
# 재기동 판정은 /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) {
$null = @(
Stop-UvicornByPort `
-AppImport "engine_gateway.gateway:app" `
-Port $EnginePort `
-TimeoutSec $ProcessStopTimeoutSeconds
)
# Start-Process는 리다이렉트 대상 로그를 덮어쓴다. 직전 사고 로그를 보존해야
# 재기동 후에도 원인을 추적할 수 있다.
$rotateStamp = Get-Date -Format "yyyyMMdd-HHmmss"
foreach ($logFile in @($EngineOutLog, $EngineErrLog)) {
if (Test-Path $logFile) {
# stable detached source의 provenance gate는 untracked 파일도 차단한다.
# suffix를 .log로 유지해 회전 산출물이 기존 *.log ignore 경계 안에 머물게 한다.
Move-Item -LiteralPath $logFile -Destination "$logFile.$rotateStamp.bak.log" -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:VIGNETTE_VOICE_STT_PROVIDER = "local_whisper"
$env:VIGNETTE_LOCAL_WHISPER_STT_URL = "ws://127.0.0.1:$WhisperPort/v1/listen"
$env:VIGNETTE_LOCAL_WHISPER_STT_MODEL = $WhisperModel
$env:VIGNETTE_LOCAL_WHISPER_STT_LANGUAGE = $WhisperLanguage
$env:VIGNETTE_VOICE_TTS_PROVIDER = "melotts"
$env:VIGNETTE_MELOTTS_TTS_URL = "http://127.0.0.1:$MeloTtsPort"
$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"
})
# 포트 리스너만으로는 올바른 provider/model을 증명하지 못한다. 첫 WS ready
# 프레임과 MeloTTS health metadata가 운영 계약과 정확히 일치할 때만 API를
# 유지하거나 재시작한다. 잘못된 기존 리스너는 소유권을 추측해 종료하지 않는다.
if (-not (Test-VoiceSidecarReady -Component "stt")) {
if ($RequireFreshPublicProvenance) {
throw "Fresh public promotion will not mutate local_whisper; restore the exact sidecar before retrying"
}
if (Test-PortListener -Port $WhisperPort) {
throw "Port $WhisperPort is occupied but does not expose the exact local_whisper/$WhisperModel/$WhisperDevice protocol"
}
& $WhisperStartScript `
-Port $WhisperPort `
-Model $WhisperModel `
-Device $WhisperDevice `
-WaitReadySeconds 0
if (-not (Wait-VoiceSidecarReady -Component "stt" -TimeoutSec $VoiceSidecarReadySeconds)) {
throw "local_whisper/$WhisperModel/$WhisperDevice did not become exactly ready before the API restart gate"
}
}
if (-not (Test-VoiceSidecarReady -Component "tts")) {
if ($RequireFreshPublicProvenance) {
throw "Fresh public promotion will not mutate MeloTTS; restore the exact sidecar before retrying"
}
if (Test-PortListener -Port $MeloTtsPort) {
throw "Port $MeloTtsPort is occupied but does not expose the exact melotts/$MeloTtsModel health contract"
}
& $MeloTtsStartScript `
-Port $MeloTtsPort `
-Language $MeloTtsLanguage `
-Device "cpu" `
-WaitReadySeconds 0
if (-not (Wait-VoiceSidecarReady -Component "tts" -TimeoutSec $VoiceSidecarReadySeconds)) {
throw "melotts/$MeloTtsModel did not become exactly ready before the API restart gate"
}
}
# 두 sidecar를 한 번 더 함께 검사해 개별 probe 사이의 TOCTOU를 닫는다.
if (-not (Test-VoiceSidecarReady -Component "stt") -or -not (Test-VoiceSidecarReady -Component "tts")) {
throw "Voice sidecar readiness changed before the API restart gate"
}
$health = Get-JsonHealth -Uri "http://127.0.0.1:$ApiPort/health"
$voiceHealth = Get-JsonHealth -Uri "http://127.0.0.1:$ApiPort/voice/health"
$apiControlPlaneReady = (
$null -ne $health -and
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true -and
(Test-VoiceApiReady -Health $voiceHealth)
)
$proc = $null
$apiStoppedProcessIds = @()
$apiLaunchIdentity = $null
if ($apiControlPlaneReady -and -not $ForceApiRestart) {
Write-Output "Production API and exact local voice stack already healthy; skipping API restart"
} else {
if ($RequireFreshPublicProvenance) {
$freshFailureStage = "api_cutover"
$freshMutationStarted = $true
}
$apiStoppedProcessIds = @(
Stop-UvicornByPort `
-AppImport "app.main:app" `
-Port $ApiPort `
-TimeoutSec $ProcessStopTimeoutSeconds
)
$proc = Start-Process -WindowStyle Hidden -FilePath $Python `
-ArgumentList @(
"-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1",
"--port", "$ApiPort",
"--ws", "websockets",
"--ws-max-queue", "4"
) `
-WorkingDirectory $ApiDir `
-RedirectStandardOutput $OutLog `
-RedirectStandardError $ErrLog `
-PassThru
if ($RequireFreshPublicProvenance) {
if ($apiStoppedProcessIds -contains $proc.Id) {
throw "Fresh public API did not receive a replacement PID"
}
$apiLaunchIdentity = Wait-ProcessIdentity `
-ProcessId $proc.Id `
-Role "api" `
-ExpectedCwd $ApiDir `
-TimeoutSec $ProcessStopTimeoutSeconds
if ($apiLaunchIdentity.executable_sha256 -ne $ExpectedPythonSha256.ToLowerInvariant()) {
throw "Fresh public API executable SHA256 does not match the pinned Python"
}
foreach ($requiredArgument in @("uvicorn", "app.main:app", "--port", "$ApiPort", "--ws-max-queue", "4")) {
if ($apiLaunchIdentity.command_line.IndexOf($requiredArgument, [System.StringComparison]::Ordinal) -lt 0) {
throw "Fresh public API command line is missing required argument: $requiredArgument"
}
}
}
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 -eq $true -and
$health.engine -eq $true
} `
-TimeoutSec 30
$voiceHealth = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPort/voice/health" `
-IsHealthy { param($health) Test-VoiceApiReady -Health $health } `
-TimeoutSec 30
}
if ($health.environment -ne "prod" -or -not $health.db -or -not $health.engine) {
throw "Admin/auth control plane is not production-safe: $($health | ConvertTo-Json -Compress)"
}
if (-not (Test-VoiceApiReady -Health $voiceHealth)) {
throw "Public voice API does not match the exact local provider/model contract: $($voiceHealth | ConvertTo-Json -Compress)"
}
if (!$SkipWebRestart) {
if ($RequireFreshPublicProvenance) {
$freshFailureStage = "web_preview"
}
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
}
$cloudflaredProcess = $null
$cloudflaredLaunchIdentity = $null
$cloudflaredStoppedProcessIds = @()
if (!$SkipCloudflaredRestart) {
if ($RequireFreshPublicProvenance) {
$freshFailureStage = "cloudflared_cutover"
}
if (!(Test-Path $Cloudflared)) {
throw "cloudflared not found at $Cloudflared"
}
if (!(Test-Path $CloudflaredConfig)) {
throw "cloudflared config not found at $CloudflaredConfig"
}
if ($RouteCloudflareDns) {
foreach ($hostname in ($PublicWebHostnames + $PublicApiHostnames)) {
& $Cloudflared tunnel route dns $CloudflareTunnelName $hostname
if ($LASTEXITCODE -ne 0) {
Write-Warning "cloudflared DNS route failed for $hostname"
}
}
}
if (-not $RequireFreshPublicProvenance) {
Set-CloudflaredIngress `
-ConfigPath $CloudflaredConfig `
-ApiHostnames $PublicApiHostnames `
-WebHostnames $PublicWebHostnames `
-ApiPortValue $ApiPort `
-WebPortValue $WebPort
}
$resolvedCloudflaredConfig = (Resolve-Path -LiteralPath $CloudflaredConfig).Path
if ($RequireFreshPublicProvenance) {
$existingCloudflaredProcesses = @(
Get-CloudflaredProcessesForConfig `
-ConfigPath $resolvedCloudflaredConfig `
-ExactPath
)
} else {
$existingCloudflaredProcesses = @(
Get-CloudflaredProcessesForConfig -ConfigPath $resolvedCloudflaredConfig
)
}
$cloudflaredStoppedProcessIds = @(
Stop-ProcessesBounded `
-Processes $existingCloudflaredProcesses `
-TimeoutSec $ProcessStopTimeoutSeconds `
-Role "cloudflared for $resolvedCloudflaredConfig"
)
$cloudflaredProcess = Start-Process -WindowStyle Hidden -FilePath $Cloudflared `
-ArgumentList @("tunnel", "--config", $resolvedCloudflaredConfig, "run") `
-WorkingDirectory $Workspace `
-RedirectStandardOutput (Join-Path $Workspace "cloudflared.public.out.log") `
-RedirectStandardError (Join-Path $Workspace "cloudflared.public.err.log") `
-PassThru
if ($cloudflaredStoppedProcessIds -contains $cloudflaredProcess.Id) {
throw "Cloudflared did not receive a replacement PID"
}
if ($RequireFreshPublicProvenance) {
$cloudflaredLaunchIdentity = Wait-ProcessIdentity `
-ProcessId $cloudflaredProcess.Id `
-Role "cloudflared" `
-ExpectedCwd $Workspace `
-TimeoutSec $ProcessStopTimeoutSeconds
if ($cloudflaredLaunchIdentity.executable_sha256 -ne $ExpectedCloudflaredSha256.ToLowerInvariant()) {
throw "Fresh cloudflared executable SHA256 does not match its pin"
}
if ($cloudflaredLaunchIdentity.command_line.IndexOf($resolvedCloudflaredConfig, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) {
throw "Fresh cloudflared command line is not pinned to the expected config"
}
}
}
if ($RequireFreshPublicProvenance) {
$freshFailureStage = "identity_revalidation"
if ($null -eq $apiLaunchIdentity -or $null -eq $cloudflaredLaunchIdentity) {
throw "Fresh public promotion did not produce both API and cloudflared identities"
}
$apiFinalIdentity = Wait-ProcessIdentity `
-ProcessId $apiLaunchIdentity.pid `
-Role "api" `
-ExpectedCwd $ApiDir `
-TimeoutSec $ProcessStopTimeoutSeconds
$cloudflaredFinalIdentity = Wait-ProcessIdentity `
-ProcessId $cloudflaredLaunchIdentity.pid `
-Role "cloudflared" `
-ExpectedCwd $Workspace `
-TimeoutSec $ProcessStopTimeoutSeconds
foreach ($identityPair in @(
[pscustomobject]@{ Role = "api"; Launch = $apiLaunchIdentity; Final = $apiFinalIdentity },
[pscustomobject]@{ Role = "cloudflared"; Launch = $cloudflaredLaunchIdentity; Final = $cloudflaredFinalIdentity }
)) {
foreach ($field in @("pid", "started_at_utc", "executable_sha256", "command_line_sha256", "cwd")) {
if ($identityPair.Launch[$field].ToString() -cne $identityPair.Final[$field].ToString()) {
throw "Fresh $($identityPair.Role) provenance drifted before receipt: $field"
}
}
}
$finalConfigSha256 = (Get-FileHash -LiteralPath $CloudflaredConfig -Algorithm SHA256).Hash.ToLowerInvariant()
if ($finalConfigSha256 -ne $ExpectedCloudflaredConfigSha256.ToLowerInvariant()) {
throw "Pinned cloudflared config drifted before provenance receipt"
}
$freshFailureStage = "public_health_validation"
$publicHealth = Wait-JsonHealth `
-Uri $CanonicalPublicHealthUrl `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true
} `
-TimeoutSec 60
$publicVoiceHealth = Wait-JsonHealth `
-Uri $CanonicalPublicVoiceHealthUrl `
-IsHealthy { param($health) Test-VoiceApiReady -Health $health } `
-TimeoutSec 30
$publicOpenApi = Wait-JsonHealth `
-Uri $CanonicalPublicOpenApiUrl `
-IsHealthy {
param($document)
Test-RequiredOpenApiPaths `
-Document $document `
-RequiredPaths $RequiredPublicApiPaths
} `
-TimeoutSec 30
if (-not (Test-VoiceSidecarReady -Component "stt") -or -not (Test-VoiceSidecarReady -Component "tts")) {
throw "Exact local voice sidecars changed before provenance receipt"
}
$psutilVersionArgs = @("-X", "utf8", "-c", "import importlib.metadata; print(importlib.metadata.version('psutil'))")
$psutilVersion = (@(& $Python @psutilVersionArgs) -join [Environment]::NewLine).Trim()
if ($LASTEXITCODE -ne 0 -or -not $psutilVersion) {
throw "Could not record the psutil version used for process provenance"
}
$safeApiIdentity = ConvertTo-SafeProcessIdentity -Identity $apiFinalIdentity
$safeCloudflaredIdentity = ConvertTo-SafeProcessIdentity -Identity $cloudflaredFinalIdentity
$provenance = [ordered]@{
schema_version = "vignette.public-runtime-launch-provenance.v1"
status = "passed"
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
source = [ordered]@{
repo_root = (Resolve-Path -LiteralPath $Workspace).Path
git_commit = $ExpectedSourceCommit.ToLowerInvariant()
git_tree = $ExpectedSourceTree.ToLowerInvariant()
launcher_sha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
clean_detached_head = $true
}
config = [ordered]@{
path = (Resolve-Path -LiteralPath $CloudflaredConfig).Path
sha256 = $finalConfigSha256
}
public_validation = [ordered]@{
health_url = $CanonicalPublicHealthUrl
health = $true
voice_health_url = $CanonicalPublicVoiceHealthUrl
voice_health = $true
openapi_url = $CanonicalPublicOpenApiUrl
required_openapi_paths = @($RequiredPublicApiPaths)
openapi = $true
local_voice_sidecars = $true
}
replacement = [ordered]@{
api_stopped_pids = @($apiStoppedProcessIds)
cloudflared_stopped_pids = @($cloudflaredStoppedProcessIds)
api_new_pid = [int]$apiFinalIdentity.pid
cloudflared_new_pid = [int]$cloudflaredFinalIdentity.pid
}
processes = [ordered]@{
api = $safeApiIdentity
cloudflared = $safeCloudflaredIdentity
}
topology_inputs = [ordered]@{
repo_root = (Resolve-Path -LiteralPath $Workspace).Path
git_sha = $ExpectedSourceCommit.ToLowerInvariant()
api_pid = [int]$apiFinalIdentity.pid
api_started_at_utc = $apiFinalIdentity.started_at_utc
api_executable_name = $apiFinalIdentity.executable_name
api_executable_sha256 = $apiFinalIdentity.executable_sha256
api_command_line_sha256 = $apiFinalIdentity.command_line_sha256
api_cwd = $apiFinalIdentity.cwd
api_listen_port = $ApiPort
cloudflared_pid = [int]$cloudflaredFinalIdentity.pid
cloudflared_started_at_utc = $cloudflaredFinalIdentity.started_at_utc
cloudflared_executable_name = $cloudflaredFinalIdentity.executable_name
cloudflared_executable_sha256 = $cloudflaredFinalIdentity.executable_sha256
cloudflared_command_line_sha256 = $cloudflaredFinalIdentity.command_line_sha256
cloudflared_cwd = $cloudflaredFinalIdentity.cwd
psutil_version = $psutilVersion
}
}
$provenanceJson = ConvertTo-Json -InputObject $provenance -Depth 8
$freshFailureStage = "receipt_publish"
try {
Write-Utf8TextAtomically `
-OutputPath $resolvedRuntimeProvenancePath `
-Value ($provenanceJson + [Environment]::NewLine)
} catch {
# 새 PID들은 이미 health/identity gate를 통과했지만, atomic receipt가 없으면
# 승격 성공으로 간주할 수 없다. 기존 receipt는 보존되고 호출은 non-zero로 끝난다.
throw "Fresh public promotion failed closed after runtime replacement: no atomic passed receipt was published. Re-run the pinned promotion after fixing the receipt destination. $($_.Exception.Message)"
}
$freshPromotionCommitted = $true
Write-Output "Fresh public provenance: $resolvedRuntimeProvenancePath"
}
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)"
Write-Output "Voice health: $($voiceHealth | ConvertTo-Json -Compress)"