param( [string]$TrainingDir = 'D:\qiwei-training', [int]$DashboardPort = 4434 ) $ErrorActionPreference = 'Stop' $exe = Join-Path $TrainingDir 'qiwei-workbench.exe' if (-not (Test-Path (Join-Path $TrainingDir '.mcp.json'))) { throw 'Training package is missing .mcp.json' } $mcpConfig = Get-Content (Join-Path $TrainingDir '.mcp.json') -Raw | ConvertFrom-Json $mcpEntry = $mcpConfig.mcpServers.'qiwei-assistant' if (-not $mcpEntry) { throw '.mcp.json does not register qiwei-assistant' } if (-not ($mcpEntry.args -contains 'mcp')) { throw 'qiwei-assistant does not configure the mcp subcommand' } $mcpVerifier = Join-Path $PSScriptRoot 'verify-training-package-mcp.js' $mcpOutput = & node $mcpVerifier $TrainingDir 2>&1 if ($LASTEXITCODE -ne 0) { throw "MCP handshake failed: $mcpOutput" } Write-Output "MCP registration and handshake: $mcpOutput" function Stop-TrainingProcesses([string]$root) { $exePath = [IO.Path]::GetFullPath((Join-Path $root 'qiwei-workbench.exe')) $deadline = (Get-Date).AddSeconds(8) $quietSince = $null do { $processes = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $exePath }) if (-not $processes.Count) { if (-not $quietSince) { $quietSince = Get-Date } elseif (((Get-Date) - $quietSince).TotalMilliseconds -ge 1500) { return } Start-Sleep -Milliseconds 300 continue } $quietSince = $null foreach ($process in $processes) { # /T also closes helper children which can retain SQLite -wal/-shm files. & "$env:SystemRoot\System32\taskkill.exe" /PID $process.ProcessId /T /F | Out-Null } Start-Sleep -Milliseconds 300 } while ((Get-Date) -lt $deadline) $remaining = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $exePath }) if ($remaining.Count) { throw "Training process did not exit: $($remaining.ProcessId -join ', ')" } } function Stop-TrainingRuntime([string]$root) { $runtime = Start-TrainingProcess -FilePath (Join-Path $root 'qiwei-workbench.exe') -ArgumentList @('runtime', 'stop') -WorkingDirectory $root if (-not $runtime.WaitForExit(10000)) { & "$env:SystemRoot\System32\taskkill.exe" /PID $runtime.Id /T /F | Out-Null throw "Runtime stop timed out for $root" } if ($runtime.ExitCode -ne 0) { throw "Runtime stop failed for $root (exit $($runtime.ExitCode))" } } function Remove-TestRoot([string]$root) { $deadline = (Get-Date).AddSeconds(5) do { try { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop return } catch { Start-Sleep -Milliseconds 300 } } while ((Get-Date) -lt $deadline) throw "Could not remove temporary test directory: $root" } function Wait-Http([string]$url, [int]$seconds = 15) { $deadline = (Get-Date).AddSeconds($seconds) do { try { return Invoke-RestMethod -Uri $url -TimeoutSec 3 } catch { Start-Sleep -Milliseconds 500 } } while ((Get-Date) -lt $deadline) throw "Timed out waiting for $url" } function Start-TrainingProcess([string]$filePath, [string[]]$argumentList, [string]$workingDirectory, [hashtable]$environment = $null) { # PowerShell 5.1 has no Start-Process -Environment. ProcessStartInfo gives # the same per-process isolation without changing this shell's environment. $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $filePath $startInfo.Arguments = ($argumentList -join ' ') $startInfo.WorkingDirectory = $workingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true if ($environment) { foreach ($entry in $environment.GetEnumerator()) { $startInfo.EnvironmentVariables[$entry.Key] = [string]$entry.Value } } return [System.Diagnostics.Process]::Start($startInfo) } function Set-EnvValue([string]$path, [string]$key, [string]$value) { $content = if (Test-Path $path) { Get-Content $path -Raw } else { '' } $line = "$key=$value" if ($content -match "(?m)^$([regex]::Escape($key))\s*=.*$") { $content = [regex]::Replace($content, "(?m)^$([regex]::Escape($key))\s*=.*$", $line) } else { $content = $content.TrimEnd() + [Environment]::NewLine + $line + [Environment]::NewLine } [IO.File]::WriteAllText($path, $content, [Text.UTF8Encoding]::new($false)) } function Read-EnvValue([string]$path, [string]$key) { $line = Get-Content -LiteralPath $path | Where-Object { $_ -match "^$([regex]::Escape($key))\s*=" } | Select-Object -Last 1 if (-not $line) { return '' } return ($line -replace "^$([regex]::Escape($key))\s*=\s*", '').Trim().Trim('"').Trim("'") } Stop-TrainingProcesses $TrainingDir $emptyRoot = Join-Path $env:TEMP ("qiwei-empty-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $emptyRoot | Out-Null Copy-Item $exe (Join-Path $emptyRoot 'qiwei-workbench.exe') New-Item -ItemType Directory -Path (Join-Path $emptyRoot 'web') | Out-Null Copy-Item (Join-Path $TrainingDir 'web\*') (Join-Path $emptyRoot 'web') [IO.File]::WriteAllText((Join-Path $emptyRoot '.env.local'), "QIWEI_API_BASE=https://server.fmode.cn/api/qiwei`nQIWEI_AUTH_TOKEN=`nQIWEI_UID=`nQIWEI_GUID=`n", [Text.UTF8Encoding]::new($false)) $empty = Start-Process -FilePath (Join-Path $emptyRoot 'qiwei-workbench.exe') -ArgumentList 'dashboard','--port','4432' -WorkingDirectory $emptyRoot -PassThru -WindowStyle Hidden try { $emptyStatus = Wait-Http 'http://127.0.0.1:4432/api/status?fast=true' $emptyAuth = Wait-Http 'http://127.0.0.1:4432/api/auth/token' if ($emptyStatus.summary.authConfigured -or $emptyAuth.data.configured) { throw 'Empty package borrowed a host credential' } } finally { Stop-TrainingRuntime $emptyRoot Stop-TrainingProcesses $emptyRoot Remove-TestRoot $emptyRoot } $conflictRoot = Join-Path $env:TEMP ("qiwei-conflict-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $conflictRoot | Out-Null Copy-Item $exe (Join-Path $conflictRoot 'qiwei-workbench.exe') New-Item -ItemType Directory -Path (Join-Path $conflictRoot 'web') | Out-Null Copy-Item (Join-Path $TrainingDir 'web\*') (Join-Path $conflictRoot 'web') [IO.File]::WriteAllText((Join-Path $conflictRoot '.env.local'), "QIWEI_API_BASE=https://file.invalid/api/qiwei`nQIWEI_AUTH_TOKEN=file-auth`nQIWEI_UID=file-uid`nQIWEI_GUID=file-guid`n", [Text.UTF8Encoding]::new($false)) $conflict = Start-TrainingProcess -FilePath (Join-Path $conflictRoot 'qiwei-workbench.exe') -ArgumentList @('dashboard','--port','4433') -WorkingDirectory $conflictRoot -Environment @{ QIWEI_AUTH_TOKEN = 'startup-auth' QIWEI_UID = 'startup-uid' QIWEI_GUID = 'startup-guid' QIWEI_API_BASE = 'https://startup.invalid/api/qiwei' } try { $first = Wait-Http 'http://127.0.0.1:4433/api/health' if ($first.data.activeAccountUid -ne 'file-uid') { throw 'Package file did not override startup uid' } Set-EnvValue (Join-Path $conflictRoot '.env.local') 'QIWEI_UID' 'file-uid-next' $second = Invoke-RestMethod -Uri 'http://127.0.0.1:4433/api/health' -TimeoutSec 5 if ($second.data.activeAccountUid -ne 'file-uid-next') { throw 'Updated package uid was not visible without restart' } } finally { Stop-TrainingRuntime $conflictRoot Stop-TrainingProcesses $conflictRoot Remove-TestRoot $conflictRoot } $real = Start-Process -FilePath $exe -ArgumentList 'dashboard','--port',"$DashboardPort" -WorkingDirectory $TrainingDir -PassThru -WindowStyle Hidden try { $realStatus = Wait-Http "http://127.0.0.1:$DashboardPort/api/status" $agentStatus = Wait-Http "http://127.0.0.1:$DashboardPort/api/agent/status" $result = [ordered]@{ emptyPackageIsolated = $true fileOverridesStartupEnvironment = $true fileUpdateVisibleWithoutRestart = $true real = [ordered]@{ dashboardPort = $DashboardPort authConfigured = [bool]$realStatus.summary.authConfigured subscribed = [bool]$realStatus.summary.subscribed trialActive = [bool]$realStatus.data.subscription.summary.trialActive source = $realStatus.data.subscription.summary.source seats = $realStatus.data.subscription.summary.seats usedSeats = $realStatus.data.subscription.summary.usedSeats online = [bool]$agentStatus.data.account.online statusCode = $agentStatus.data.account.statusCode reviewMode = $agentStatus.data.globalMode allowedSenderCount = $agentStatus.data.config.allowedSenderCount confirmedGroupCount = $agentStatus.data.config.confirmedGroupCount } } $result | ConvertTo-Json -Depth 8 } finally { Stop-TrainingRuntime $TrainingDir Stop-TrainingProcesses $TrainingDir }