# 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') } }