730 lines
33 KiB
PowerShell
730 lines
33 KiB
PowerShell
param(
|
|
[string]$RemoteHost = "192.168.3.90",
|
|
[string]$RemoteUser = "root",
|
|
[string]$BackendContainer = "ai-contract-backend-restore",
|
|
[string]$SharedAIContainer = "shared-ai-platform",
|
|
[string]$BackendBaseUrl = "http://192.168.3.90:8001",
|
|
[string]$FrontendBaseUrl = "http://192.168.3.90:3003",
|
|
[string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519",
|
|
[switch]$UseSshpass,
|
|
[string]$TenantId = "tenant-demo-001",
|
|
[int]$ChatWaitSeconds = 35,
|
|
[string]$ArtifactRoot = "",
|
|
[switch]$RequireCleanWorktree,
|
|
[switch]$SkipProductE2E
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
function Add-Check {
|
|
param(
|
|
[System.Collections.Generic.List[object]]$Checks,
|
|
[string]$Key,
|
|
[string]$Label,
|
|
[ValidateSet("pass", "fail", "warn", "skip", "blocked")]
|
|
[string]$Status,
|
|
[string]$Detail,
|
|
[bool]$Required = $true,
|
|
[hashtable]$Meta = @{}
|
|
)
|
|
|
|
$Checks.Add([ordered]@{
|
|
key = $Key
|
|
label = $Label
|
|
status = $Status
|
|
required = $Required
|
|
detail = $Detail
|
|
meta = $Meta
|
|
}) | Out-Null
|
|
}
|
|
|
|
function Save-Json {
|
|
param([string]$Path, [object]$Value)
|
|
$Value | ConvertTo-Json -Depth 100 | Set-Content -Encoding UTF8 -Path $Path
|
|
}
|
|
|
|
function Read-JsonFile {
|
|
param([string]$Path)
|
|
return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
}
|
|
|
|
function ConvertTo-ShellSingleQuote {
|
|
param([string]$Value)
|
|
return "'" + ($Value -replace "'", "'`"`"`'") + "'"
|
|
}
|
|
|
|
function Resolve-SshpassPath {
|
|
$command = Get-Command sshpass.exe -ErrorAction SilentlyContinue
|
|
if ($null -ne $command) {
|
|
return $command.Source
|
|
}
|
|
|
|
$candidates = @(
|
|
(Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Packages\xhcoding.sshpass-win32_Microsoft.Winget.Source_8wekyb3d8bbwe\sshpass.exe")
|
|
)
|
|
foreach ($candidate in $candidates) {
|
|
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
|
return (Resolve-Path -LiteralPath $candidate).Path
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
function Resolve-RemoteAuth {
|
|
if ($UseSshpass -or -not [string]::IsNullOrWhiteSpace($env:SSHPASS)) {
|
|
$sshpassPath = Resolve-SshpassPath
|
|
if ([string]::IsNullOrWhiteSpace($sshpassPath)) {
|
|
throw "sshpass.exe not found. Install sshpass or use SSH key auth."
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($env:SSHPASS)) {
|
|
throw "SSHPASS environment variable is empty."
|
|
}
|
|
return [ordered]@{
|
|
mode = "sshpass"
|
|
ssh_file = $sshpassPath
|
|
scp_file = $sshpassPath
|
|
ssh_prefix = @("-e", "ssh")
|
|
scp_prefix = @("-e", "scp")
|
|
ssh_common = @("-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
|
|
scp_common = @("-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
|
|
}
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $SshKeyPath -PathType Leaf) {
|
|
return [ordered]@{
|
|
mode = "key"
|
|
ssh_file = "ssh"
|
|
scp_file = "scp"
|
|
ssh_prefix = @("-i", $SshKeyPath)
|
|
scp_prefix = @("-i", $SshKeyPath)
|
|
ssh_common = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
|
|
scp_common = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
|
|
}
|
|
}
|
|
|
|
throw "SSH key not found and SSHPASS is not configured."
|
|
}
|
|
|
|
function ConvertTo-ProcessArgument {
|
|
param([AllowNull()][string]$Value)
|
|
|
|
if ($null -eq $Value) {
|
|
return '""'
|
|
}
|
|
|
|
$text = [string]$Value
|
|
if ($text.Length -eq 0) {
|
|
return '""'
|
|
}
|
|
|
|
if ($text -notmatch '[\s"]') {
|
|
return $text
|
|
}
|
|
|
|
return '"' + $text.Replace('"', '\"') + '"'
|
|
}
|
|
|
|
function Invoke-LoggedCommand {
|
|
param(
|
|
[string]$Name,
|
|
[string]$FilePath,
|
|
[string[]]$Arguments,
|
|
[string]$LogDir,
|
|
[switch]$AllowFailure
|
|
)
|
|
|
|
$logPath = Join-Path $LogDir "$Name.log"
|
|
$stdoutPath = Join-Path $LogDir "$Name.stdout.log"
|
|
$stderrPath = Join-Path $LogDir "$Name.stderr.log"
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
$quotedArguments = @($Arguments | ForEach-Object { ConvertTo-ProcessArgument $_ })
|
|
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments -NoNewWindow -Wait -PassThru -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath
|
|
$exitCode = if ($process -and $process.ExitCode -is [int]) { $process.ExitCode } else { 0 }
|
|
} finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
}
|
|
|
|
$stdoutText = if (Test-Path -LiteralPath $stdoutPath -PathType Leaf) { Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue } else { "" }
|
|
$stderrText = if (Test-Path -LiteralPath $stderrPath -PathType Leaf) { Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue } else { "" }
|
|
$outputText = (@($stdoutText, $stderrText) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine
|
|
$outputText | Set-Content -Encoding UTF8 -Path $logPath
|
|
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
|
throw "$Name failed with exit code $exitCode. See $logPath"
|
|
}
|
|
return [ordered]@{
|
|
exit_code = $exitCode
|
|
output = $outputText
|
|
log = $logPath
|
|
}
|
|
}
|
|
|
|
function Invoke-HttpProbe {
|
|
param([string]$Url)
|
|
try {
|
|
$response = Invoke-WebRequest -UseBasicParsing -Method Get -Uri $Url -TimeoutSec 30
|
|
return [ordered]@{
|
|
status_code = [int]$response.StatusCode
|
|
ok = ([int]$response.StatusCode -ge 200 -and [int]$response.StatusCode -lt 400)
|
|
content_length = if ($null -ne $response.Content) { $response.Content.Length } else { 0 }
|
|
}
|
|
} catch {
|
|
$statusCode = $null
|
|
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
|
|
$statusCode = [int]$_.Exception.Response.StatusCode
|
|
}
|
|
return [ordered]@{
|
|
status_code = $statusCode
|
|
ok = $false
|
|
error = $_.Exception.Message
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-LatestArtifactDirectory {
|
|
param([string]$Root)
|
|
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
|
|
return $null
|
|
}
|
|
return Get-ChildItem -LiteralPath $Root -Directory |
|
|
Sort-Object LastWriteTime -Descending |
|
|
Select-Object -First 1
|
|
}
|
|
|
|
function New-ReleaseInventoryPython {
|
|
param([string]$Path)
|
|
|
|
$script = @'
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List
|
|
|
|
from sqlalchemy import func
|
|
|
|
from app.db import SessionLocal
|
|
from app.models.contract_template import ContractTemplate
|
|
from app.models.legal_article import LegalArticle
|
|
from app.models.legal_document_type import LegalDocumentType
|
|
from app.models.prompt_asset import PromptAsset
|
|
from app.models.prompt_asset_version import PromptAssetVersion
|
|
from app.services.legal_article_service import LegalArticleService
|
|
from app.services.prompt_runtime_service import PromptRuntimeService
|
|
|
|
|
|
FOCUS_DOCUMENT_TYPES = [
|
|
"investment_agreement",
|
|
"valuation_adjustment_agreement",
|
|
"shareholder_resolution",
|
|
"power_of_attorney",
|
|
]
|
|
|
|
LEGAL_SEARCH_CASES = [
|
|
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u6c11\u6cd5\u5178 \u7b2c\u4e94\u767e\u96f6\u4e5d\u6761 \u5408\u540c\u5c65\u884c",
|
|
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u516c\u53f8\u6cd5 \u80a1\u4e1c\u4f1a \u51b3\u8bae",
|
|
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u52b3\u52a8\u5408\u540c\u6cd5 \u4e66\u9762\u52b3\u52a8\u5408\u540c",
|
|
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u4e2a\u4eba\u4fe1\u606f\u4fdd\u62a4\u6cd5 \u4e2a\u4eba\u4fe1\u606f\u5904\u7406",
|
|
]
|
|
|
|
|
|
def load_catalog(path: str) -> list[dict[str, Any]]:
|
|
with open(path, "r", encoding="utf-8-sig") as handle:
|
|
payload = json.load(handle)
|
|
assets = payload.get("assets") or []
|
|
if not isinstance(assets, list) or not assets:
|
|
raise RuntimeError("Prompt asset catalog is empty or invalid")
|
|
return assets
|
|
|
|
|
|
def to_bool(value: Any) -> bool:
|
|
return bool(value)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--catalog-path", required=True)
|
|
parser.add_argument("--output-dir", required=True)
|
|
parser.add_argument("--tenant-id", required=True)
|
|
args = parser.parse_args()
|
|
|
|
os.makedirs(args.output_dir, exist_ok=True)
|
|
assets = load_catalog(args.catalog_path)
|
|
expected_keys = [str(item.get("prompt_key") or "").strip() for item in assets if str(item.get("prompt_key") or "").strip()]
|
|
expected_by_key = {str(item.get("prompt_key")): item for item in assets if item.get("prompt_key")}
|
|
current_user = {
|
|
"id": "phase15-release-gate",
|
|
"tenant_id": args.tenant_id,
|
|
"display_name": "Phase15 Release Gate",
|
|
"role": "admin",
|
|
}
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
prompt_rows = (
|
|
db.query(PromptAsset)
|
|
.filter(PromptAsset.tenant_id == args.tenant_id, PromptAsset.prompt_key.in_(expected_keys))
|
|
.all()
|
|
)
|
|
prompts_by_key = {row.prompt_key: row for row in prompt_rows}
|
|
missing_prompt_keys = sorted(set(expected_keys) - set(prompts_by_key.keys()))
|
|
not_published: list[dict[str, Any]] = []
|
|
published_version_ids: list[str] = []
|
|
by_type: dict[str, dict[str, int]] = {}
|
|
for key in expected_keys:
|
|
expected = expected_by_key[key]
|
|
prompt_type = str(expected.get("prompt_type") or "")
|
|
by_type.setdefault(prompt_type, {"expected": 0, "found": 0, "published": 0})
|
|
by_type[prompt_type]["expected"] += 1
|
|
row = prompts_by_key.get(key)
|
|
if row is None:
|
|
continue
|
|
by_type[prompt_type]["found"] += 1
|
|
if row.status == "published" and row.published_version_id:
|
|
by_type[prompt_type]["published"] += 1
|
|
published_version_ids.append(row.published_version_id)
|
|
else:
|
|
not_published.append({"prompt_key": key, "status": row.status, "published_version_id": row.published_version_id})
|
|
|
|
published_versions = []
|
|
if published_version_ids:
|
|
published_versions = (
|
|
db.query(PromptAssetVersion)
|
|
.filter(PromptAssetVersion.tenant_id == args.tenant_id, PromptAssetVersion.id.in_(published_version_ids))
|
|
.all()
|
|
)
|
|
published_version_count = len([item for item in published_versions if item.is_published])
|
|
|
|
runtime_service = PromptRuntimeService(db)
|
|
runtime_results: list[dict[str, Any]] = []
|
|
runtime_failures: list[dict[str, Any]] = []
|
|
for key in expected_keys:
|
|
prompt_type = str(expected_by_key[key].get("prompt_type") or "")
|
|
runtime = runtime_service.resolve_published_prompt(
|
|
current_user,
|
|
prompt_type,
|
|
preferred_keys=[key],
|
|
fallback_prompt_version=f"{key}.local_default",
|
|
)
|
|
ok = (
|
|
runtime.get("available") is True
|
|
and runtime.get("source") == "published_prompt_asset"
|
|
and runtime.get("prompt_key") == key
|
|
and runtime.get("match_strategy") == "preferred_key"
|
|
)
|
|
item = {
|
|
"prompt_key": key,
|
|
"prompt_type": prompt_type,
|
|
"ok": ok,
|
|
"source": runtime.get("source"),
|
|
"match_strategy": runtime.get("match_strategy"),
|
|
"prompt_version": runtime.get("prompt_version"),
|
|
"prompt_asset_id": runtime.get("prompt_asset_id"),
|
|
}
|
|
runtime_results.append(item)
|
|
if not ok:
|
|
runtime_failures.append(item)
|
|
|
|
active_document_types = db.query(func.count(LegalDocumentType.id)).filter(LegalDocumentType.is_active.is_(True)).scalar() or 0
|
|
enabled_templates = (
|
|
db.query(func.count(ContractTemplate.id))
|
|
.filter(ContractTemplate.tenant_id == args.tenant_id, ContractTemplate.enabled.is_(True))
|
|
.scalar()
|
|
or 0
|
|
)
|
|
focus_results = []
|
|
for code in FOCUS_DOCUMENT_TYPES:
|
|
document_type = db.query(LegalDocumentType).filter(LegalDocumentType.code == code, LegalDocumentType.is_active.is_(True)).first()
|
|
template_count = (
|
|
db.query(func.count(ContractTemplate.id))
|
|
.filter(
|
|
ContractTemplate.tenant_id == args.tenant_id,
|
|
ContractTemplate.document_type_code == code,
|
|
ContractTemplate.enabled.is_(True),
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
focus_results.append(
|
|
{
|
|
"document_type_code": code,
|
|
"document_type_exists": document_type is not None,
|
|
"enabled_template_count": int(template_count),
|
|
"ok": document_type is not None and int(template_count) > 0,
|
|
}
|
|
)
|
|
|
|
published_indexed_count = (
|
|
db.query(func.count(LegalArticle.id))
|
|
.filter(
|
|
LegalArticle.tenant_id == args.tenant_id,
|
|
LegalArticle.published_status == "published",
|
|
LegalArticle.index_status == "indexed",
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
legal_service = LegalArticleService(db)
|
|
search_results = []
|
|
for query in LEGAL_SEARCH_CASES:
|
|
items = legal_service.search_runtime_sources(current_user, query, limit=5)
|
|
search_results.append(
|
|
{
|
|
"query": query,
|
|
"count": len(items),
|
|
"top_source_title": items[0].get("source_title") if items else None,
|
|
"top_article_no": items[0].get("article_no") if items else None,
|
|
"ok": len(items) > 0,
|
|
}
|
|
)
|
|
|
|
checks = {
|
|
"prompt_assets_36_36_published": len(expected_keys) == 36 and len(prompt_rows) == 36 and len(not_published) == 0 and published_version_count == 36,
|
|
"prompt_runtime_36_36_resolved": len(runtime_failures) == 0 and len(runtime_results) == 36,
|
|
"template_document_type_baseline": int(active_document_types) >= 18 and int(enabled_templates) >= 18 and all(item["ok"] for item in focus_results),
|
|
"legal_library_searchable": int(published_indexed_count) > 0 and all(item["ok"] for item in search_results),
|
|
}
|
|
status = "PASS" if all(checks.values()) else "FAIL"
|
|
summary = {
|
|
"status": status,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"tenant_id": args.tenant_id,
|
|
"prompt_assets": {
|
|
"catalog_total": len(expected_keys),
|
|
"found": len(prompt_rows),
|
|
"published": len(prompt_rows) - len(not_published),
|
|
"published_versions": published_version_count,
|
|
"runtime_resolved": len(runtime_results) - len(runtime_failures),
|
|
"missing_prompt_keys": missing_prompt_keys,
|
|
"not_published": not_published,
|
|
"runtime_failures": runtime_failures,
|
|
"by_type": by_type,
|
|
},
|
|
"templates_and_document_types": {
|
|
"active_document_types": int(active_document_types),
|
|
"enabled_templates": int(enabled_templates),
|
|
"focus_document_types": focus_results,
|
|
"focus_passed": sum(1 for item in focus_results if item["ok"]),
|
|
"focus_total": len(focus_results),
|
|
},
|
|
"legal_articles": {
|
|
"published_indexed_count": int(published_indexed_count),
|
|
"search_cases_passed": sum(1 for item in search_results if item["ok"]),
|
|
"search_cases_total": len(search_results),
|
|
"search_results": search_results,
|
|
},
|
|
"checks": checks,
|
|
}
|
|
output_path = os.path.join(args.output_dir, "phase15-release-gate-inventory.json")
|
|
with open(output_path, "w", encoding="utf-8") as handle:
|
|
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
|
print(json.dumps({"status": status, "output_path": output_path}, ensure_ascii=False))
|
|
return 0 if status == "PASS" else 1
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
'@
|
|
|
|
$script | Set-Content -Encoding UTF8 -Path $Path
|
|
}
|
|
|
|
function Invoke-ReleaseInventoryCheck {
|
|
param(
|
|
[string]$ArtifactDir,
|
|
[string]$RepoRoot,
|
|
[string]$Timestamp
|
|
)
|
|
|
|
$catalogPath = Join-Path $RepoRoot "project-docs\phase15-prompt-assets\default-prompt-assets.json"
|
|
if (-not (Test-Path -LiteralPath $catalogPath -PathType Leaf)) {
|
|
throw "Prompt asset catalog not found: $catalogPath"
|
|
}
|
|
|
|
$auth = Resolve-RemoteAuth
|
|
$target = "${RemoteUser}@${RemoteHost}"
|
|
$remoteWorkDir = "/tmp/ai-contract-phase15-release-gate-$Timestamp"
|
|
$inventoryScriptPath = Join-Path $ArtifactDir "phase15_release_gate_inventory.py"
|
|
New-ReleaseInventoryPython -Path $inventoryScriptPath
|
|
|
|
Invoke-LoggedCommand -Name "remote-inventory-mkdir" -FilePath $auth.ssh_file -Arguments ($auth.ssh_prefix + $auth.ssh_common + @($target, "rm -rf $(ConvertTo-ShellSingleQuote $remoteWorkDir) && mkdir -p $(ConvertTo-ShellSingleQuote $remoteWorkDir)")) -LogDir $ArtifactDir | Out-Null
|
|
Invoke-LoggedCommand -Name "scp-inventory-script" -FilePath $auth.scp_file -Arguments ($auth.scp_prefix + $auth.scp_common + @($inventoryScriptPath, "${target}:$remoteWorkDir/phase15_release_gate_inventory.py")) -LogDir $ArtifactDir | Out-Null
|
|
Invoke-LoggedCommand -Name "scp-prompt-catalog" -FilePath $auth.scp_file -Arguments ($auth.scp_prefix + $auth.scp_common + @($catalogPath, "${target}:$remoteWorkDir/default-prompt-assets.json")) -LogDir $ArtifactDir | Out-Null
|
|
|
|
$containerScript = "$remoteWorkDir/phase15_release_gate_inventory.py"
|
|
$containerCatalog = "$remoteWorkDir/default-prompt-assets.json"
|
|
$containerOutput = "$remoteWorkDir/output"
|
|
$containerRunCommand = "cd /app && PYTHONPATH=/app python $(ConvertTo-ShellSingleQuote $containerScript) --catalog-path $(ConvertTo-ShellSingleQuote $containerCatalog) --output-dir $(ConvertTo-ShellSingleQuote $containerOutput) --tenant-id $(ConvertTo-ShellSingleQuote $TenantId)"
|
|
$remoteCommandParts = @(
|
|
"set +e",
|
|
"docker exec $BackendContainer sh -lc $(ConvertTo-ShellSingleQuote "rm -rf $remoteWorkDir && mkdir -p $containerOutput")",
|
|
"prep_exit=`$?",
|
|
"if [ `$prep_exit -ne 0 ]; then exit `$prep_exit; fi",
|
|
"docker cp $remoteWorkDir/phase15_release_gate_inventory.py ${BackendContainer}:$containerScript",
|
|
"copy_script_exit=`$?",
|
|
"if [ `$copy_script_exit -ne 0 ]; then exit `$copy_script_exit; fi",
|
|
"docker cp $remoteWorkDir/default-prompt-assets.json ${BackendContainer}:$containerCatalog",
|
|
"copy_catalog_exit=`$?",
|
|
"if [ `$copy_catalog_exit -ne 0 ]; then exit `$copy_catalog_exit; fi",
|
|
"docker exec $BackendContainer sh -lc $(ConvertTo-ShellSingleQuote $containerRunCommand)",
|
|
"inventory_exit=`$?",
|
|
"docker cp ${BackendContainer}:$containerOutput $remoteWorkDir/output >/dev/null 2>&1 || true",
|
|
"exit `$inventory_exit"
|
|
)
|
|
$remoteRun = Invoke-LoggedCommand -Name "remote-release-inventory" -FilePath $auth.ssh_file -Arguments ($auth.ssh_prefix + $auth.ssh_common + @($target, ($remoteCommandParts -join " ; "))) -LogDir $ArtifactDir -AllowFailure
|
|
Invoke-LoggedCommand -Name "scp-inventory-output" -FilePath $auth.scp_file -Arguments ($auth.scp_prefix + $auth.scp_common + @("-r", "${target}:$remoteWorkDir/output/.", $ArtifactDir)) -LogDir $ArtifactDir | Out-Null
|
|
|
|
$summaryPath = Join-Path $ArtifactDir "phase15-release-gate-inventory.json"
|
|
if (-not (Test-Path -LiteralPath $summaryPath -PathType Leaf)) {
|
|
throw "Inventory summary not found after remote run: $summaryPath"
|
|
}
|
|
|
|
return [ordered]@{
|
|
exit_code = $remoteRun.exit_code
|
|
summary_path = $summaryPath
|
|
summary = Read-JsonFile -Path $summaryPath
|
|
}
|
|
}
|
|
|
|
function Invoke-ProductE2E {
|
|
param(
|
|
[string]$ArtifactDir,
|
|
[string]$RepoRoot
|
|
)
|
|
|
|
$scriptPath = Join-Path $RepoRoot "scripts\run-phase15-product-e2e.ps1"
|
|
$childRoot = Join-Path $ArtifactDir "phase15-product-e2e"
|
|
$args = @(
|
|
"-RemoteHost", $RemoteHost,
|
|
"-RemoteUser", $RemoteUser,
|
|
"-BackendContainer", $BackendContainer,
|
|
"-SharedAIContainer", $SharedAIContainer,
|
|
"-BackendBaseUrl", $BackendBaseUrl,
|
|
"-FrontendBaseUrl", $FrontendBaseUrl,
|
|
"-SshKeyPath", $SshKeyPath,
|
|
"-TenantId", $TenantId,
|
|
"-ChatWaitSeconds", ([string]$ChatWaitSeconds),
|
|
"-ArtifactRoot", $childRoot
|
|
)
|
|
if ($UseSshpass) {
|
|
$args += "-UseSshpass"
|
|
}
|
|
$run = Invoke-LoggedCommand -Name "phase15-product-e2e" -FilePath "powershell.exe" -Arguments (@("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $scriptPath) + $args) -LogDir $ArtifactDir -AllowFailure
|
|
$match = [regex]::Match($run.output, "PHASE15_PRODUCT_E2E_(PASS|BLOCKED|FAIL)\s+(.+)")
|
|
$childArtifact = $null
|
|
if ($match.Success) {
|
|
$childArtifact = $match.Groups[2].Value.Trim()
|
|
}
|
|
$summaryPath = if ($childArtifact) { Join-Path $childArtifact "phase15-product-e2e-summary.json" } else { $null }
|
|
$summary = $null
|
|
if ($summaryPath -and (Test-Path -LiteralPath $summaryPath -PathType Leaf)) {
|
|
$summary = Read-JsonFile -Path $summaryPath
|
|
}
|
|
return [ordered]@{
|
|
exit_code = $run.exit_code
|
|
log = $run.log
|
|
artifact_dir = $childArtifact
|
|
summary_path = $summaryPath
|
|
summary = $summary
|
|
}
|
|
}
|
|
|
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
|
|
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
|
|
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase15-release-gate"
|
|
}
|
|
if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
|
$ArtifactRoot = Join-Path $repoRoot $ArtifactRoot
|
|
}
|
|
$artifactDir = Join-Path $ArtifactRoot $timestamp
|
|
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
|
|
|
$summaryJsonPath = Join-Path $artifactDir "phase15-release-gate-summary.json"
|
|
$summaryMdPath = Join-Path $artifactDir "phase15-release-gate-summary.md"
|
|
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
|
|
$rollbackPromptPath = Join-Path $artifactDir "backup-rollback-prompt.json"
|
|
$checks = New-Object System.Collections.Generic.List[object]
|
|
|
|
if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) {
|
|
Add-Check -Checks $checks -Key "gstack" -Label "gstack prerequisite" -Status "fail" -Detail "GSTACK_MISSING"
|
|
} else {
|
|
Add-Check -Checks $checks -Key "gstack" -Label "gstack prerequisite" -Status "pass" -Detail "GSTACK_OK"
|
|
}
|
|
|
|
$branch = (git -C $repoRoot rev-parse --abbrev-ref HEAD).Trim()
|
|
$commit = (git -C $repoRoot rev-parse HEAD).Trim()
|
|
$gitStatus = git -C $repoRoot status --short
|
|
($gitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $gitStatusPath
|
|
if ($gitStatus) {
|
|
if ($RequireCleanWorktree) {
|
|
Add-Check -Checks $checks -Key "working_tree" -Label "working tree" -Status "fail" -Detail "Working tree has uncommitted changes and -RequireCleanWorktree is set" -Meta @{ artifact = $gitStatusPath }
|
|
} else {
|
|
Add-Check -Checks $checks -Key "working_tree" -Label "working tree" -Status "warn" -Required:$false -Detail "Working tree has uncommitted changes; release gate continues in advisory mode" -Meta @{ artifact = $gitStatusPath }
|
|
}
|
|
} else {
|
|
Add-Check -Checks $checks -Key "working_tree" -Label "working tree" -Status "pass" -Detail "Working tree is clean" -Meta @{ artifact = $gitStatusPath }
|
|
}
|
|
|
|
$backendHealth = Invoke-HttpProbe -Url ($BackendBaseUrl.TrimEnd("/") + "/health")
|
|
Add-Check -Checks $checks -Key "backend_health" -Label "backend health" -Status $(if ($backendHealth.ok) { "pass" } else { "fail" }) -Detail "GET /health status=$($backendHealth.status_code)" -Meta @{ url = ($BackendBaseUrl.TrimEnd("/") + "/health") }
|
|
|
|
$readinessUrl = $BackendBaseUrl.TrimEnd("/") + "/api/v1/system/readiness"
|
|
$readiness = Invoke-HttpProbe -Url $readinessUrl
|
|
Add-Check -Checks $checks -Key "backend_readiness" -Label "backend readiness" -Status $(if ($readiness.ok) { "pass" } else { "fail" }) -Detail "GET /api/v1/system/readiness status=$($readiness.status_code)" -Meta @{ url = $readinessUrl }
|
|
|
|
$frontend = Invoke-HttpProbe -Url $FrontendBaseUrl.TrimEnd("/")
|
|
Add-Check -Checks $checks -Key "frontend_health" -Label "frontend health" -Status $(if ($frontend.ok) { "pass" } else { "fail" }) -Detail "GET frontend status=$($frontend.status_code)" -Meta @{ url = $FrontendBaseUrl.TrimEnd("/") }
|
|
|
|
try {
|
|
$inventoryRun = Invoke-ReleaseInventoryCheck -ArtifactDir $artifactDir -RepoRoot $repoRoot -Timestamp $timestamp
|
|
$inventory = $inventoryRun.summary
|
|
Add-Check -Checks $checks -Key "prompt_assets_published" -Label "Prompt assets 36/36 published" -Status $(if ($inventory.checks.prompt_assets_36_36_published -and $inventoryRun.exit_code -eq 0) { "pass" } else { "fail" }) -Detail "catalog=$($inventory.prompt_assets.catalog_total); published=$($inventory.prompt_assets.published); published_versions=$($inventory.prompt_assets.published_versions)" -Meta @{ artifact = $inventoryRun.summary_path }
|
|
Add-Check -Checks $checks -Key "prompt_runtime_resolved" -Label "Prompt runtime 36/36 published resolution" -Status $(if ($inventory.checks.prompt_runtime_36_36_resolved) { "pass" } else { "fail" }) -Detail "runtime_resolved=$($inventory.prompt_assets.runtime_resolved)/$($inventory.prompt_assets.catalog_total)" -Meta @{ artifact = $inventoryRun.summary_path }
|
|
Add-Check -Checks $checks -Key "templates_document_types" -Label "templates and document types" -Status $(if ($inventory.checks.template_document_type_baseline) { "pass" } else { "fail" }) -Detail "active_document_types=$($inventory.templates_and_document_types.active_document_types); enabled_templates=$($inventory.templates_and_document_types.enabled_templates); focus=$($inventory.templates_and_document_types.focus_passed)/$($inventory.templates_and_document_types.focus_total)" -Meta @{ artifact = $inventoryRun.summary_path }
|
|
Add-Check -Checks $checks -Key "legal_articles_searchable" -Label "legal article library searchable" -Status $(if ($inventory.checks.legal_library_searchable) { "pass" } else { "fail" }) -Detail "published_indexed=$($inventory.legal_articles.published_indexed_count); search=$($inventory.legal_articles.search_cases_passed)/$($inventory.legal_articles.search_cases_total)" -Meta @{ artifact = $inventoryRun.summary_path }
|
|
} catch {
|
|
$inventory = $null
|
|
Add-Check -Checks $checks -Key "release_inventory" -Label "release inventory" -Status "fail" -Detail $_.Exception.Message
|
|
}
|
|
|
|
if ($SkipProductE2E) {
|
|
$productE2E = $null
|
|
Add-Check -Checks $checks -Key "phase15_product_e2e" -Label "Phase15 product E2E" -Status "skip" -Required:$false -Detail "Skipped by -SkipProductE2E"
|
|
Add-Check -Checks $checks -Key "chat_stream" -Label "Chat stream" -Status "skip" -Required:$false -Detail "Skipped because product E2E was skipped"
|
|
} else {
|
|
$productE2E = Invoke-ProductE2E -ArtifactDir $artifactDir -RepoRoot $repoRoot
|
|
$productSummary = $productE2E.summary
|
|
$productPass = $productE2E.exit_code -eq 0 -and $null -ne $productSummary -and $productSummary.status -eq "PASS"
|
|
Add-Check -Checks $checks -Key "phase15_product_e2e" -Label "Phase15 product E2E" -Status $(if ($productPass) { "pass" } elseif ($productE2E.exit_code -eq 2) { "blocked" } else { "fail" }) -Detail $(if ($productSummary) { "status=$($productSummary.status); documents=$($productSummary.metrics.document_cases_passed)/$($productSummary.metrics.document_cases_total)" } else { "summary unavailable; exit=$($productE2E.exit_code)" }) -Meta @{ log = $productE2E.log; artifact_dir = $productE2E.artifact_dir; summary = $productE2E.summary_path }
|
|
|
|
$chatPass = $false
|
|
$chatDetail = "product E2E summary unavailable"
|
|
if ($null -ne $productSummary) {
|
|
$followupPassed = [int]$productSummary.metrics.frontend_continue_followup_passed
|
|
$followupTotal = [int]$productSummary.metrics.frontend_continue_followup_total
|
|
$businessPassed = [int]$productSummary.metrics.frontend_business_auxiliary_chat_passed
|
|
$businessTotal = [int]$productSummary.metrics.frontend_business_auxiliary_chat_total
|
|
$chatPass = ($followupTotal -gt 0 -and $followupPassed -eq $followupTotal -and $businessTotal -gt 0 -and $businessPassed -eq $businessTotal)
|
|
$chatDetail = "continue_followup=$followupPassed/$followupTotal; business_auxiliary=$businessPassed/$businessTotal"
|
|
}
|
|
Add-Check -Checks $checks -Key "chat_stream" -Label "Chat stream" -Status $(if ($chatPass) { "pass" } else { "fail" }) -Detail $chatDetail -Meta @{ artifact_dir = $productE2E.artifact_dir; summary = $productE2E.summary_path }
|
|
}
|
|
|
|
$restoreRoot = Join-Path $repoRoot "project-docs\qa-artifacts\restore-docker-deploy"
|
|
$latestRestore = Get-LatestArtifactDirectory -Root $restoreRoot
|
|
$rollbackPrompt = [ordered]@{
|
|
reminder = "Before production publish, confirm latest deploy artifacts and rollback owner. Keep backend/frontend/project-docs archives available until post-release signoff."
|
|
suggested_restore_command = "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-ai-contract-restore-docker.ps1 -UseSshpass -AllowDirtyWorktree"
|
|
latest_restore_artifact_dir = if ($latestRestore) { $latestRestore.FullName } else { $null }
|
|
latest_restore_summary = if ($latestRestore) { Join-Path $latestRestore.FullName "restore-docker-deploy-summary.md" } else { $null }
|
|
}
|
|
Save-Json -Path $rollbackPromptPath -Value $rollbackPrompt
|
|
$rollbackStatus = if ($latestRestore -and (Test-Path -LiteralPath $rollbackPrompt.latest_restore_summary -PathType Leaf)) { "pass" } else { "warn" }
|
|
Add-Check -Checks $checks -Key "backup_rollback_prompt" -Label "backup and rollback prompt" -Status $rollbackStatus -Required:$false -Detail $(if ($latestRestore) { "latest_restore_artifact=$($latestRestore.Name)" } else { "No restore-docker-deploy artifact found; prompt generated" }) -Meta @{ artifact = $rollbackPromptPath }
|
|
|
|
$failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
|
$blockedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "blocked" })
|
|
$warnings = @($checks | Where-Object { $_.status -eq "warn" })
|
|
$skipped = @($checks | Where-Object { $_.status -eq "skip" })
|
|
$overallStatus = if ($failedRequired.Count -gt 0) {
|
|
"FAIL"
|
|
} elseif ($blockedRequired.Count -gt 0) {
|
|
"BLOCKED"
|
|
} elseif ($warnings.Count -gt 0) {
|
|
"WARN"
|
|
} else {
|
|
"PASS"
|
|
}
|
|
|
|
$summary = [ordered]@{
|
|
name = "phase15-release-gate"
|
|
status = $overallStatus
|
|
generated_at = (Get-Date).ToUniversalTime().ToString("o")
|
|
artifact_dir = $artifactDir
|
|
branch = $branch
|
|
commit = $commit
|
|
targets = [ordered]@{
|
|
remote_host = $RemoteHost
|
|
backend_container = $BackendContainer
|
|
shared_ai_container = $SharedAIContainer
|
|
backend_base_url = $BackendBaseUrl
|
|
frontend_base_url = $FrontendBaseUrl
|
|
tenant_id = $TenantId
|
|
}
|
|
options = [ordered]@{
|
|
require_clean_worktree = [bool]$RequireCleanWorktree
|
|
skip_product_e2e = [bool]$SkipProductE2E
|
|
chat_wait_seconds = $ChatWaitSeconds
|
|
}
|
|
checks = $checks
|
|
inventory = if ($inventory) { $inventory } else { $null }
|
|
product_e2e = if ($productE2E) {
|
|
[ordered]@{
|
|
exit_code = $productE2E.exit_code
|
|
artifact_dir = $productE2E.artifact_dir
|
|
summary_path = $productE2E.summary_path
|
|
}
|
|
} else { $null }
|
|
rollback_prompt = $rollbackPrompt
|
|
totals = [ordered]@{
|
|
checks = $checks.Count
|
|
failed_required = $failedRequired.Count
|
|
blocked_required = $blockedRequired.Count
|
|
warnings = $warnings.Count
|
|
skipped = $skipped.Count
|
|
}
|
|
}
|
|
Save-Json -Path $summaryJsonPath -Value $summary
|
|
|
|
$markdown = New-Object System.Collections.Generic.List[string]
|
|
$markdown.Add("# Phase15 Release Gate") | Out-Null
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add("- Status: $overallStatus") | Out-Null
|
|
$markdown.Add("- Generated at: $($summary.generated_at)") | Out-Null
|
|
$markdown.Add("- Branch: $branch") | Out-Null
|
|
$markdown.Add("- Commit: $commit") | Out-Null
|
|
$markdown.Add("- Artifact dir: $artifactDir") | Out-Null
|
|
$markdown.Add("- Backend: $BackendBaseUrl") | Out-Null
|
|
$markdown.Add("- Frontend: $FrontendBaseUrl") | Out-Null
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add("## Checks") | Out-Null
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add("| Check | Required | Status | Detail |") | Out-Null
|
|
$markdown.Add("| --- | ---: | --- | --- |") | Out-Null
|
|
foreach ($check in $checks) {
|
|
$markdown.Add("| $($check.label) | $($check.required) | $($check.status.ToUpperInvariant()) | $($check.detail) |") | Out-Null
|
|
}
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add("## Backup And Rollback Prompt") | Out-Null
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add("- Reminder: $($rollbackPrompt.reminder)") | Out-Null
|
|
$markdown.Add('- Suggested restore command: `' + $rollbackPrompt.suggested_restore_command + '`') | Out-Null
|
|
if ($rollbackPrompt.latest_restore_artifact_dir) {
|
|
$markdown.Add('- Latest restore artifact: `' + $rollbackPrompt.latest_restore_artifact_dir + '`') | Out-Null
|
|
}
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add("## Artifacts") | Out-Null
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add('- `phase15-release-gate-summary.json`') | Out-Null
|
|
$markdown.Add('- `phase15-release-gate-summary.md`') | Out-Null
|
|
$markdown.Add('- `phase15-release-gate-inventory.json`') | Out-Null
|
|
$markdown.Add('- `backup-rollback-prompt.json`') | Out-Null
|
|
$markdown.Add('- `git-status.txt`') | Out-Null
|
|
if ($productE2E -and $productE2E.artifact_dir) {
|
|
$markdown.Add('- Product E2E: `' + $productE2E.artifact_dir + '`') | Out-Null
|
|
}
|
|
$markdown | Set-Content -Encoding UTF8 -Path $summaryMdPath
|
|
|
|
Write-Host "PHASE15_RELEASE_GATE_$overallStatus $artifactDir"
|
|
if ($overallStatus -eq "FAIL" -or $overallStatus -eq "BLOCKED") {
|
|
exit 1
|
|
}
|
|
exit 0
|