vignette/scripts/dev-up.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

361 lines
14 KiB
PowerShell

<#
.SYNOPSIS
Vignette 로컬 개발 스택을 깔끔하게 (재)기동한다: 엔진 게이트웨이(9099) + API(8000) + 웹(5173).
.DESCRIPTION
- 기존(고아 포함) 프로세스를 커맨드라인 기준으로 정확히 정리한 뒤 새로 띄운다.
API는 Windows reloader 고아 프로세스/포트 잔류를 피하려고 단일 프로세스로 띄운다.
게이트웨이는 no-reload 유지(상주 claude -p 세션 보존; Windows에선 reload 시 자식 claude 고아화).
웹(vite)은 npm run dev 기본 HMR.
- API는 DB 미가용 시 in-memory degraded로 기동된다(Postgres 불필요). dev-login + seed 페르소나 활성.
- 로그는 .devlogs/ 에 남긴다. 종료는 scripts/dev-down.ps1.
.PARAMETER NoGateway
엔진 게이트웨이를 띄우지 않는다(AI 턴 생성 불가, UI만 테스트 시).
.PARAMETER NoWeb
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]$UseHiggsVoice,
[int]$HiggsPort = 9881
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$api = Join-Path $repo 'apps\api'
$web = Join-Path $repo 'apps\web'
$logs = Join-Path $repo '.devlogs'
$DevDbContainerName = 'vignette-dev-db'
$DevDbDataVolume = 'vignette-dev-db-pgdata'
New-Item -ItemType Directory -Force -Path $logs | Out-Null
# uvicorn 이 설치된 python 을 해석한다(시스템에 3.11/3.14 등 복수 python 공존 — 'python' 별칭이
# uvicorn 없는 인터프리터를 가리킬 수 있다). 후보를 순회해 import uvicorn 성공하는 것을 고른다.
$pyCandidates = @(
(Join-Path $env:LOCALAPPDATA 'Programs\Python\Python311\python.exe'),
(Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe'),
'py',
'python'
)
$Python = $null
foreach ($c in $pyCandidates) {
$exe = $c; $pre = @()
if ($c -eq 'py') { $pre = @('-3') }
try {
& $exe @pre '-c' 'import uvicorn' 2>$null
if ($LASTEXITCODE -eq 0) { $Python = $exe; $PyPre = $pre; break }
} catch {}
}
if (-not $Python) { Write-Host 'ERROR: uvicorn 설치된 python 을 못 찾음 (pip install -r apps/api/requirements.txt)'; exit 1 }
Write-Host ("python: {0} {1}" -f $Python, ($PyPre -join ' '))
function Stop-Stale([string]$pattern, [string]$label) {
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object { ($_.Name -eq 'python.exe' -or $_.Name -eq 'node.exe') -and $_.CommandLine -and $_.CommandLine -match $pattern } |
ForEach-Object {
Write-Host (" stop {0,-8} PID {1}" -f $label, $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
function Stop-ListenerByPort([int]$port, [string]$label) {
Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique |
Where-Object { $_ -and $_ -ne 0 } |
ForEach-Object {
Write-Host (" stop {0,-8} PID {1} (:${port})" -f $label, $_)
Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue
}
}
function Wait-Health([string]$url, [string]$label, [int]$timeoutSec = 40) {
$deadline = (Get-Date).AddSeconds($timeoutSec)
while ((Get-Date) -lt $deadline) {
try {
$r = Invoke-WebRequest -Uri $url -TimeoutSec 4 -UseBasicParsing -ErrorAction Stop
if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { Write-Host " OK $label ($url)"; return $true }
} catch { Start-Sleep -Milliseconds 600 }
}
Write-Host " WARN $label 미응답 ($url) — .devlogs 로그 확인"; return $false
}
function Read-EnvFileValue([string]$path, [string]$key) {
if (!(Test-Path -LiteralPath $path)) { return $null }
$pattern = ('^\s*{0}\s*=' -f [regex]::Escape($key))
$line = Get-Content -LiteralPath $path -Encoding utf8 |
Where-Object { $_ -match $pattern } |
Select-Object -First 1
if (-not $line) { return $null }
$value = ($line -replace $pattern, '').Trim()
$value = $value.Trim('"')
$value = $value.Trim("'")
return $value
}
function Warn-ComposeEnvReadiness {
$composeEnv = Join-Path $repo 'infra\.env'
if (!(Test-Path -LiteralPath $composeEnv)) {
Write-Host ' INFO infra\.env 없음 - docker compose 전체 스택은 .env 작성 후 사용.'
return
}
$required = @(
'POSTGRES_PASSWORD',
'APP_DB_PASSWORD',
'OPENAI_API_KEY',
'SESSION_SECRET',
'OAUTH_GOOGLE_CLIENT_ID',
'OAUTH_GOOGLE_CLIENT_SECRET'
)
$missing = @()
foreach ($key in $required) {
$value = Read-EnvFileValue $composeEnv $key
if ([string]::IsNullOrWhiteSpace($value)) { $missing += $key }
}
if ($missing.Count -gt 0) {
Write-Host (' WARN infra\.env 필수값 누락: {0} - compose api/proxy 기동 전 보강 필요.' -f ($missing -join ', '))
}
}
function Test-DevDbRoleSafety([string]$ownerUser, [string]$dbName) {
if ([string]::IsNullOrWhiteSpace($ownerUser) -or [string]::IsNullOrWhiteSpace($dbName)) { return }
$sql = "SELECT rolname || ':' || rolsuper || ':' || rolbypassrls FROM pg_roles WHERE rolname IN ('vignette_app','vignette_owner') ORDER BY rolname;"
$rows = & docker exec vignette-dev-db psql -U $ownerUser -d $dbName -At -c $sql 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host ' WARN db role 점검 실패 - psql 접속/권한 확인 필요.'
return
}
foreach ($row in $rows) {
$parts = "$row".Split(':')
if ($parts.Count -lt 3) { continue }
if ($parts[0] -eq 'vignette_app' -and ($parts[1] -eq 't' -or $parts[2] -eq 't')) {
Write-Host ' WARN vignette_app role 이 SUPERUSER/BYPASSRLS 상태 - RLS 검증이 무의미함. 데이터 보존형 owner/app migration 필요(dev-up은 DB를 교체하지 않음).'
}
}
}
function Get-DevDbContainerState {
$state = & docker inspect -f '{{.State.Status}}' $DevDbContainerName 2>$null
if ($LASTEXITCODE -ne 0) { return $null }
return "$state".Trim()
}
function Get-DevDbContainerEnvValue([string]$key, [string]$fallback) {
$envLines = @(& docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' $DevDbContainerName 2>$null)
if ($LASTEXITCODE -ne 0) { return $fallback }
$prefix = "${key}="
foreach ($line in $envLines) {
$text = "$line"
if ($text.StartsWith($prefix, [StringComparison]::Ordinal)) {
return $text.Substring($prefix.Length)
}
}
return $fallback
}
function Wait-DevDbReady([string]$ownerUser, [int]$attempts = 24) {
for ($i = 0; $i -lt $attempts; $i++) {
& docker exec $DevDbContainerName pg_isready -U $ownerUser 1>$null 2>$null
if ($LASTEXITCODE -eq 0) { return $true }
Start-Sleep -Seconds 2
}
return $false
}
function Inspect-DevDbContainer {
$healthMode = & docker inspect -f '{{if .Config.Healthcheck}}health{{else}}none{{end}}' $DevDbContainerName 2>$null
if ($LASTEXITCODE -eq 0 -and "$healthMode" -eq 'none') {
Write-Host ' WARN vignette-dev-db healthcheck 없음 - 데이터는 유지하고 pg_isready로 점검함(dev-up은 DB를 재생성하지 않음).'
}
$ownerUser = Get-DevDbContainerEnvValue 'POSTGRES_USER' 'vignette_owner'
$dbName = Get-DevDbContainerEnvValue 'POSTGRES_DB' 'vignette'
& docker exec $DevDbContainerName pg_isready -U $ownerUser 1>$null 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host ' OK db (vignette-dev-db 준비됨)'
} else {
Write-Host ' WARN db 컨테이너는 있으나 pg_isready 실패'
}
Test-DevDbRoleSafety "$ownerUser" "$dbName"
}
function Start-ExistingDevDbContainer([string]$state) {
if ($state -notin @('created', 'exited')) {
throw "vignette-dev-db 상태 '$state'는 자동 복구 대상이 아님. dev-up은 기존 DB를 제거하거나 교체하지 않는다."
}
Write-Host (" start db {0} (기존 PGDATA 보존)" -f $DevDbContainerName)
& docker start $DevDbContainerName 1>$null
if ($LASTEXITCODE -ne 0) {
throw '기존 vignette-dev-db 시작 실패. 데이터 교체 없이 중단함. pg_dump와 복구 검증을 갖춘 별도 유지보수 절차가 필요하다.'
}
$ownerUser = Get-DevDbContainerEnvValue 'POSTGRES_USER' 'vignette_owner'
if (-not (Wait-DevDbReady $ownerUser)) {
throw '기존 vignette-dev-db가 시작됐지만 pg_isready가 실패함. 컨테이너/볼륨을 교체하지 않고 중단함.'
}
Inspect-DevDbContainer
}
function Ensure-DevDbDataVolume {
$existing = & docker volume inspect -f '{{.Name}}' $DevDbDataVolume 2>$null
if ($LASTEXITCODE -eq 0) {
if ("$existing".Trim() -ne $DevDbDataVolume) {
throw "DB named volume 식별 불일치: $existing"
}
return
}
$created = & docker volume create $DevDbDataVolume
if ($LASTEXITCODE -ne 0 -or "$created".Trim() -ne $DevDbDataVolume) {
throw "DB named volume 생성 실패: $DevDbDataVolume"
}
Write-Host (" create volume {0} (새 DB 전용 named PGDATA)" -f $DevDbDataVolume)
}
function Ensure-DevDb {
if (!(Get-Command docker -ErrorAction SilentlyContinue)) {
Write-Host ' WARN docker CLI 없음 - DB 없이 degraded(UI 세션 시작 제한).'
return
}
if (!(Test-Path -LiteralPath '\\.\pipe\dockerDesktopLinuxEngine')) {
Write-Host ' WARN Docker Desktop Linux engine 미실행 - DB 없이 degraded(UI 세션 시작 제한).'
return
}
docker ps 1>$null 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host ' WARN Docker 데몬 미응답 - DB 없이 degraded(UI 세션 시작 제한). Docker Desktop 실행 필요.'
return
}
$containerState = Get-DevDbContainerState
if (-not [string]::IsNullOrWhiteSpace($containerState)) {
if ($containerState -eq 'running') {
Inspect-DevDbContainer
return
}
Start-ExistingDevDbContainer $containerState
return
}
$apiEnv = Join-Path $api '.env'
$dbUrl = Read-EnvFileValue $apiEnv 'DATABASE_URL'
if ($dbUrl -notmatch 'postgresql://([^:]+):([^@]+)@[^:]+:([0-9]+)/(\S+)') {
Write-Host ' WARN DATABASE_URL 파싱 실패 - DB 스킵'
return
}
$u = $Matches[1]
$p = $Matches[2]
$port = $Matches[3]
$db = $Matches[4]
$ownerUser = 'vignette_owner'
$ownerPassword = $p
$appUser = $u
$appPassword = $p
if ($appUser -eq $ownerUser) {
Write-Host ' WARN DATABASE_URL 이 owner role 을 사용 중 - RLS 검증을 위해 앱 전용 role URL 권장.'
}
Ensure-DevDbDataVolume
$initPath = Join-Path $repo 'infra\db\init'
$dockerArgs = @(
'run', '-d',
'--name', $DevDbContainerName,
'-p', ('{0}:5432' -f $port),
'--health-cmd', ('pg_isready -U {0}' -f $ownerUser),
'--health-interval', '10s',
'--health-timeout', '5s',
'--health-retries', '5',
'-e', ('POSTGRES_USER={0}' -f $ownerUser),
'-e', ('POSTGRES_PASSWORD={0}' -f $ownerPassword),
'-e', ('POSTGRES_DB={0}' -f $db),
'-e', ('APP_DB_USER={0}' -f $appUser),
'-e', ('APP_DB_PASSWORD={0}' -f $appPassword),
'--mount', ('type=volume,source={0},target=/var/lib/postgresql/data' -f $DevDbDataVolume),
'-v', ('{0}:/docker-entrypoint-initdb.d:ro' -f $initPath),
'pgvector/pgvector:pg16'
)
& docker @dockerArgs 1>$null
if ($LASTEXITCODE -ne 0) {
throw '새 vignette-dev-db 컨테이너 생성 실패. named PGDATA volume은 보존함.'
}
if (Wait-DevDbReady $ownerUser) {
Write-Host ' OK db (Postgres 준비됨, named PGDATA + init 스키마 적용)'
Test-DevDbRoleSafety $ownerUser $db
Start-Sleep -Seconds 1
return
}
throw '새 vignette-dev-db 준비 타임아웃. 컨테이너와 named PGDATA volume은 조사/복구를 위해 보존함.'
}
Write-Host '[1/4] 기존 스택 정리...'
if (-not $NoGateway) {
Stop-Stale 'engine_gateway\.gateway' 'gateway'
Stop-ListenerByPort 9099 'gateway'
}
Stop-Stale ('app\.main:app.*--port\s+{0}' -f $ApiPort) 'api'
Stop-ListenerByPort $ApiPort 'api'
if (-not $NoWeb) {
Stop-Stale 'vite.*--port\s+5173' 'web'
Stop-ListenerByPort 5173 'web'
}
Start-Sleep -Seconds 2
if (-not $NoDb) {
Write-Host '[DB] Postgres(pgvector) 보장...'
Warn-ComposeEnvReadiness
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')
Start-Process -FilePath $Python -ArgumentList $gatewayArgs -WorkingDirectory $api -WindowStyle Hidden -RedirectStandardOutput (Join-Path $logs 'gateway.out.log') -RedirectStandardError (Join-Path $logs 'gateway.err.log')
} else {
Write-Host '[2/4] (게이트웨이 건너뜀)'
}
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')
if (-not $NoWeb) {
Write-Host ('[4/4] 웹 vite :5173... API proxy {0}' -f $env:VITE_API_PROXY_TARGET)
Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', 'npm run dev -- --host 127.0.0.1 --port 5173') -WorkingDirectory $web -WindowStyle Hidden -RedirectStandardOutput (Join-Path $logs 'web.out.log') -RedirectStandardError (Join-Path $logs 'web.err.log')
} else {
Write-Host '[4/4] (웹 건너뜀)'
}
Start-Sleep -Seconds 3
Write-Host ''
Write-Host '헬스 체크:'
if (-not $NoGateway) {
Wait-Health 'http://127.0.0.1:9099/health' 'gateway' | Out-Null
}
Wait-Health "http://127.0.0.1:$ApiPort/health" 'api' | Out-Null
if (-not $NoWeb) {
Wait-Health 'http://localhost:5173/' 'web' | Out-Null
}
Write-Host ''
Write-Host '준비 완료. 진입점: http://localhost:5173 (로그인 페이지에서 dev-login)'
Write-Host '종료: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1'