chore: archive project history and preserve delivery controls

Keep current plans and release controls discoverable without losing historical evidence, and include the reviewed Windows start/stop workflow.
This commit is contained in:
2026-10-08 19:05:30 +08:00
parent d405c1ba65
commit af057ff165
66 changed files with 4846 additions and 58 deletions
+160 -58
View File
@@ -1,63 +1,165 @@
# Yuqei AI Contract Platform - one-click local startup.
# Usage (repo root): powershell -ExecutionPolicy Bypass -File .\start-dev.ps1
# Optional: add -WithReviewWorker to also start the contract review worker.
# NOTE: keep this file ASCII-only. Windows PowerShell 5.1 misreads UTF-8
# comments without BOM (GBK mojibake breaks the parser).
param(
[switch]$WithReviewWorker
)
$ErrorActionPreference = "Stop"
# 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"
$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 = @{}
if (-not (Test-Path $python)) {
Write-Host "Python not found: $python" -ForegroundColor Red
Write-Host "Rebuild the venv first: python -m venv .venv-project then .\.venv-project\Scripts\pip install -e yuqei-contract-platform\services\contract-api -e yuqei-ai-platform\services\ai-platform-api -e yuqei-ai-platform\services\worker -e yuqei-ai-sdk-python"
exit 1
function Set-ChildEnvironment([string]$Name, [string]$Value) {
$savedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process')
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
}
function Start-ServiceWindow($title, $workDir, $command) {
Start-Process powershell -ArgumentList @(
"-NoExit", "-Command",
"`$Host.UI.RawUI.WindowTitle = '$title'; Set-Location '$workDir'; $command"
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') }
}
# 0) Database migration (idempotent; skips applied migrations; never blocks startup)
Write-Host "Running database migrations (alembic upgrade head)..." -ForegroundColor Cyan
$apiDir = Join-Path $root "yuqei-contract-platform\services\contract-api"
Push-Location $apiDir
& $python -m alembic upgrade head
if ($LASTEXITCODE -eq 0) {
Write-Host "Database migrations up to date." -ForegroundColor Green
} else {
Write-Host "Migration check did not complete (services will still start; re-run manually when the DB is reachable)." -ForegroundColor Yellow
}
Pop-Location
# 1) Contract API (port 8100)
Start-ServiceWindow "contract-api :8100" (Join-Path $root "yuqei-contract-platform\services\contract-api") `
"& '$python' -m uvicorn yuqei_contract_api.main:app --app-dir src --reload --port 8100"
# 2) AI Platform API (port 8101)
Start-ServiceWindow "ai-platform-api :8101" (Join-Path $root "yuqei-ai-platform\services\ai-platform-api") `
"& '$python' -m uvicorn yuqei_ai_platform_api.main:app --app-dir src --reload --port 8101"
# 3) Contract Web (port 3000)
Start-ServiceWindow "contract-web :3000" (Join-Path $root "yuqei-contract-platform\apps\contract-web") `
"npm run dev"
# 4) Optional: contract review worker
if ($WithReviewWorker) {
$workerDir = Join-Path $root "yuqei-contract-platform\services\contract-api"
Start-ServiceWindow "contract-review-worker" $workerDir `
"`$env:PYTHONPATH='src;..\..\..\yuqei-ai-sdk-python\src'; & '$python' -m yuqei_contract_api.review_worker"
}
Write-Host ""
Write-Host "Started: Contract API(8100) / AI Platform(8101) / Web(3000)" -ForegroundColor Green
if ($WithReviewWorker) { Write-Host "Started: contract review worker" -ForegroundColor Green }
Start-Sleep -Seconds 8
Start-Process "http://localhost:3000"