feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
param(
|
||||
[string]$AuditDate = '2026-07-28'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$auditRoot = Join-Path $repoRoot "docs\audits\$AuditDate\openclaw-core-integration"
|
||||
$screenshotRoot = Join-Path $auditRoot 'screenshots'
|
||||
|
||||
function New-LabelledComparison {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$LeftPath,
|
||||
[Parameter(Mandatory)][string]$LeftLabel,
|
||||
[Parameter(Mandatory)][string]$RightPath,
|
||||
[Parameter(Mandatory)][string]$RightLabel,
|
||||
[Parameter(Mandatory)][string]$OutputPath
|
||||
)
|
||||
|
||||
$left = [System.Drawing.Image]::FromFile($LeftPath)
|
||||
$right = [System.Drawing.Image]::FromFile($RightPath)
|
||||
$canvas = New-Object System.Drawing.Bitmap 1440, 490
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($canvas)
|
||||
$font = New-Object System.Drawing.Font 'Segoe UI', 13, ([System.Drawing.FontStyle]::Bold)
|
||||
$labelBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(232, 234, 240))
|
||||
$backgroundBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(8, 7, 24))
|
||||
|
||||
try {
|
||||
$graphics.FillRectangle($backgroundBrush, 0, 0, $canvas.Width, $canvas.Height)
|
||||
$graphics.DrawString($LeftLabel, $font, $labelBrush, 12, 10)
|
||||
$graphics.DrawString($RightLabel, $font, $labelBrush, 732, 10)
|
||||
$graphics.DrawImage($left, 0, 40, 720, 450)
|
||||
$graphics.DrawImage($right, 720, 40, 720, 450)
|
||||
$canvas.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
}
|
||||
finally {
|
||||
$backgroundBrush.Dispose()
|
||||
$labelBrush.Dispose()
|
||||
$font.Dispose()
|
||||
$graphics.Dispose()
|
||||
$canvas.Dispose()
|
||||
$right.Dispose()
|
||||
$left.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function New-ContactSheet {
|
||||
param(
|
||||
[Parameter(Mandatory)][array]$Items,
|
||||
[Parameter(Mandatory)][int]$Columns,
|
||||
[Parameter(Mandatory)][int]$CellWidth,
|
||||
[Parameter(Mandatory)][int]$CellHeight,
|
||||
[Parameter(Mandatory)][string]$OutputPath
|
||||
)
|
||||
|
||||
$labelHeight = 28
|
||||
$rows = [Math]::Ceiling($Items.Count / $Columns)
|
||||
$canvas = New-Object System.Drawing.Bitmap ($Columns * $CellWidth), ($rows * ($CellHeight + $labelHeight))
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($canvas)
|
||||
$font = New-Object System.Drawing.Font 'Segoe UI', 10, ([System.Drawing.FontStyle]::Bold)
|
||||
$labelBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(224, 226, 238))
|
||||
$backgroundBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(8, 7, 24))
|
||||
|
||||
try {
|
||||
$graphics.FillRectangle($backgroundBrush, 0, 0, $canvas.Width, $canvas.Height)
|
||||
|
||||
for ($index = 0; $index -lt $Items.Count; $index++) {
|
||||
$item = $Items[$index]
|
||||
$column = $index % $Columns
|
||||
$row = [Math]::Floor($index / $Columns)
|
||||
$x = $column * $CellWidth
|
||||
$y = $row * ($CellHeight + $labelHeight)
|
||||
$image = [System.Drawing.Image]::FromFile($item.Path)
|
||||
|
||||
try {
|
||||
$graphics.DrawString($item.Label, $font, $labelBrush, $x + 8, $y + 6)
|
||||
$graphics.DrawImage($image, $x, $y + $labelHeight, $CellWidth, $CellHeight)
|
||||
}
|
||||
finally {
|
||||
$image.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$canvas.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
}
|
||||
finally {
|
||||
$backgroundBrush.Dispose()
|
||||
$labelBrush.Dispose()
|
||||
$font.Dispose()
|
||||
$graphics.Dispose()
|
||||
$canvas.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
New-LabelledComparison `
|
||||
-LeftPath (Join-Path $repoRoot 'docs\audits\2026-07-26\screenshots\00-dashboard-reference-1440.png') `
|
||||
-LeftLabel 'Reference - 1440 px' `
|
||||
-RightPath (Join-Path $screenshotRoot '02-dashboard-1440.png') `
|
||||
-RightLabel 'Current build - 1440 px' `
|
||||
-OutputPath (Join-Path $auditRoot 'dashboard-reference-vs-current.png')
|
||||
|
||||
New-LabelledComparison `
|
||||
-LeftPath (Join-Path $repoRoot 'docs\audits\2026-07-28\sites-mission-control\02-run-control.png') `
|
||||
-LeftLabel 'Reference mockup' `
|
||||
-RightPath (Join-Path $screenshotRoot '03-run-control-1440.png') `
|
||||
-RightLabel 'Current functional build' `
|
||||
-OutputPath (Join-Path $auditRoot 'run-control-mock-vs-current.png')
|
||||
|
||||
$coreItems = @(
|
||||
@{ Label = 'Dashboard'; Path = Join-Path $screenshotRoot '02-dashboard-1440.png' },
|
||||
@{ Label = 'Run Control'; Path = Join-Path $screenshotRoot '03-run-control-1440.png' },
|
||||
@{ Label = 'Agents'; Path = Join-Path $screenshotRoot '04-agents-1440.png' },
|
||||
@{ Label = 'Agent detail'; Path = Join-Path $screenshotRoot '05-agent-detail-1440.png' },
|
||||
@{ Label = 'Projects'; Path = Join-Path $screenshotRoot '06-projects-1440.png' },
|
||||
@{ Label = 'Project detail'; Path = Join-Path $screenshotRoot '07-project-detail-1440.png' },
|
||||
@{ Label = 'Task Board'; Path = Join-Path $screenshotRoot '08-task-board-1440.png' },
|
||||
@{ Label = 'Task detail'; Path = Join-Path $screenshotRoot '09-task-detail-1440.png' },
|
||||
@{ Label = 'Memory'; Path = Join-Path $screenshotRoot '10-memory-1440.png' },
|
||||
@{ Label = 'Docs'; Path = Join-Path $screenshotRoot '11-docs-1440.png' },
|
||||
@{ Label = 'Models'; Path = Join-Path $screenshotRoot '12-models-1440.png' },
|
||||
@{ Label = 'Activity'; Path = Join-Path $screenshotRoot '13-activity-1440.png' },
|
||||
@{ Label = 'Calendar'; Path = Join-Path $screenshotRoot '14-calendar-1440.png' },
|
||||
@{ Label = 'Security'; Path = Join-Path $screenshotRoot '15-security-1440.png' },
|
||||
@{ Label = 'Incidents'; Path = Join-Path $screenshotRoot '16-incidents-1440.png' },
|
||||
@{ Label = 'Notifications'; Path = Join-Path $screenshotRoot '17-notifications-1440.png' },
|
||||
@{ Label = 'Settings'; Path = Join-Path $screenshotRoot '18-settings-1440.png' }
|
||||
)
|
||||
|
||||
New-ContactSheet `
|
||||
-Items $coreItems `
|
||||
-Columns 4 `
|
||||
-CellWidth 360 `
|
||||
-CellHeight 225 `
|
||||
-OutputPath (Join-Path $auditRoot 'core-pages-contact-sheet.png')
|
||||
|
||||
$responsiveItems = @(
|
||||
@{ Label = 'Run Control - 375 px'; Path = Join-Path $screenshotRoot '19-run-control-375.png' },
|
||||
@{ Label = 'Run Control - 768 px'; Path = Join-Path $screenshotRoot '23-run-control-768.png' },
|
||||
@{ Label = 'Run Control - 1024 px'; Path = Join-Path $screenshotRoot '24-run-control-1024.png' },
|
||||
@{ Label = 'Run Control - 1920 px'; Path = Join-Path $screenshotRoot '25-run-control-1920.png' }
|
||||
)
|
||||
|
||||
New-ContactSheet `
|
||||
-Items $responsiveItems `
|
||||
-Columns 2 `
|
||||
-CellWidth 720 `
|
||||
-CellHeight 450 `
|
||||
-OutputPath (Join-Path $auditRoot 'run-control-responsive-contact-sheet.png')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Config = "evals/promptfoo/promptfooconfig.yaml",
|
||||
[switch]$ValidateOnly,
|
||||
[ValidateRange(1, 100)]
|
||||
[int]$Repeat = 1
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$configPath = if ([System.IO.Path]::IsPathRooted($Config)) {
|
||||
$Config
|
||||
} else {
|
||||
Join-Path $repoRoot $Config
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) {
|
||||
throw "Promptfoo config not found: $configPath"
|
||||
}
|
||||
|
||||
$providerPath = Join-Path (Split-Path -Parent $configPath) "nexus-provider.cjs"
|
||||
if (-not (Test-Path -LiteralPath $providerPath -PathType Leaf)) {
|
||||
throw "Promptfoo provider not found: $providerPath"
|
||||
}
|
||||
|
||||
& node --check $providerPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Promptfoo provider syntax validation failed."
|
||||
}
|
||||
|
||||
if (-not $ValidateOnly) {
|
||||
if ($env:NEXUS_EVAL_ALLOW_PROPOSALS -ne "1") {
|
||||
throw "Set NEXUS_EVAL_ALLOW_PROPOSALS=1 after selecting an isolated test database."
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($env:NEXUS_EVAL_BEARER_TOKEN)) {
|
||||
throw "Set NEXUS_EVAL_BEARER_TOKEN to a fresh owner JWT. The script never prints it."
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
if ($ValidateOnly) {
|
||||
& npx --yes "promptfoo@0.121.19" validate --config $configPath
|
||||
} else {
|
||||
& npx --yes "promptfoo@0.121.19" eval `
|
||||
--config $configPath `
|
||||
--max-concurrency 1 `
|
||||
--repeat $Repeat `
|
||||
--no-cache
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$operation = if ($ValidateOnly) { "validation" } else { "evaluation" }
|
||||
throw "Promptfoo $operation failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import http from 'k6/http'
|
||||
import { check, sleep } from 'k6'
|
||||
import { Rate, Trend } from 'k6/metrics'
|
||||
|
||||
const BASE_URL = (__ENV.NEXUS_BASE_URL || 'http://127.0.0.1:18880').replace(/\/+$/, '')
|
||||
const BEARER_TOKEN = (__ENV.NEXUS_BEARER_TOKEN || '').trim()
|
||||
const API_KEY = (__ENV.NEXUS_API_KEY || '').trim()
|
||||
const IS_SMOKE = (__ENV.NEXUS_K6_SMOKE || '').trim() === '1'
|
||||
const TARGET = new URL(BASE_URL)
|
||||
const IS_LOOPBACK = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(TARGET.hostname)
|
||||
const INSECURE_TLS = (__ENV.NEXUS_INSECURE_TLS || '').trim() === '1'
|
||||
const ALLOW_REMOTE = (__ENV.NEXUS_K6_ALLOW_REMOTE || '').trim() === '1'
|
||||
const REQUIRE_DONE_CURSOR = (__ENV.NEXUS_K6_REQUIRE_DONE_CURSOR || '').trim()
|
||||
? (__ENV.NEXUS_K6_REQUIRE_DONE_CURSOR || '').trim() === '1'
|
||||
: !IS_SMOKE
|
||||
|
||||
if (!BEARER_TOKEN && !API_KEY) {
|
||||
throw new Error(
|
||||
'Set NEXUS_BEARER_TOKEN or NEXUS_API_KEY. Credentials are read from the environment and are never printed.',
|
||||
)
|
||||
}
|
||||
if (!['http:', 'https:'].includes(TARGET.protocol)) {
|
||||
throw new Error('NEXUS_BASE_URL must use HTTP or HTTPS.')
|
||||
}
|
||||
if (
|
||||
TARGET.username
|
||||
|| TARGET.password
|
||||
|| TARGET.search
|
||||
|| TARGET.hash
|
||||
|| !['', '/'].includes(TARGET.pathname)
|
||||
) {
|
||||
throw new Error(
|
||||
'NEXUS_BASE_URL must be an origin without embedded credentials, path, query, or fragment.',
|
||||
)
|
||||
}
|
||||
if (!IS_LOOPBACK && !ALLOW_REMOTE) {
|
||||
throw new Error(
|
||||
'Remote k6 targets are blocked. Set NEXUS_K6_ALLOW_REMOTE=1 only for an isolated non-production environment.',
|
||||
)
|
||||
}
|
||||
if (!IS_LOOPBACK && TARGET.protocol !== 'https:') {
|
||||
throw new Error(
|
||||
'Remote k6 targets must use HTTPS because the test sends an authenticated credential.',
|
||||
)
|
||||
}
|
||||
if (!IS_LOOPBACK && INSECURE_TLS) {
|
||||
throw new Error('NEXUS_INSECURE_TLS is allowed only for loopback diagnostics.')
|
||||
}
|
||||
|
||||
const boardRequestDuration = new Trend('nexus_board_request_duration', true)
|
||||
const boardInitialDuration = new Trend('nexus_board_initial_duration', true)
|
||||
const boardDoneDuration = new Trend('nexus_board_done_duration', true)
|
||||
const boardFailures = new Rate('nexus_board_failures')
|
||||
const boardContractFailures = new Rate('nexus_board_contract_failures')
|
||||
const boardDatasetFailures = new Rate('nexus_board_dataset_failures')
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
task_board: IS_SMOKE
|
||||
? {
|
||||
executor: 'constant-vus',
|
||||
vus: 1,
|
||||
duration: '10s',
|
||||
gracefulStop: '5s',
|
||||
}
|
||||
: {
|
||||
executor: 'constant-vus',
|
||||
vus: 10,
|
||||
duration: '2m',
|
||||
gracefulStop: '15s',
|
||||
},
|
||||
},
|
||||
thresholds: IS_SMOKE
|
||||
? {
|
||||
nexus_board_failures: ['rate<0.05'],
|
||||
nexus_board_contract_failures: ['rate<0.05'],
|
||||
nexus_board_dataset_failures: ['rate<0.05'],
|
||||
}
|
||||
: {
|
||||
'http_req_duration{endpoint:task-board,page:initial}': ['p(95)<500'],
|
||||
'http_req_duration{endpoint:task-board,page:done}': ['p(95)<300'],
|
||||
'http_req_failed{endpoint:task-board,page:initial}': ['rate<0.01'],
|
||||
'http_req_failed{endpoint:task-board,page:done}': ['rate<0.01'],
|
||||
nexus_board_initial_duration: ['p(95)<500'],
|
||||
nexus_board_done_duration: ['p(95)<300'],
|
||||
nexus_board_failures: ['rate<0.01'],
|
||||
nexus_board_contract_failures: ['rate<0.01'],
|
||||
nexus_board_dataset_failures: ['rate<0.01'],
|
||||
checks: ['rate>0.99'],
|
||||
},
|
||||
userAgent: 'nexus-task-board-k6/1.0',
|
||||
insecureSkipTLSVerify: INSECURE_TLS,
|
||||
noConnectionReuse: false,
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
|
||||
if (BEARER_TOKEN) headers.Authorization = `Bearer ${BEARER_TOKEN}`
|
||||
if (API_KEY) headers['X-Nexus-Api-Key'] = API_KEY
|
||||
return headers
|
||||
}
|
||||
|
||||
function readBoardPage(url, page) {
|
||||
const response = http.get(url, {
|
||||
headers: authHeaders(),
|
||||
tags: {
|
||||
endpoint: 'task-board',
|
||||
page,
|
||||
},
|
||||
timeout: '10s',
|
||||
})
|
||||
|
||||
boardRequestDuration.add(response.timings.duration, { page })
|
||||
if (page === 'initial') boardInitialDuration.add(response.timings.duration)
|
||||
if (page === 'done') boardDoneDuration.add(response.timings.duration)
|
||||
const httpOk = response.status === 200
|
||||
boardFailures.add(!httpOk, { page })
|
||||
|
||||
let body = null
|
||||
if (httpOk) {
|
||||
try {
|
||||
body = response.json()
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
|
||||
const contractOk = Boolean(
|
||||
body
|
||||
&& typeof body.revision === 'string'
|
||||
&& Array.isArray(body.offen)
|
||||
&& Array.isArray(body.inProgress)
|
||||
&& Array.isArray(body.review)
|
||||
&& Array.isArray(body.blocked)
|
||||
&& Array.isArray(body.done)
|
||||
&& typeof body.hasMoreDone === 'boolean',
|
||||
)
|
||||
boardContractFailures.add(!contractOk, { page })
|
||||
|
||||
check(response, {
|
||||
[`${page}: returns 200`]: () => httpOk,
|
||||
[`${page}: preserves the board contract`]: () => contractOk,
|
||||
[`${page}: emits Server-Timing`]: res => Boolean(res.headers['Server-Timing']),
|
||||
})
|
||||
|
||||
return contractOk ? body : null
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const firstPage = readBoardPage(
|
||||
`${BASE_URL}/api/v1/tasks/board?doneLimit=50`,
|
||||
'initial',
|
||||
)
|
||||
|
||||
const hasDoneCursor = Boolean(firstPage?.hasMoreDone && firstPage.nextDoneCursor)
|
||||
const datasetOk = !REQUIRE_DONE_CURSOR || hasDoneCursor
|
||||
boardDatasetFailures.add(!datasetOk)
|
||||
check(firstPage, {
|
||||
'acceptance dataset exposes a Done continuation cursor': () => datasetOk,
|
||||
})
|
||||
|
||||
if (hasDoneCursor) {
|
||||
readBoardPage(
|
||||
`${BASE_URL}/api/v1/tasks/board?doneLimit=50&doneCursor=${encodeURIComponent(firstPage.nextDoneCursor)}`,
|
||||
'done',
|
||||
)
|
||||
}
|
||||
|
||||
sleep(0.75 + Math.random() * 0.5)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
#requires -Version 7.0
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Pin", "Baseline", "Candidate")]
|
||||
[string]$Mode = "Pin",
|
||||
[string]$CandidateVersion = "2.0.0",
|
||||
[switch]$KeepTemporaryCopy
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$stableVersion = "1.4.1"
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$projectPath = Join-Path $repoRoot "backend\Nexus.Api.csproj"
|
||||
$testProjectPath = Join-Path $repoRoot "backend-tests\Nexus.Api.Tests.csproj"
|
||||
$projectHashBefore = (Get-FileHash -LiteralPath $projectPath -Algorithm SHA256).Hash
|
||||
$temporaryRoot = $null
|
||||
$dotnetExe = $null
|
||||
|
||||
function Get-McpPackageVersion {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
|
||||
[xml]$project = Get-Content -LiteralPath $Path -Raw
|
||||
$reference = @(
|
||||
@($project.Project.ItemGroup.PackageReference) |
|
||||
Where-Object { $_.Include -eq "ModelContextProtocol.AspNetCore" }
|
||||
)
|
||||
if ($reference.Count -ne 1) {
|
||||
throw "Expected exactly one ModelContextProtocol.AspNetCore reference in $Path."
|
||||
}
|
||||
return $reference[0].GetAttribute("Version")
|
||||
}
|
||||
|
||||
function Assert-StaticGate {
|
||||
$version = Get-McpPackageVersion -Path $projectPath
|
||||
if ($version -ne $stableVersion) {
|
||||
throw "MCP production pin must remain $stableVersion; found $version."
|
||||
}
|
||||
|
||||
$testFiles = @(
|
||||
"backend-tests\McpServerConfigurationTests.cs",
|
||||
"backend-tests\McpToolsTests.cs",
|
||||
"backend-tests\AgentProposalServiceTests.cs"
|
||||
)
|
||||
foreach ($relativePath in $testFiles) {
|
||||
$path = Join-Path $repoRoot $relativePath
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "Required MCP compatibility coverage is missing: $relativePath"
|
||||
}
|
||||
}
|
||||
|
||||
$toolSource = Get-Content -LiteralPath (Join-Path $repoRoot "backend\Services\NexusMcpTools.cs") -Raw
|
||||
foreach ($marker in @(
|
||||
"nexus_propose_agent",
|
||||
"nexus_get_agent_proposal",
|
||||
"UseStructuredContent = true",
|
||||
"OutputSchemaType = typeof(AgentProposalToolResult)",
|
||||
"Idempotent = true",
|
||||
"OpenWorld = false"
|
||||
)) {
|
||||
if (-not $toolSource.Contains($marker, [StringComparison]::Ordinal)) {
|
||||
throw "Required MCP contract marker is missing: $marker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-DotNet10 {
|
||||
$candidates = @()
|
||||
$pathDotnet = Get-Command dotnet -ErrorAction SilentlyContinue
|
||||
if ($pathDotnet) {
|
||||
$candidates += $pathDotnet.Source
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
|
||||
$candidates += (Join-Path $env:USERPROFILE ".dotnet\dotnet.exe")
|
||||
}
|
||||
|
||||
foreach ($candidate in $candidates | Select-Object -Unique) {
|
||||
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) {
|
||||
continue
|
||||
}
|
||||
$sdks = & $candidate --list-sdks
|
||||
if ($LASTEXITCODE -eq 0 -and ($sdks | Where-Object { $_ -match "^10\." })) {
|
||||
return (Resolve-Path -LiteralPath $candidate).Path
|
||||
}
|
||||
}
|
||||
|
||||
throw ".NET SDK 10 is required for Baseline and Candidate modes. Add it to PATH or install it under USERPROFILE\\.dotnet."
|
||||
}
|
||||
|
||||
function Invoke-DotNetTest {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Project,
|
||||
[Parameter(Mandatory)][string]$WorkingDirectory
|
||||
)
|
||||
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& $script:dotnetExe test $Project `
|
||||
--configuration Release `
|
||||
--verbosity minimal
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet test failed for $Project."
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-SourceTree {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Source,
|
||||
[Parameter(Mandatory)][string]$Destination
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
|
||||
$sourceRoot = [System.IO.Path]::GetFullPath($Source)
|
||||
Get-ChildItem -LiteralPath $sourceRoot -Recurse -File |
|
||||
Where-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($sourceRoot, $_.FullName)
|
||||
$segments = $relative -split "[\\/]"
|
||||
-not ($segments -contains "bin" -or $segments -contains "obj")
|
||||
} |
|
||||
ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($sourceRoot, $_.FullName)
|
||||
$target = Join-Path $Destination $relative
|
||||
$targetDirectory = Split-Path -Parent $target
|
||||
New-Item -ItemType Directory -Path $targetDirectory -Force | Out-Null
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $target -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Set-CandidatePackageVersion {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Parameter(Mandatory)][string]$Version
|
||||
)
|
||||
|
||||
[xml]$project = Get-Content -LiteralPath $Path -Raw
|
||||
$reference = @(
|
||||
@($project.Project.ItemGroup.PackageReference) |
|
||||
Where-Object { $_.Include -eq "ModelContextProtocol.AspNetCore" }
|
||||
)
|
||||
if ($reference.Count -ne 1) {
|
||||
throw "Candidate copy does not contain exactly one MCP package reference."
|
||||
}
|
||||
$reference[0].SetAttribute("Version", $Version)
|
||||
$project.Save($Path)
|
||||
}
|
||||
|
||||
try {
|
||||
Assert-StaticGate
|
||||
Write-Host "PASS: MCP production dependency is pinned to $stableVersion and required source/test markers are present."
|
||||
Write-Host "Static Pin mode did not execute tests or a live MCP/OpenClaw negotiation."
|
||||
|
||||
if ($Mode -eq "Pin") {
|
||||
return
|
||||
}
|
||||
|
||||
$dotnetExe = Resolve-DotNet10
|
||||
# MSBuild launches helper applications (for example the OpenAPI document
|
||||
# generator) through `dotnet`, not through the absolute executable used by
|
||||
# this script. Keep those child processes on the same .NET 10 runtime.
|
||||
$dotnetRoot = Split-Path -Parent $dotnetExe
|
||||
$env:DOTNET_ROOT = $dotnetRoot
|
||||
$pathSeparator = [System.IO.Path]::PathSeparator
|
||||
if (-not (($env:PATH -split [Regex]::Escape([string]$pathSeparator)) -contains $dotnetRoot)) {
|
||||
$env:PATH = "$dotnetRoot$pathSeparator$env:PATH"
|
||||
}
|
||||
Invoke-DotNetTest -Project $testProjectPath -WorkingDirectory $repoRoot
|
||||
Write-Host "PASS: MCP $stableVersion baseline command completed successfully."
|
||||
Write-Warning "Environment-gated Docker, Toxiproxy, or live OpenClaw tests may still be skipped; inspect the test summary before treating this as full compatibility evidence."
|
||||
|
||||
if ($Mode -eq "Baseline") {
|
||||
return
|
||||
}
|
||||
|
||||
if ($CandidateVersion -notmatch "^2\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") {
|
||||
throw "CandidateVersion must be an explicit MCP 2.x package version."
|
||||
}
|
||||
|
||||
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "nexus-mcp2-gate-$([Guid]::NewGuid().ToString('N'))"
|
||||
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
|
||||
Copy-SourceTree -Source (Join-Path $repoRoot "backend") -Destination (Join-Path $temporaryRoot "backend")
|
||||
Copy-SourceTree -Source (Join-Path $repoRoot "backend-tests") -Destination (Join-Path $temporaryRoot "backend-tests")
|
||||
|
||||
$candidateProject = Join-Path $temporaryRoot "backend\Nexus.Api.csproj"
|
||||
$candidateTests = Join-Path $temporaryRoot "backend-tests\Nexus.Api.Tests.csproj"
|
||||
Set-CandidatePackageVersion -Path $candidateProject -Version $CandidateVersion
|
||||
|
||||
& $dotnetExe restore $candidateTests --force-evaluate
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "MCP $CandidateVersion restore failed in the isolated copy."
|
||||
}
|
||||
|
||||
$assetsPath = Join-Path $temporaryRoot "backend\obj\project.assets.json"
|
||||
if (-not (Test-Path -LiteralPath $assetsPath -PathType Leaf)) {
|
||||
throw "Candidate restore did not create project.assets.json."
|
||||
}
|
||||
$resolvedMarker = "`"ModelContextProtocol.AspNetCore/$CandidateVersion`""
|
||||
$assets = Get-Content -LiteralPath $assetsPath -Raw
|
||||
if (-not $assets.Contains($resolvedMarker, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Restore did not resolve the exact requested candidate $CandidateVersion."
|
||||
}
|
||||
|
||||
Invoke-DotNetTest -Project $candidateTests -WorkingDirectory $temporaryRoot
|
||||
|
||||
if ($CandidateVersion.Contains("-", [StringComparison]::Ordinal)) {
|
||||
Write-Warning "COMPATIBILITY PROBE PASS: $CandidateVersion compiled and passed all backend tests."
|
||||
Write-Warning "PROMOTION GATE CLOSED: prerelease packages are not approved for production. The repository remains on $stableVersion."
|
||||
} else {
|
||||
Write-Host "SDK COMPATIBILITY PROBE PASS: stable candidate $CandidateVersion compiled and passed the backend suite."
|
||||
Write-Warning "LIVE OPENCLAW GATE CLOSED: this probe does not test OpenClaw tools/list, client identity, Streamable HTTP, or protocol down-level negotiation."
|
||||
Write-Host "The repository was not modified; live isolated OpenClaw compatibility evidence and a separate reviewed dependency change are still required."
|
||||
}
|
||||
} finally {
|
||||
$projectHashAfter = (Get-FileHash -LiteralPath $projectPath -Algorithm SHA256).Hash
|
||||
if ($projectHashAfter -ne $projectHashBefore) {
|
||||
throw "Safety invariant violated: $projectPath changed during the compatibility gate."
|
||||
}
|
||||
|
||||
if ($temporaryRoot -and (Test-Path -LiteralPath $temporaryRoot)) {
|
||||
if ($KeepTemporaryCopy) {
|
||||
Write-Host "Temporary candidate copy retained at $temporaryRoot"
|
||||
} else {
|
||||
$resolvedTemporaryRoot = [System.IO.Path]::GetFullPath($temporaryRoot)
|
||||
$systemTemporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
|
||||
if (
|
||||
-not $resolvedTemporaryRoot.StartsWith($systemTemporaryRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not ([System.IO.Path]::GetFileName($resolvedTemporaryRoot)).StartsWith(
|
||||
"nexus-mcp2-gate-",
|
||||
[StringComparison]::Ordinal)
|
||||
) {
|
||||
throw "Refusing to remove an unverified temporary path: $resolvedTemporaryRoot"
|
||||
}
|
||||
Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user