Add persistent HTTP journey live-fire harness

This commit is contained in:
Meta Vens 2026-08-17 20:37:38 +02:00
commit 742838e590

View file

@ -0,0 +1,346 @@
[CmdletBinding()]
param(
[string]$RunRoot = (Join-Path 'F:\GameCult\GhostlightDungeon\acceptance' ("http-journey-{0}-{1}" -f [DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N'))),
[string]$SourceRoot = 'F:\Projects\Ghostlight',
[ValidateRange(1024, 65535)]
[int]$Port = 8841,
[ValidateRange(30, 600)]
[int]$RequestTimeoutSeconds = 240
)
$ErrorActionPreference = 'Stop'
$acceptanceRoot = [IO.Path]::GetFullPath('F:\GameCult\GhostlightDungeon\acceptance')
$resolvedRoot = [IO.Path]::GetFullPath($RunRoot)
if (-not $resolvedRoot.StartsWith($acceptanceRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
throw "HTTP journey output must remain below $acceptanceRoot"
}
if (Test-Path -LiteralPath $resolvedRoot) {
throw "HTTP journey output already exists: $resolvedRoot"
}
$binary = Join-Path $SourceRoot 'target\debug\ghostlight-dungeon.exe'
$secret = 'F:\GameCult\GhostlightDungeon\secrets\deepseek.dpapi'
foreach ($required in @($binary, $secret)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "Required journey input is absent: $required"
}
}
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, $Port)
try {
$listener.Start()
} catch {
throw "Loopback port $Port is already occupied"
} finally {
$listener.Stop()
}
New-Item -ItemType Directory -Path $resolvedRoot, (Join-Path $resolvedRoot 'secrets'), (Join-Path $resolvedRoot 'boundary') | Out-Null
Copy-Item -LiteralPath $secret -Destination (Join-Path $resolvedRoot 'secrets\deepseek.dpapi')
$baseUri = "http://127.0.0.1:$Port"
$tokens = 1..2 | ForEach-Object {
$bytes = [byte[]]::new(32)
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
try {
[Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
} finally {
[Array]::Clear($bytes, 0, $bytes.Length)
}
}
$inviteMaterial = $tokens -join ','
$daemonGeneration = 0
function Save-Json([string]$Name, $Value) {
$path = Join-Path $resolvedRoot "boundary\$Name.json"
[IO.File]::WriteAllText($path, ($Value | ConvertTo-Json -Depth 100))
$path
}
function Start-JourneyDaemon {
$script:daemonGeneration++
$stdout = Join-Path $resolvedRoot ("daemon-{0}.stdout.log" -f $script:daemonGeneration)
$stderr = Join-Path $resolvedRoot ("daemon-{0}.stderr.log" -f $script:daemonGeneration)
$psi = [Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $binary
$psi.WorkingDirectory = $SourceRoot
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.Environment['GHOSTLIGHT_DUNGEON_ROOT'] = $resolvedRoot
$psi.Environment['GHOSTLIGHT_DUNGEON_BIND'] = "127.0.0.1:$Port"
$psi.Environment['GHOSTLIGHT_INVITES'] = $inviteMaterial
$process = [Diagnostics.Process]::Start($psi)
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
[pscustomobject]@{
Process = $process
StdoutTask = $stdoutTask
StderrTask = $stderrTask
Stdout = $stdout
Stderr = $stderr
}
}
function Stop-JourneyDaemon($Daemon) {
if (-not $Daemon.Process.HasExited) {
$Daemon.Process.Kill($true)
$Daemon.Process.WaitForExit(10000)
}
[IO.File]::WriteAllText($Daemon.Stdout, $Daemon.StdoutTask.GetAwaiter().GetResult())
[IO.File]::WriteAllText($Daemon.Stderr, $Daemon.StderrTask.GetAwaiter().GetResult())
}
function Wait-JourneyHealth($Daemon) {
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($RequestTimeoutSeconds)
do {
if ($Daemon.Process.HasExited) {
Stop-JourneyDaemon $Daemon
throw "Journey daemon exited during startup; inspect $($Daemon.Stderr)"
}
try {
$response = Invoke-WebRequest -Uri "$baseUri/health" -TimeoutSec 5
if ($response.StatusCode -eq 200) {
return $response.Content | ConvertFrom-Json -Depth 100
}
} catch {
Start-Sleep -Milliseconds 250
}
} while ([DateTimeOffset]::UtcNow -lt $deadline)
throw "Journey daemon did not become healthy within $RequestTimeoutSeconds seconds"
}
function Invoke-JourneyRequest {
param(
[Parameter(Mandatory)][ValidateSet('GET', 'POST')][string]$Method,
[Parameter(Mandatory)][string]$Path,
[Microsoft.PowerShell.Commands.WebRequestSession]$Session,
$Body,
[string]$OutFile
)
$parameters = @{
Uri = "$baseUri$Path"
Method = $Method
TimeoutSec = $RequestTimeoutSeconds
}
if ($Session) { $parameters.WebSession = $Session }
if ($null -ne $Body) {
$parameters.ContentType = 'application/json'
$parameters.Body = $Body | ConvertTo-Json -Depth 100 -Compress
}
if ($OutFile) { $parameters.OutFile = $OutFile }
$response = Invoke-WebRequest @parameters
if ($OutFile) { return $response }
if ([string]::IsNullOrWhiteSpace($response.Content)) { return $null }
if ($response.Headers.'Content-Type' -like '*json*') {
return $response.Content | ConvertFrom-Json -Depth 100
}
$response.Content
}
function Get-SelectedCampaign($Session) {
$campaigns = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $Session
$selected = @($campaigns.campaigns | Where-Object selected)
if ($selected.Count -ne 1) {
throw "Expected one selected campaign, found $($selected.Count)"
}
$selected[0]
}
function Find-EveNode($Node, [string]$Id) {
if ($null -eq $Node) { return $null }
if ($Node.id -eq $Id) { return $Node }
foreach ($child in @($Node.children)) {
$found = Find-EveNode $child $Id
if ($found) { return $found }
}
$null
}
$daemon = $null
try {
$daemon = Start-JourneyDaemon
$healthBefore = Wait-JourneyHealth $daemon
Save-Json 'health-before' $healthBefore | Out-Null
$unauthorized = Invoke-WebRequest -Uri "$baseUri/api/surface" -SkipHttpErrorCheck
if ($unauthorized.StatusCode -ne 401) {
throw "Unauthenticated surface returned $($unauthorized.StatusCode), expected 401"
}
$testerOne = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$testerTwo = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
Invoke-WebRequest -Uri "$baseUri/invite/$($tokens[0])" -WebSession $testerOne | Out-Null
Invoke-WebRequest -Uri "$baseUri/invite/$($tokens[1])" -WebSession $testerTwo | Out-Null
$consumed = Invoke-WebRequest -Uri "$baseUri/invite/$($tokens[0])" -SkipHttpErrorCheck
if ($consumed.StatusCode -ne 401) {
throw "Consumed invite returned $($consumed.StatusCode), expected 401"
}
$testerTwoInitial = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $testerTwo
if (@($testerTwoInitial.campaigns).Count -ne 0) {
throw 'Second tester inherited another session campaign'
}
$compile = Invoke-JourneyRequest -Method POST -Path '/api/compiler/custom' -Session $testerOne -Body @{
campaign_name = 'The Embargo Ledger'
who = 'a junior Lucent maintenance runner trained to visually inspect public equipment, carrying no tools and holding no institutional authority'
where_ = 'a Lucent newsroom during an evidence embargo'
when = 'the narrowest era the retrieved Aetheria evidence can honestly support'
goal = 'verify one publicly observable inconsistency without taking custody of a source or inventing authority'
}
Save-Json 'compile-preview' $compile | Out-Null
if (-not $compile.preview_id -or $compile.preview.locations.Count -lt 1) {
throw 'Compiler returned no approvable bounded world'
}
$approve = Invoke-JourneyRequest -Method POST -Path "/api/compiler/approve/$($compile.preview_id)" -Session $testerOne
Save-Json 'approve' $approve | Out-Null
$original = Get-SelectedCampaign $testerOne
$forbidden = Invoke-WebRequest -Uri "$baseUri/api/campaigns/select/$($original.id)" -Method POST -WebSession $testerTwo -SkipHttpErrorCheck
if ($forbidden.StatusCode -ne 403) {
throw "Second tester selected the first tester's campaign with status $($forbidden.StatusCode)"
}
$surfaceBefore = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
Save-Json 'surface-before-turn' $surfaceBefore | Out-Null
$speak = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
type = 'speak'
expected_revision = [uint64]$surfaceBefore.world_revision
actor_id = 'player'
text = 'I set my empty hands where everyone can see them. I am not asking for trust. Which part of this embargo can a runner verify without taking custody of your sources?'
intended_effect = 'invite a bounded answer without compelling disclosure or claiming authority'
}
Save-Json 'speak' $speak | Out-Null
$afterSpeak = Get-SelectedCampaign $testerOne
$impossible = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
type = 'assess'
expected_revision = [uint64]$afterSpeak.revision
intent = @{
actor_id = 'player'
description = 'Teleport into a nonexistent sealed archive and declare every Lucent source legally mine.'
intended_effect = 'gain remote access, custody, ownership, and institutional authority immediately'
}
}
Save-Json 'assessment-impossible' $impossible | Out-Null
if ($impossible.kind -ne 'assessed' -or $impossible.assessment.admissible) {
throw 'Impossible overreach was admitted to a roll'
}
$afterImpossible = Get-SelectedCampaign $testerOne
if ($afterImpossible.revision -ne $afterSpeak.revision) {
throw 'Private assessment changed campaign revision or became visible world state'
}
$surfaceForAction = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
$ledger = Find-EveNode $surfaceForAction.surface.root 'dungeon.ledger.text'
$capabilityLine = ([string]$ledger.props.value -split "`n" | Where-Object { $_ -like 'Capabilities:*' } | Select-Object -First 1)
$capability = ($capabilityLine -replace '^Capabilities:\s*', '' -split ',' | Select-Object -First 1).Trim()
if ([string]::IsNullOrWhiteSpace($capability) -or $capability -eq 'none') {
throw 'Compiled player role exposed no capability for the bounded action test'
}
$plausible = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
type = 'assess'
expected_revision = [uint64]$afterImpossible.revision
intent = @{
actor_id = 'player'
description = "Use my existing capability exactly as recorded: $capability. I act only from my current position and touch nothing I do not possess."
intended_effect = 'produce the smallest directly observable result that this existing capability, current location, and current access permit'
}
}
Save-Json 'assessment-plausible' $plausible | Out-Null
if ($plausible.kind -ne 'assessed' -or -not $plausible.assessment.admissible) {
throw 'A bounded use of an existing capability was not admitted to a roll'
}
$attempt = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
type = 'attempt'
assessment_digest = $plausible.assessment.digest
}
Save-Json 'attempt' $attempt | Out-Null
if ($attempt.kind -ne 'committed' -or -not $attempt.receipt.roll) {
throw 'Confirmed assessment did not atomically commit a server roll'
}
$afterAttempt = Get-SelectedCampaign $testerOne
$wait = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
type = 'wait'
expected_revision = [uint64]$afterAttempt.revision
minutes = 60
}
Save-Json 'wait' $wait | Out-Null
$beforeFork = Get-SelectedCampaign $testerOne
$surfaceAfter = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
Save-Json 'surface-after-turns' $surfaceAfter | Out-Null
$fork = Invoke-JourneyRequest -Method POST -Path '/api/campaigns/fork' -Session $testerOne -Body @{ name = 'The Embargo Ledger — Fork' }
Save-Json 'fork' $fork | Out-Null
if ($fork.kind -ne 'forked' -or $fork.revision -ne 0) {
throw 'Campaign fork did not begin as an isolated revision-zero branch'
}
$exportPath = Join-Path $resolvedRoot 'fork-export.cc'
Invoke-JourneyRequest -Method GET -Path '/api/campaigns/export' -Session $testerOne -OutFile $exportPath | Out-Null
if ((Get-Item -LiteralPath $exportPath).Length -le 0) {
throw 'Campaign export is empty'
}
Stop-JourneyDaemon $daemon
$daemon = Start-JourneyDaemon
$healthAfter = Wait-JourneyHealth $daemon
Save-Json 'health-after-restart' $healthAfter | Out-Null
$reloadedCampaigns = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $testerOne
Save-Json 'campaigns-after-restart' $reloadedCampaigns | Out-Null
if (@($reloadedCampaigns.campaigns).Count -ne 2) {
throw 'Daemon restart did not reload both owned campaign branches'
}
Invoke-JourneyRequest -Method POST -Path "/api/campaigns/select/$($original.id)" -Session $testerOne | Out-Null
$reloadedOriginal = Get-SelectedCampaign $testerOne
if ($reloadedOriginal.revision -ne $beforeFork.revision) {
throw 'Original campaign revision changed across fork or daemon restart'
}
$reloadedSurface = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
Save-Json 'surface-after-restart' $reloadedSurface | Out-Null
$testerTwoAfter = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $testerTwo
if (@($testerTwoAfter.campaigns).Count -ne 0) {
throw 'Second tester acquired the first tester campaign after restart'
}
$story = Find-EveNode $reloadedSurface.surface.root 'dungeon.transcript'
$narrations = @($story.children | Where-Object id -like 'narration-*')
$transcript = @($story.children | Where-Object id -like 'turn-*')
$commit = (& git -C $SourceRoot rev-parse HEAD).Trim()
$summary = [ordered]@{
schema = 'ghostlight.http_journey_smoke.v1'
source_commit = $commit
production_runtime_touched = $false
listener = "127.0.0.1:$Port"
startup_health = $healthBefore.status
restart_health = $healthAfter.status
unauthenticated_status = [int]$unauthorized.StatusCode
consumed_invite_status = [int]$consumed.StatusCode
cross_session_select_status = [int]$forbidden.StatusCode
campaign_id = $original.id
campaign_revision_after_journey = [uint64]$reloadedOriginal.revision
fork_campaign_id = $fork.campaign_id
reloaded_owned_campaigns = @($reloadedCampaigns.campaigns).Count
second_tester_campaigns = @($testerTwoAfter.campaigns).Count
compiled_locations = @($compile.preview.locations).Count
compiled_cast = @($compile.preview.cast).Count + 1
compiled_institutions = @($compile.preview.institutions).Count
compile_gaps = @($compile.preview.gaps).Count
impossible_admissible = [bool]$impossible.assessment.admissible
impossible_bargains = @($impossible.assessment.bargains).Count
plausible_capability = $capability
plausible_dc = [int]$plausible.assessment.dc
roll = $attempt.receipt.roll
narration_count = $narrations.Count
transcript_count = $transcript.Count
export_bytes = (Get-Item -LiteralPath $exportPath).Length
evidence_paths = Get-ChildItem -LiteralPath (Join-Path $resolvedRoot 'boundary') -File | Select-Object -ExpandProperty FullName
}
Save-Json 'summary' $summary | Out-Null
$summary | ConvertTo-Json -Depth 20
} finally {
if ($daemon) {
Stop-JourneyDaemon $daemon
}
[Array]::Clear($tokens, 0, $tokens.Length)
$inviteMaterial = $null
}