af057ff165
Keep current plans and release controls discoverable without losing historical evidence, and include the reviewed Windows start/stop workflow.
166 lines
11 KiB
PowerShell
166 lines
11 KiB
PowerShell
# Keep this script ASCII-only for Windows PowerShell 5.1.
|
|
param([switch]$NoBrowser, [switch]$WithReviewWorker)
|
|
$ErrorActionPreference = 'Stop'
|
|
$root = $PSScriptRoot
|
|
$python = Join-Path $root '.venv-project\Scripts\python.exe'
|
|
$apiDir = Join-Path $root 'yuqei-contract-platform\services\contract-api'
|
|
$aiDir = Join-Path $root 'yuqei-ai-platform\services\ai-platform-api'
|
|
$webDir = Join-Path $root 'yuqei-contract-platform\apps\contract-web'
|
|
$logDir = Join-Path $root '.runtime\dev-logs'
|
|
$stateFile = Join-Path $logDir 'processes.json'
|
|
$records = @{}
|
|
$savedEnvironment = @{}
|
|
|
|
function Set-ChildEnvironment([string]$Name, [string]$Value) {
|
|
$savedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
|
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
|
|
}
|
|
function Get-Identity([int]$ProcessId) {
|
|
$item = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
|
|
if (-not $item) { return $null }
|
|
return [pscustomobject]@{ Pid = [int]$item.ProcessId; ParentPid = [int]$item.ParentProcessId; CreatedUtc = $item.CreationDate.ToUniversalTime().ToString('o'); ExecutablePath = [string]$item.ExecutablePath; CommandLine = [string]$item.CommandLine }
|
|
}
|
|
function Find-ProjectOwner($Identity, [string]$Marker) {
|
|
# Windows venv python.exe launches the base interpreter as a child.
|
|
for ($level = 0; $Identity -and $level -lt 8; $level++) {
|
|
if ($Identity.CommandLine.IndexOf($Marker, [StringComparison]::OrdinalIgnoreCase) -lt 0) { return $null }
|
|
if ($Identity.CommandLine.IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0) { return $Identity }
|
|
$parent = Get-Identity $Identity.ParentPid
|
|
if (-not $parent -or [datetime]::Parse($parent.CreatedUtc) -gt [datetime]::Parse($Identity.CreatedUtc)) { return $null }
|
|
$Identity = $parent
|
|
}
|
|
return $null
|
|
}
|
|
function Test-Recorded($Record) {
|
|
if (-not $Record) { return $false }
|
|
$actual = Get-Identity $Record.Pid
|
|
return ($actual -and $actual.CreatedUtc -eq $Record.CreatedUtc -and $actual.ExecutablePath -eq $Record.ExecutablePath -and $actual.CommandLine -eq $Record.CommandLine)
|
|
}
|
|
function Save-State {
|
|
$entries = @($records.GetEnumerator() | ForEach-Object { [pscustomobject]@{ Name = $_.Key; Process = $_.Value } })
|
|
[pscustomobject]@{ Root = $root; Services = $entries } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $stateFile -Encoding UTF8
|
|
}
|
|
function Test-Endpoint($Service) {
|
|
try {
|
|
if ($Service.Port -eq 3000) {
|
|
$response = Invoke-WebRequest -UseBasicParsing -Uri $Service.Url -TimeoutSec 8
|
|
return ($response.StatusCode -eq 200 -and $response.Content -match 'login-auth-page')
|
|
}
|
|
$response = Invoke-RestMethod -Uri $Service.Url -TimeoutSec 5
|
|
return ($response.status -eq 'ok' -and $response.service -eq $Service.Name)
|
|
} catch { return $false }
|
|
}
|
|
function Find-Running($Service) {
|
|
if ($Service.Port) {
|
|
$listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Service.Port -ErrorAction SilentlyContinue)
|
|
if ($listeners.Count -eq 0) { return $false }
|
|
foreach ($listener in $listeners) {
|
|
$identity = Find-ProjectOwner (Get-Identity $listener.OwningProcess) $Service.Marker
|
|
if (-not $identity) {
|
|
throw "Port $($Service.Port) belongs to another process. Resolve the conflict before starting."
|
|
}
|
|
}
|
|
if (-not (Test-Endpoint $Service)) { throw "$($Service.Name) occupies port $($Service.Port) but is not healthy. Check $logDir and run stop-dev.ps1 before retrying." }
|
|
if (-not (Test-Recorded $records[$Service.Name])) { $records[$Service.Name] = $identity; Save-State }
|
|
Write-Host "Already ready: $($Service.Name)"
|
|
return $true
|
|
}
|
|
if (Test-Recorded $records[$Service.Name]) { Write-Host "Already running: $($Service.Name)"; return $true }
|
|
$existing = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0 -and $_.CommandLine -match (' -m ' + [regex]::Escape($Service.Marker) + '(\s|$)') })
|
|
if ($existing.Count -gt 0) {
|
|
$records[$Service.Name] = Get-Identity $existing[0].ProcessId
|
|
Save-State
|
|
Write-Host "Already running: $($Service.Name)"
|
|
return $true
|
|
}
|
|
return $false
|
|
}
|
|
function Start-LocalService($Service) {
|
|
$stdout = Join-Path $logDir "$($Service.Name).out.log"
|
|
$stderr = Join-Path $logDir "$($Service.Name).err.log"
|
|
$process = Start-Process -FilePath $Service.Exe -ArgumentList $Service.Arguments -WorkingDirectory $Service.Directory -WindowStyle Hidden -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
Start-Sleep -Milliseconds 400
|
|
$identity = Get-Identity $process.Id
|
|
if (-not $identity) { throw "$($Service.Name) exited. Check $stderr" }
|
|
$records[$Service.Name] = $identity
|
|
Save-State
|
|
Write-Host "Started: $($Service.Name) (PID $($process.Id))"
|
|
}
|
|
function Invoke-Migration([string]$Name, [string]$Directory) {
|
|
Write-Host "Checking database migrations: $Name"
|
|
$stdout = Join-Path $logDir "$Name-migration.out.log"
|
|
$stderr = Join-Path $logDir "$Name-migration.err.log"
|
|
$process = Start-Process -FilePath $python -ArgumentList @('-m', 'alembic', 'upgrade', 'head') -WorkingDirectory $Directory -WindowStyle Hidden -PassThru -Wait -RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
if ($process.ExitCode -ne 0) { throw "$Name migration failed (exit $($process.ExitCode)). Check $stderr. Startup stopped." }
|
|
}
|
|
try {
|
|
if (-not (Test-Path -LiteralPath $python)) { throw "Python environment missing: $python" }
|
|
& $python -c 'import sys; assert sys.version_info >= (3, 12); import uvicorn, alembic, dotenv' 2>$null
|
|
if ($LASTEXITCODE -ne 0) { throw 'Project Python is unavailable. Repair .venv-project and install project dependencies first.' }
|
|
$node = (Get-Command node -ErrorAction Stop).Source
|
|
$next = Join-Path $webDir 'node_modules\next\dist\bin\next'
|
|
if (-not (Test-Path -LiteralPath $next)) { $next = Join-Path $root 'node_modules\next\dist\bin\next' }
|
|
if (-not (Test-Path -LiteralPath $next)) { throw 'Next.js dependencies are missing. Install workspace npm dependencies first.' }
|
|
$webEnv = Join-Path $webDir '.env.local'
|
|
if (-not (Test-Path -LiteralPath $webEnv)) { throw "Web configuration missing: $webEnv" }
|
|
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
|
if (Test-Path -LiteralPath $stateFile) {
|
|
$state = Get-Content -Raw -LiteralPath $stateFile | ConvertFrom-Json
|
|
if ($state.Root -ne $root) { throw "Process state belongs to another directory: $stateFile" }
|
|
foreach ($entry in $state.Services) { $records[$entry.Name] = $entry.Process }
|
|
}
|
|
# Read only proxy secrets, without printing them or replacing service .env settings.
|
|
$readSecrets = "import json,sys; from dotenv import dotenv_values; e=dotenv_values(sys.argv[1]); print(json.dumps({k:e.get(k) for k in ('CONTRACT_API_PROXY_SECRET','AI_PLATFORM_PROXY_SECRET')}))"
|
|
$secretJson = & $python -c $readSecrets $webEnv
|
|
if ($LASTEXITCODE -ne 0) { throw 'Could not read web proxy authentication configuration.' }
|
|
$secrets = $secretJson | ConvertFrom-Json
|
|
if (-not $secrets.CONTRACT_API_PROXY_SECRET -or -not $secrets.AI_PLATFORM_PROXY_SECRET) { throw 'CONTRACT_API_PROXY_SECRET and AI_PLATFORM_PROXY_SECRET are required in web .env.local.' }
|
|
Set-ChildEnvironment 'CONTRACT_API_WEB_PROXY_SHARED_SECRET' $secrets.CONTRACT_API_PROXY_SECRET
|
|
Set-ChildEnvironment 'CONTRACT_API_AI_PLATFORM_SERVICE_KEY' $secrets.AI_PLATFORM_PROXY_SECRET
|
|
Set-ChildEnvironment 'AI_PLATFORM_SERVICE_SHARED_SECRET' $secrets.AI_PLATFORM_PROXY_SECRET
|
|
Set-ChildEnvironment 'CONTRACT_API_DRAFTING_WORKER_ENABLED' 'true'
|
|
Set-ChildEnvironment 'PYTHONUNBUFFERED' '1'
|
|
$bypassProxy = @($env:NO_PROXY -split ','; 'localhost'; '127.0.0.1'; '::1') | Where-Object { $_ } | Select-Object -Unique
|
|
Set-ChildEnvironment 'NO_PROXY' ($bypassProxy -join ',')
|
|
Set-ChildEnvironment 'PYTHONPATH' ((Join-Path $apiDir 'src'), (Join-Path $aiDir 'src'), (Join-Path $root 'yuqei-ai-sdk-python\src') -join ';')
|
|
$services = @(
|
|
@{ Name = 'ai-platform-api'; Exe = $python; Directory = $aiDir; Marker = 'yuqei_ai_platform_api.main:app'; Port = 8101; Url = 'http://127.0.0.1:8101/health'; Arguments = '-m uvicorn yuqei_ai_platform_api.main:app --app-dir src --host 127.0.0.1 --port 8101' },
|
|
@{ Name = 'contract-api'; Exe = $python; Directory = $apiDir; Marker = 'yuqei_contract_api.main:app'; Port = 8100; Url = 'http://127.0.0.1:8100/health'; Arguments = '-m uvicorn yuqei_contract_api.main:app --app-dir src --host 127.0.0.1 --port 8100' },
|
|
@{ Name = 'contract-web'; Exe = $node; Directory = $webDir; Marker = 'next'; Port = 3000; Url = 'http://127.0.0.1:3000/login'; Arguments = ('"' + $next + '" dev --hostname 127.0.0.1 --port 3000') },
|
|
@{ Name = 'contract-review-worker'; Exe = $python; Directory = $apiDir; Marker = 'yuqei_contract_api.review_worker'; Port = 0; Arguments = '-m yuqei_contract_api.review_worker' },
|
|
@{ Name = 'drafting-continuation-worker'; Exe = $python; Directory = $apiDir; Marker = 'yuqei_contract_api.drafting_continuation_worker'; Port = 0; Arguments = '-m yuqei_contract_api.drafting_continuation_worker' }
|
|
)
|
|
$running = @{}
|
|
foreach ($service in $services) { $running[$service.Name] = Find-Running $service }
|
|
# Both schemas must be current before any new API or worker starts.
|
|
Invoke-Migration 'ai-platform-api' $aiDir
|
|
Invoke-Migration 'contract-api' $apiDir
|
|
foreach ($service in $services) { if (-not $running[$service.Name]) { Start-LocalService $service } }
|
|
foreach ($service in $services) {
|
|
if ($service.Port) {
|
|
$deadline = (Get-Date).AddSeconds(120)
|
|
while (-not (Test-Endpoint $service)) {
|
|
if (-not (Test-Recorded $records[$service.Name])) { throw "$($service.Name) exited. Check $logDir." }
|
|
if ((Get-Date) -gt $deadline) { throw "$($service.Name) did not become ready. Check $logDir." }
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
} elseif (-not (Test-Recorded $records[$service.Name])) { throw "$($Service.Name) exited. Check $logDir." }
|
|
}
|
|
Write-Host 'Ready: http://localhost:3000/login' -ForegroundColor Green
|
|
Write-Host 'APIs: http://127.0.0.1:8100/health and http://127.0.0.1:8101/health'
|
|
Write-Host "Review and drafting workers are running. Logs: $logDir"
|
|
Write-Host 'Stop: powershell -ExecutionPolicy Bypass -File .\stop-dev.ps1'
|
|
if (-not $NoBrowser) {
|
|
$chrome = Get-Command chrome.exe -ErrorAction SilentlyContinue
|
|
$chromePath = if ($chrome) { $chrome.Source } else { Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe' }
|
|
if (Test-Path -LiteralPath $chromePath) { Start-Process -FilePath $chromePath -ArgumentList 'http://localhost:3000/login' }
|
|
else { Start-Process 'http://localhost:3000/login' }
|
|
}
|
|
} catch {
|
|
Write-Host ('Startup failed: ' + $_.Exception.Message) -ForegroundColor Red
|
|
Write-Host 'Any services already started remain recorded. Inspect logs or run stop-dev.ps1 before retrying.'
|
|
exit 1
|
|
} finally {
|
|
foreach ($name in $savedEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') }
|
|
}
|