@ran-sh/dsh-crew 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +16 -16
- package/.claude-plugin/plugin.json +14 -14
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +126 -126
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +116 -116
- package/agents/ds-flash.md +24 -24
- package/agents/ds-pro.md +24 -24
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +23 -23
- package/bin/dsh-crew.mjs +16 -16
- package/codex/agents/ds-flash.toml +34 -34
- package/codex/agents/ds-pro.toml +34 -34
- package/codex/agents/ds-reviewer.toml +32 -32
- package/codex/agents/ds-worker.toml +32 -32
- package/codex/prompts/dsh-config.md +19 -19
- package/codex/prompts/dsh-status.md +5 -5
- package/commands/config.md +23 -23
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +9 -9
- package/cordis.patch.yml +4 -4
- package/docs/gpt-relay-extension.md +103 -103
- package/docs/installation.md +138 -138
- package/docs/job-contracts.md +103 -103
- package/docs/readiness-matrix.md +85 -85
- package/docs/ui-surfaces.md +107 -107
- package/official-web-bridge/cordis.patch.yml +4 -4
- package/official-web-bridge/entry.mjs +1 -1
- package/official-web-bridge/overlay-entry.mjs +59 -59
- package/official-web-bridge/package.json +25 -25
- package/package.json +3 -2
- package/scripts/build-client.mjs +49 -49
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/remove-legacy-official-bridge.ps1 +89 -89
- package/scripts/setup.mjs +393 -393
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-crew-ui-polish.mjs +145 -145
- package/scripts/verify-history-ui.mjs +97 -97
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +311 -311
- package/scripts/verify-official-bridge-e2e.mjs +192 -192
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -55
- package/src/client/history-panel.tsx +108 -108
- package/src/client/host-readiness.mjs +71 -71
- package/src/client/index.tsx +1711 -1711
- package/src/client/model-callability-view.mjs +17 -17
- package/src/client/panel-chrome.tsx +40 -40
- package/src/client/quick-entry.tsx +10 -10
- package/src/client/quick-panel.tsx +275 -275
- package/src/client/readiness-envelope.mjs +34 -34
- package/src/client/surface-detection.mjs +43 -43
- package/src/config-readiness.mjs +226 -226
- package/src/credential-reference.mjs +38 -38
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +1021 -1021
- package/src/dsh-cohort.mjs +20 -20
- package/src/extension-contract.mjs +104 -104
- package/src/failure-classification.mjs +201 -201
- package/src/history/admission-gate.mjs +67 -67
- package/src/history/archive-store.mjs +272 -272
- package/src/history/cleanup-plan.mjs +89 -89
- package/src/history/http.mjs +32 -32
- package/src/history/operation.mjs +86 -86
- package/src/history/runner-detach.mjs +34 -34
- package/src/history/runner.mjs +52 -52
- package/src/history/runtime.mjs +36 -36
- package/src/history/service.mjs +177 -177
- package/src/history/state.mjs +27 -27
- package/src/hub/entry.mjs +104 -104
- package/src/hub/index.mjs +2694 -2694
- package/src/hub-client.mjs +154 -154
- package/src/hub-compatibility.mjs +42 -42
- package/src/i18n.mjs +19 -19
- package/src/information-flow.mjs +67 -67
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +711 -711
- package/src/install/install.mjs +483 -483
- package/src/install/npx-lifecycle.mjs +3456 -3456
- package/src/install/official-frontend-assets.mjs +78 -78
- package/src/install/official-web.mjs +95 -95
- package/src/install/payload-content.mjs +88 -88
- package/src/install/windows-startup.mjs +236 -236
- package/src/install/windows-supervisor-adapter.mjs +443 -443
- package/src/install/windows-supervisor-lifecycle.mjs +782 -782
- package/src/install/zcode.mjs +397 -397
- package/src/job-contracts.mjs +255 -255
- package/src/local-request-guard.mjs +60 -60
- package/src/mcp-runtime.mjs +340 -340
- package/src/model-callability-contract.mjs +79 -79
- package/src/model-catalog.mjs +180 -180
- package/src/model-routing.mjs +586 -586
- package/src/model-schedule.mjs +207 -207
- package/src/official-web-bridge.mjs +447 -447
- package/src/policy.mjs +235 -235
- package/src/provider-delete-adapters.mjs +1934 -1934
- package/src/provider-health.mjs +130 -130
- package/src/provider-inventory.mjs +182 -182
- package/src/provider-layer-migration-adapters.mjs +759 -759
- package/src/provider-layer-migration.mjs +198 -198
- package/src/provider-lifecycle-state.mjs +103 -103
- package/src/provider-lifecycle.mjs +252 -252
- package/src/provider-profile-store.mjs +390 -390
- package/src/provider-settings-store.mjs +633 -633
- package/src/provider-store-lock.mjs +67 -67
- package/src/readiness-matrix.mjs +181 -181
- package/src/removable-waiter.mjs +29 -29
- package/src/role-profiles.mjs +107 -107
- package/src/runtime-controls.mjs +84 -84
- package/src/runtime-identity-contract.mjs +34 -34
- package/src/runtime-identity.mjs +235 -235
- package/src/runtime-readiness-snapshot.mjs +285 -285
- package/src/server.mjs +581 -581
- package/src/session-origins.mjs +60 -60
- package/src/standalone-sdk.mjs +23 -23
- package/src/status-shard.mjs +63 -63
- package/src/structured-error-code.mjs +38 -38
- package/src/supervisor/restart-request.mjs +256 -256
- package/src/workflow-runtime.mjs +739 -739
- package/src/workflow.mjs +155 -155
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-context.mjs +146 -146
- package/src/workspace-isolation.mjs +463 -455
- package/src/workspace-readiness.mjs +32 -32
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +1302 -1302
- package/windows/supervisor-control.ps1 +467 -467
- package/worker.cordis.yml +67 -67
- package/zcode/agents/ds-reviewer.md +31 -31
- package/zcode/agents/ds-worker.md +31 -31
- package/zcode/commands/dsh-config.md +17 -17
- package/zcode/commands/dsh-status.md +5 -5
|
@@ -1,1302 +1,1302 @@
|
|
|
1
|
-
# DSH Crew managed Windows launcher
|
|
2
|
-
[CmdletBinding()]
|
|
3
|
-
param(
|
|
4
|
-
[ValidateSet('background', 'open', 'watch')]
|
|
5
|
-
[string] $Mode = 'open'
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
Set-StrictMode -Version Latest
|
|
9
|
-
$ErrorActionPreference = 'Stop'
|
|
10
|
-
|
|
11
|
-
$crewHome = Join-Path $env:USERPROFILE '.config\dsh-crew\harness'
|
|
12
|
-
$configuredDshCli = [string] $env:DSH_CREW_DSH_CLI
|
|
13
|
-
if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { $configuredDshCli = '' }
|
|
14
|
-
if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
|
|
15
|
-
# Crew-managed npm runtime: the normal install path, written by dsh-crew
|
|
16
|
-
# update and carrying whatever cohort that release pinned. An explicit
|
|
17
|
-
# DSH_CREW_DSH_CLI always wins so an operator can still pin another entry.
|
|
18
|
-
$runtimeEntry = Join-Path $crewHome 'runtime\node_modules\@deepseek-ai\dsh\lib\bin.js'
|
|
19
|
-
if (Test-Path -LiteralPath $runtimeEntry -PathType Leaf) {
|
|
20
|
-
$configuredDshCli = $runtimeEntry
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
|
|
24
|
-
# Crew-managed source cohort: a runtime-source-<label>.json sidecar names the
|
|
25
|
-
# CLI entry. The sidecar's recorded version must equal the checkout's own
|
|
26
|
-
# manifest, so a stale sidecar can never pin a half-updated tree.
|
|
27
|
-
foreach ($sidecar in @(Get-ChildItem -LiteralPath $crewHome -Filter 'runtime-source-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
28
|
-
try {
|
|
29
|
-
$metadata = Get-Content -LiteralPath $sidecar.FullName -Raw | ConvertFrom-Json
|
|
30
|
-
if ($metadata.managed_by -ne 'dsh-crew') { continue }
|
|
31
|
-
$candidate = [string] $metadata.cli_entry
|
|
32
|
-
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
|
33
|
-
$sourceRoot = [IO.Path]::GetFullPath((Join-Path $crewHome ([IO.Path]::GetFileNameWithoutExtension($sidecar.Name))))
|
|
34
|
-
$candidateFull = [IO.Path]::GetFullPath($candidate)
|
|
35
|
-
$sourcePrefix = $sourceRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
36
|
-
$sourceManifest = Join-Path $sourceRoot 'apps\cli\package.json'
|
|
37
|
-
$sourceVersion = if (Test-Path -LiteralPath $sourceManifest -PathType Leaf) {
|
|
38
|
-
[string] (Get-Content -LiteralPath $sourceManifest -Raw | ConvertFrom-Json).version
|
|
39
|
-
} else { $null }
|
|
40
|
-
$candidateValid = $sourceVersion -and $sourceVersion -eq [string] $metadata.version `
|
|
41
|
-
-and $candidateFull.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase) `
|
|
42
|
-
-and (Test-Path -LiteralPath $candidateFull -PathType Leaf)
|
|
43
|
-
if ($candidateValid) {
|
|
44
|
-
$configuredDshCli = $candidateFull
|
|
45
|
-
break
|
|
46
|
-
}
|
|
47
|
-
} catch { }
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
$dshCli = if ($configuredDshCli.Trim()) {
|
|
51
|
-
try { [IO.Path]::GetFullPath($configuredDshCli) } catch { $configuredDshCli }
|
|
52
|
-
} else {
|
|
53
|
-
Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
|
|
54
|
-
}
|
|
55
|
-
if ($configuredDshCli.Trim()) { $env:DSH_CREW_DSH_CLI = $dshCli }
|
|
56
|
-
# DSH_HOME that owns the selected CLI. The managed npm runtime shares the Crew
|
|
57
|
-
# home; a Crew-managed source cohort is its own home (its profiles, sessions and
|
|
58
|
-
# settings live inside that tree). Derived from the selected entry so an explicit
|
|
59
|
-
# DSH_CREW_DSH_CLI override resolves correctly too.
|
|
60
|
-
$dshHome = $crewHome
|
|
61
|
-
$sourceRootMatch = [regex]::Match($dshCli, '^(?<root>.+?[\\/]runtime-source-[^\\/]+)[\\/]')
|
|
62
|
-
if ($sourceRootMatch.Success) {
|
|
63
|
-
$candidateRoot = $sourceRootMatch.Groups['root'].Value
|
|
64
|
-
if (Test-Path -LiteralPath (Join-Path $candidateRoot 'profiles') -PathType Container) { $dshHome = $candidateRoot }
|
|
65
|
-
}
|
|
66
|
-
$dshCliIsNodeEntry = [IO.Path]::GetExtension($dshCli).ToLowerInvariant() -eq '.js'
|
|
67
|
-
$dshCommand = if ($dshCliIsNodeEntry) {
|
|
68
|
-
(Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
|
69
|
-
} else { $dshCli }
|
|
70
|
-
|
|
71
|
-
function Get-CrewDshManifest {
|
|
72
|
-
param([string] $Entry)
|
|
73
|
-
if (-not $Entry) { return $null }
|
|
74
|
-
try { $cursor = [IO.Path]::GetFullPath($Entry) } catch { return $null }
|
|
75
|
-
if ([IO.Path]::GetExtension($cursor).ToLowerInvariant() -eq '.js') {
|
|
76
|
-
$cursor = Split-Path -Parent $cursor
|
|
77
|
-
} else {
|
|
78
|
-
$cursor = Split-Path -Parent $cursor
|
|
79
|
-
}
|
|
80
|
-
for ($depth = 0; $depth -lt 12 -and $cursor; $depth += 1) {
|
|
81
|
-
$candidates = @(
|
|
82
|
-
(Join-Path $cursor 'package.json'),
|
|
83
|
-
(Join-Path $cursor 'node_modules\@deepseek-ai\dsh\package.json')
|
|
84
|
-
)
|
|
85
|
-
foreach ($candidate in $candidates) {
|
|
86
|
-
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
|
|
87
|
-
try {
|
|
88
|
-
$manifest = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json
|
|
89
|
-
$knownOfficial = -not $configuredDshCli.Trim() -or $manifest.name -eq '@deepseek-ai/dsh'
|
|
90
|
-
if ($knownOfficial -and [string] $manifest.version) { return $manifest }
|
|
91
|
-
} catch { }
|
|
92
|
-
}
|
|
93
|
-
$parent = Split-Path -Parent $cursor
|
|
94
|
-
if ($parent -eq $cursor) { break }
|
|
95
|
-
$cursor = $parent
|
|
96
|
-
}
|
|
97
|
-
return $null
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
$dshManifest = Get-CrewDshManifest -Entry $dshCli
|
|
101
|
-
$logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
|
|
102
|
-
$launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
|
|
103
|
-
$startedAt = Get-Date
|
|
104
|
-
$launcherProcessStartedAtUtcTicks = try { (Get-Process -Id $PID -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture) } catch { $null }
|
|
105
|
-
$supervisorInstanceId = [guid]::NewGuid().ToString()
|
|
106
|
-
$launcherHelperHash = $null
|
|
107
|
-
try {
|
|
108
|
-
$hashStream = [System.IO.File]::OpenRead($PSCommandPath)
|
|
109
|
-
try {
|
|
110
|
-
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
|
111
|
-
try { $launcherHelperHash = (($sha256.ComputeHash($hashStream) | ForEach-Object { $_.ToString('x2') }) -join '') }
|
|
112
|
-
finally { $sha256.Dispose() }
|
|
113
|
-
} finally { $hashStream.Dispose() }
|
|
114
|
-
} catch { $launcherHelperHash = $null }
|
|
115
|
-
$services = @(
|
|
116
|
-
[pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $dshHome; Port = 3210; Url = 'http://127.0.0.1:3210'; CrewOwned = $true; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
function Write-LaunchLog {
|
|
120
|
-
param([string] $Message, [ValidateSet('INFO', 'WARN', 'ERROR')] [string] $Level = 'INFO')
|
|
121
|
-
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffK'), $Level, $Message
|
|
122
|
-
Add-Content -LiteralPath $launcherLog -Value $line -Encoding UTF8
|
|
123
|
-
if ($Mode -eq 'open') {
|
|
124
|
-
if ($Level -eq 'ERROR') { Write-Host $line -ForegroundColor Red }
|
|
125
|
-
elseif ($Level -eq 'WARN') { Write-Host $line -ForegroundColor Yellow }
|
|
126
|
-
else { Write-Host $line }
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function Resolve-OfficialHarnessCommand {
|
|
131
|
-
$shim = Get-Command dsh.cmd -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
|
132
|
-
$root = Join-Path (Split-Path -Parent $shim.Source) 'node_modules\@deepseek-ai\dsh'
|
|
133
|
-
$manifest = Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json
|
|
134
|
-
if ($manifest.name -ne '@deepseek-ai/dsh') { throw 'The dsh command is not a verified official Harness installation.' }
|
|
135
|
-
$entrySpec = [string] $manifest.bin.dsh
|
|
136
|
-
if (-not $entrySpec -or [IO.Path]::IsPathRooted($entrySpec) -or $entrySpec.Contains(':') -or ($entrySpec.Replace('\', '/').Split('/') -contains '..')) {
|
|
137
|
-
throw 'The official Harness CLI entry is invalid.'
|
|
138
|
-
}
|
|
139
|
-
$entry = [IO.Path]::GetFullPath((Join-Path $root $entrySpec))
|
|
140
|
-
$rootPrefix = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
141
|
-
if (-not $entry.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
|
|
142
|
-
throw 'The official Harness CLI entry was not found.'
|
|
143
|
-
}
|
|
144
|
-
$node = Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
|
145
|
-
return [pscustomobject]@{ NodePath = $node.Source; Entry = $entry }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function Test-OfficialHarnessListener {
|
|
149
|
-
param([int] $OwnerPid, [pscustomobject] $Official, [string] $Profile = 'web')
|
|
150
|
-
if ($OwnerPid -le 0) { return $false }
|
|
151
|
-
try {
|
|
152
|
-
$owner = Get-CimInstance Win32_Process -Filter ('ProcessId={0}' -f $OwnerPid) -ErrorAction Stop
|
|
153
|
-
if (-not $owner -or [IO.Path]::GetFileName([string] $owner.ExecutablePath) -ine 'node.exe') { return $false }
|
|
154
|
-
$line = ([string] $owner.CommandLine).Replace('/', '\')
|
|
155
|
-
$entry = [regex]::Escape($Official.Entry.Replace('/', '\'))
|
|
156
|
-
$node = [regex]::Escape($Official.NodePath.Replace('/', '\'))
|
|
157
|
-
$profileToken = [regex]::Escape($Profile)
|
|
158
|
-
$pattern = '^\s*(?:"?' + $node + '"?|"?node(?:\.exe)?"?)\s+"?' + $entry + '"?\s+(?:"?' + $profileToken + '"?|--profile\s+"?' + $profileToken + '"?)(?:\s|$)'
|
|
159
|
-
return $line -match $pattern
|
|
160
|
-
} catch { return $false }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function Test-CrewDshCliPreflight {
|
|
164
|
-
if ($null -eq $dshManifest -or [string]::IsNullOrWhiteSpace([string] $dshManifest.version)) {
|
|
165
|
-
throw "The DSH CLI at $dshCli is not a verified @deepseek-ai/dsh package entry."
|
|
166
|
-
}
|
|
167
|
-
if ($dshCliIsNodeEntry -and -not (Test-Path -LiteralPath (Join-Path (Join-Path $dshHome 'profiles') 'web\package.json') -PathType Leaf)) {
|
|
168
|
-
throw ('The Harness web profile is missing under {0}. Run: dsh-crew update' -f $dshHome)
|
|
169
|
-
}
|
|
170
|
-
$versionArgs = if ($dshCliIsNodeEntry) { @($dshCli, '--version') } else { @('--version') }
|
|
171
|
-
$reported = try { (& $dshCommand @versionArgs 2>&1 | Out-String).Trim() } catch { '' }
|
|
172
|
-
if ($LASTEXITCODE -ne 0 -or $reported -ne [string] $dshManifest.version) {
|
|
173
|
-
throw ('DSH CLI preflight failed: manifest={0}, reported={1}' -f $dshManifest.version, ($reported -replace '\s+', ' '))
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function Get-CrewWebSessionUrl {
|
|
178
|
-
param([string] $OutputLog = $null)
|
|
179
|
-
$logs = if ($OutputLog) {
|
|
180
|
-
@(Get-Item -LiteralPath $OutputLog -ErrorAction SilentlyContinue)
|
|
181
|
-
} else {
|
|
182
|
-
@(Get-ChildItem -LiteralPath $logRoot -Filter 'dsh-crew-web-*.out.log' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
|
|
183
|
-
}
|
|
184
|
-
foreach ($log in $logs) {
|
|
185
|
-
try {
|
|
186
|
-
$text = Get-Content -LiteralPath $log.FullName -Raw -ErrorAction Stop
|
|
187
|
-
$match = [regex]::Match($text, 'https?://127\.0\.0\.1:3080/\?token=[^\s\r\n]+')
|
|
188
|
-
if ($match.Success) { return $match.Value }
|
|
189
|
-
} catch { }
|
|
190
|
-
}
|
|
191
|
-
return $null
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function Open-CrewBrowserUrl {
|
|
195
|
-
param([Parameter(Mandatory = $true)] [string] $Url)
|
|
196
|
-
$edge = Get-Command msedge.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
197
|
-
if (-not $edge) {
|
|
198
|
-
$edgeCandidates = @(
|
|
199
|
-
@(
|
|
200
|
-
(Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
|
|
201
|
-
(Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe'),
|
|
202
|
-
(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe')
|
|
203
|
-
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) }
|
|
204
|
-
)
|
|
205
|
-
if ($edgeCandidates.Count -gt 0) { $edge = [pscustomobject]@{ Source = $edgeCandidates[0] } }
|
|
206
|
-
}
|
|
207
|
-
if ($edge) {
|
|
208
|
-
Start-Process -FilePath $edge.Source -ArgumentList @('--new-window', $Url) | Out-Null
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
Start-Process $Url | Out-Null
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function Test-CrewWebSessionUrl {
|
|
215
|
-
param([string] $Url)
|
|
216
|
-
if (-not $Url) { return $false }
|
|
217
|
-
try {
|
|
218
|
-
# The token endpoint intentionally answers with a 303 + Set-Cookie and
|
|
219
|
-
# redirects to `/`; do not follow it here or PowerShell loses the cookie
|
|
220
|
-
# and falsely reports the authenticated page as 404.
|
|
221
|
-
$response = Invoke-WebRequest -UseBasicParsing -MaximumRedirection 0 -Uri $Url -TimeoutSec 3
|
|
222
|
-
return [int] $response.StatusCode -ge 200 -and [int] $response.StatusCode -lt 400
|
|
223
|
-
} catch {
|
|
224
|
-
$status = if ($_.Exception.Response) { [int] $_.Exception.Response.StatusCode } else { 0 }
|
|
225
|
-
return $status -in @(301, 302, 303, 307, 308)
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function Open-CrewManagedFrontend {
|
|
230
|
-
param([int] $TimeoutSeconds = 90)
|
|
231
|
-
# The 3080 frontend boots from the same Crew-managed Harness entry as 3210,
|
|
232
|
-
# against that entry's own DSH_HOME (npm runtime -> Crew home, source cohort
|
|
233
|
-
# -> its own tree). No official ~/.dsh state is read or written here.
|
|
234
|
-
$managedHome = $dshHome
|
|
235
|
-
$managedProfile = 'web'
|
|
236
|
-
$profileManifest = Join-Path (Join-Path $managedHome 'profiles') ('{0}\package.json' -f $managedProfile)
|
|
237
|
-
if (-not $dshCliIsNodeEntry -or -not (Test-Path -LiteralPath $profileManifest -PathType Leaf)) {
|
|
238
|
-
return $false
|
|
239
|
-
}
|
|
240
|
-
$official = [pscustomobject]@{ NodePath = $dshCommand; Entry = $dshCli; Profile = $managedProfile }
|
|
241
|
-
$frontend = Get-OfficialFrontendOverlay
|
|
242
|
-
$mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
|
|
243
|
-
$locked = $false
|
|
244
|
-
try {
|
|
245
|
-
try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
|
|
246
|
-
if (-not $locked) { throw 'Another desktop launch is still starting the Crew-managed Harness frontend.' }
|
|
247
|
-
$port = Get-PortState -Port 3080
|
|
248
|
-
if ($port.State -eq 'unknown') { throw $port.Error }
|
|
249
|
-
if ($port.State -eq 'occupied') {
|
|
250
|
-
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
|
|
251
|
-
throw 'Port 3080 is occupied by a non-Crew-managed process; it was left untouched.'
|
|
252
|
-
}
|
|
253
|
-
if (-not (Test-OfficialWebReady)) { throw 'The Crew-managed 3080 frontend is not ready; its process was left running.' }
|
|
254
|
-
$existingUrl = Get-CrewWebSessionUrl
|
|
255
|
-
if ($existingUrl -and (Test-CrewWebSessionUrl -Url $existingUrl)) {
|
|
256
|
-
Open-CrewBrowserUrl -Url $existingUrl
|
|
257
|
-
Write-LaunchLog 'Opened the existing Crew-managed Harness frontend on 3080 with its session URL.'
|
|
258
|
-
return $true
|
|
259
|
-
}
|
|
260
|
-
Write-LaunchLog 'The existing Crew-managed 3080 session URL is stale; restarting the verified listener.' 'WARN'
|
|
261
|
-
Stop-Process -Id $port.Pid -Force -ErrorAction Stop
|
|
262
|
-
$freeDeadline = (Get-Date).AddSeconds(5)
|
|
263
|
-
do { Start-Sleep -Milliseconds 250; $port = Get-PortState -Port 3080 } while ($port.State -ne 'free' -and (Get-Date) -lt $freeDeadline)
|
|
264
|
-
if ($port.State -ne 'free') { throw 'The stale Crew-managed 3080 listener did not release the port.' }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
$stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
|
|
268
|
-
$stdout = Join-Path $logRoot ('dsh-crew-web-{0}.out.log' -f $stamp)
|
|
269
|
-
$stderr = Join-Path $logRoot ('dsh-crew-web-{0}.err.log' -f $stamp)
|
|
270
|
-
$previousHome = $env:DSH_HOME
|
|
271
|
-
$previousCli = $env:DSH_CREW_DSH_CLI
|
|
272
|
-
try {
|
|
273
|
-
$env:DSH_HOME = $managedHome
|
|
274
|
-
$env:DSH_CREW_DSH_CLI = $dshCli
|
|
275
|
-
$arguments = @($dshCli, '--profile', $managedProfile, '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
|
|
276
|
-
$process = Start-Process -FilePath $dshCommand -ArgumentList $arguments -WindowStyle Hidden -PassThru `
|
|
277
|
-
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
278
|
-
} finally {
|
|
279
|
-
$env:DSH_HOME = $previousHome
|
|
280
|
-
$env:DSH_CREW_DSH_CLI = $previousCli
|
|
281
|
-
}
|
|
282
|
-
Write-LaunchLog ('Started Crew-managed Harness on 3080; PID={0}. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
|
|
283
|
-
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
284
|
-
do {
|
|
285
|
-
$port = Get-PortState -Port 3080
|
|
286
|
-
if ($port.State -eq 'occupied') {
|
|
287
|
-
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
|
|
288
|
-
throw 'Port 3080 became occupied by an unverified process; it was left untouched.'
|
|
289
|
-
}
|
|
290
|
-
if (Test-OfficialWebReady) {
|
|
291
|
-
$startedUrl = Get-CrewWebSessionUrl -OutputLog $stdout
|
|
292
|
-
if ($startedUrl) { Open-CrewBrowserUrl -Url $startedUrl; return $true }
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
if ($process.HasExited) { throw ('Crew-managed Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
|
|
296
|
-
Start-Sleep -Milliseconds 500
|
|
297
|
-
} while ((Get-Date) -lt $deadline)
|
|
298
|
-
throw ('The Crew-managed Harness did not become ready within {0}s; inspect {1}.' -f $TimeoutSeconds, $stderr)
|
|
299
|
-
} finally {
|
|
300
|
-
if ($locked) { $mutex.ReleaseMutex() }
|
|
301
|
-
$mutex.Dispose()
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function Test-OfficialWebReady {
|
|
306
|
-
try {
|
|
307
|
-
$response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/' -TimeoutSec 2
|
|
308
|
-
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300
|
|
309
|
-
} catch {
|
|
310
|
-
# An authenticated official UI may reject this cookie-free health request.
|
|
311
|
-
if ($_.Exception.Response) { return [int] $_.Exception.Response.StatusCode -in @(401, 403) }
|
|
312
|
-
return $false
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function Get-OfficialFrontendOverlay {
|
|
317
|
-
$frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
|
|
318
|
-
$path = Join-Path $frontendRoot 'official-web.patch.json'
|
|
319
|
-
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'Crew frontend overlay is missing. Run dsh-crew update with the current GitHub candidate.' }
|
|
320
|
-
# Windows PowerShell 5.1 keeps a JSON root array as one pipeline object;
|
|
321
|
-
# direct `$patch[0].insert[0]` therefore resolves `insert` as IList.Insert
|
|
322
|
-
# instead of indexing the JSON property. Flatten both PowerShell generations
|
|
323
|
-
# explicitly and read properties through PSObject so StrictMode reports a
|
|
324
|
-
# useful validation error instead of "property id cannot be found".
|
|
325
|
-
$parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
|
|
326
|
-
$patch = @($parsed | ForEach-Object { $_ })
|
|
327
|
-
$root = if ($patch.Count -eq 1) { $patch[0] } else { $null }
|
|
328
|
-
$insertProperty = if ($null -ne $root) { $root.PSObject.Properties['insert'] } else { $null }
|
|
329
|
-
$insertValue = if ($null -ne $insertProperty) { $insertProperty.Value } else { $null }
|
|
330
|
-
$inserts = @($insertValue | ForEach-Object { $_ })
|
|
331
|
-
$bridge = if ($inserts.Count -eq 1) { $inserts[0] } else { $null }
|
|
332
|
-
$idProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['id'] } else { $null }
|
|
333
|
-
$nameProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['name'] } else { $null }
|
|
334
|
-
if ($patch.Count -ne 1 -or $inserts.Count -ne 1 -or $null -eq $idProperty -or [string] $idProperty.Value -ne 'dsh-crew-official-web-bridge' -or $null -eq $nameProperty) {
|
|
335
|
-
throw 'Crew frontend overlay has an unexpected structure.'
|
|
336
|
-
}
|
|
337
|
-
$uri = [Uri] ([string] $nameProperty.Value)
|
|
338
|
-
if (-not $uri.IsFile) { throw 'Crew frontend must load from its local snapshot.' }
|
|
339
|
-
$entry = [IO.Path]::GetFullPath($uri.LocalPath)
|
|
340
|
-
$prefix = [IO.Path]::GetFullPath((Join-Path $frontendRoot 'revisions')) + [IO.Path]::DirectorySeparatorChar
|
|
341
|
-
if (-not $entry.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Crew frontend snapshot is outside its owned directory.' }
|
|
342
|
-
$metadata = Get-Content -LiteralPath (Join-Path (Split-Path -Parent $entry) 'package.json') -Raw | ConvertFrom-Json
|
|
343
|
-
$revision = [string] $metadata.dshCrewFrontendRevision
|
|
344
|
-
if ($metadata.dshCrewManagedFrontend -ne $true -or $revision -notmatch '^[a-f0-9]{64}$' -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
|
|
345
|
-
throw 'Crew frontend snapshot is incomplete.'
|
|
346
|
-
}
|
|
347
|
-
return [pscustomobject]@{ Path = $path; Revision = $revision }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function Test-OfficialFrontendAttached {
|
|
351
|
-
param([string] $Revision)
|
|
352
|
-
try {
|
|
353
|
-
$response = Invoke-RestMethod -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/bridge-status' -TimeoutSec 2
|
|
354
|
-
return $response.ok -eq $true -and $response.surface -eq 'official-bridge' -and $response.frontend_revision -eq $Revision
|
|
355
|
-
} catch { return $false }
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function Open-OfficialFrontend {
|
|
359
|
-
param([int] $TimeoutSeconds = 90)
|
|
360
|
-
# Preferred path: the Crew-managed Harness serves 3080 with the Crew panel
|
|
361
|
-
# already loaded. The legacy fallback below (separate official install in
|
|
362
|
-
# ~/.dsh) is only reached when no Crew-managed frontend is available.
|
|
363
|
-
if (Open-CrewManagedFrontend -TimeoutSeconds $TimeoutSeconds) { return }
|
|
364
|
-
$official = Resolve-OfficialHarnessCommand
|
|
365
|
-
$frontend = Get-OfficialFrontendOverlay
|
|
366
|
-
$mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
|
|
367
|
-
$locked = $false
|
|
368
|
-
try {
|
|
369
|
-
try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
|
|
370
|
-
if (-not $locked) { throw 'Another desktop launch is still starting the official frontend.' }
|
|
371
|
-
$port = Get-PortState -Port 3080
|
|
372
|
-
if ($port.State -eq 'unknown') { throw $port.Error }
|
|
373
|
-
if ($port.State -eq 'occupied') {
|
|
374
|
-
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 is occupied by an unverified process; it was left untouched.' }
|
|
375
|
-
if (-not (Test-OfficialWebReady)) { throw 'The official 3080 frontend is not ready; its process was left running.' }
|
|
376
|
-
if (-not (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { throw 'The running official frontend has not loaded the current Crew panel. Stop that official instance when idle, then launch from the desktop again. It was left untouched.' }
|
|
377
|
-
Start-Process 'http://127.0.0.1:3080/' | Out-Null
|
|
378
|
-
Write-LaunchLog 'Opened the existing official Harness frontend on 3080.'
|
|
379
|
-
return
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
$stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
|
|
383
|
-
$stdout = Join-Path $logRoot ('dsh-official-web-{0}.out.log' -f $stamp)
|
|
384
|
-
$stderr = Join-Path $logRoot ('dsh-official-web-{0}.err.log' -f $stamp)
|
|
385
|
-
$previousHome = $env:DSH_HOME
|
|
386
|
-
try {
|
|
387
|
-
# The official application owns its normal state; no Crew plugin/profile
|
|
388
|
-
# registration or repair is performed in this home by the launcher.
|
|
389
|
-
$env:DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
|
|
390
|
-
$arguments = @(('"{0}"' -f $official.Entry), 'web', '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
|
|
391
|
-
$process = Start-Process -FilePath $official.NodePath -ArgumentList $arguments -WindowStyle Hidden -PassThru `
|
|
392
|
-
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
393
|
-
} finally { $env:DSH_HOME = $previousHome }
|
|
394
|
-
Write-LaunchLog ('Started official Harness on 3080; PID={0}. The official CLI opens the browser. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
|
|
395
|
-
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
396
|
-
$nextProgress = (Get-Date).AddSeconds(5)
|
|
397
|
-
do {
|
|
398
|
-
$port = Get-PortState -Port 3080
|
|
399
|
-
if ($port.State -eq 'occupied') {
|
|
400
|
-
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 became occupied by an unverified process; it was left untouched.' }
|
|
401
|
-
if ((Test-OfficialWebReady) -and (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { return }
|
|
402
|
-
}
|
|
403
|
-
if ($process.HasExited) { throw ('Official Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
|
|
404
|
-
if ((Get-Date) -ge $nextProgress) {
|
|
405
|
-
Write-LaunchLog 'Waiting for the official Harness frontend on 3080...'
|
|
406
|
-
$nextProgress = (Get-Date).AddSeconds(5)
|
|
407
|
-
}
|
|
408
|
-
Start-Sleep -Milliseconds 500
|
|
409
|
-
} while ((Get-Date) -lt $deadline)
|
|
410
|
-
throw ('Official Harness did not become ready within {0}s; inspect {1}. No official process was stopped.' -f $TimeoutSeconds, $stderr)
|
|
411
|
-
} finally {
|
|
412
|
-
if ($locked) { $mutex.ReleaseMutex() }
|
|
413
|
-
$mutex.Dispose()
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function Get-HealthState {
|
|
418
|
-
param([pscustomObject] $Service)
|
|
419
|
-
# Preserve the installed extension health contract used by maintenance.
|
|
420
|
-
if ($Service.CrewOwned) {
|
|
421
|
-
try {
|
|
422
|
-
$response = Invoke-RestMethod -Uri ($Service.Url + '/_dsh/dsh-crew/extension') -TimeoutSec 2
|
|
423
|
-
$runtime = $response.extension.runtime
|
|
424
|
-
$version = $runtime.runtime_version
|
|
425
|
-
# A stale 3210 process (booted before the runtime tree was swapped) can
|
|
426
|
-
# keep serving from memory while the disk tree is a different cohort.
|
|
427
|
-
# Require the reported dsh_version to equal the installed disk
|
|
428
|
-
# @deepseek-ai/dsh version so supervisor never treats a
|
|
429
|
-
# disk-rc.1/memory-alpha.5 (or vice versa) process as healthy.
|
|
430
|
-
# FAIL CLOSED: an unreadable/missing disk manifest is NOT healthy —
|
|
431
|
-
# the process cannot be proven to match the tree it will next boot.
|
|
432
|
-
$expectedDshVersion = $null
|
|
433
|
-
$diskReadable = $false
|
|
434
|
-
$activeDshManifest = Get-CrewDshManifest -Entry $dshCli
|
|
435
|
-
if ($null -ne $activeDshManifest -and [string] $activeDshManifest.version) {
|
|
436
|
-
$expectedDshVersion = [string] $activeDshManifest.version
|
|
437
|
-
$diskReadable = $true
|
|
438
|
-
}
|
|
439
|
-
$cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
|
|
440
|
-
if ($response.ok -eq $true -and $version -and $cohortMatches) {
|
|
441
|
-
return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
|
|
442
|
-
}
|
|
443
|
-
if (-not $diskReadable) {
|
|
444
|
-
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
|
|
445
|
-
}
|
|
446
|
-
if ($version -and -not $cohortMatches) {
|
|
447
|
-
return [pscustomobject]@{ Ready = $false; Version = [string] $version; Error = ('runtime cohort mismatch: hub reports dsh_version={0} but disk runtime is {1}' -f $runtime.dsh_version, $expectedDshVersion) }
|
|
448
|
-
}
|
|
449
|
-
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Response did not contain a ready runtime contract.' }
|
|
450
|
-
} catch {
|
|
451
|
-
return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
# The launcher supervises only the Crew-owned 3210 service. Any non-Crew
|
|
455
|
-
# service is never started or health-gated here; the legacy 3080 bridge
|
|
456
|
-
# is probed only diagnostically after 3210 readiness (see Write-LegacyBridgeDiagnostic).
|
|
457
|
-
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Unknown service: only the Crew-owned 3210 service is supervised.' }
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
# Optional legacy compatibility probe: returns true only when a 3080 that
|
|
461
|
-
# still hosts the Crew bridge answers its ping endpoint. The Crew launcher
|
|
462
|
-
# never depends on it; 3210 always boots directly first.
|
|
463
|
-
function Test-LegacyBridgeAvailable {
|
|
464
|
-
try {
|
|
465
|
-
$response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/ping' -TimeoutSec 3
|
|
466
|
-
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
|
|
467
|
-
} catch {
|
|
468
|
-
return $false
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
# ---- Crew supervisor control protocol (heartbeat + restart requests) --------
|
|
473
|
-
# The hub (3210) never spawns itself. It writes a durable restart request;
|
|
474
|
-
# this watcher is the only process authority and executes it after proving
|
|
475
|
-
# ownership (persisted identity + live runtime_id match).
|
|
476
|
-
|
|
477
|
-
$crewSupervisorRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\supervisor'
|
|
478
|
-
$crewHeartbeatFile = Join-Path $crewSupervisorRoot 'heartbeat.json'
|
|
479
|
-
$crewRequestsDir = Join-Path $crewSupervisorRoot 'restart-requests'
|
|
480
|
-
$crewResultsDir = Join-Path $crewSupervisorRoot 'restart-results'
|
|
481
|
-
|
|
482
|
-
# UTF-8 WITHOUT BOM: Windows PowerShell 5.1's Set-Content -Encoding UTF8
|
|
483
|
-
# writes a BOM, and the Node hub's JSON.parse rejects it (heartbeat/request
|
|
484
|
-
# files would be unreadable -> supervisor judged unavailable).
|
|
485
|
-
function Write-Utf8NoBom {
|
|
486
|
-
param([string] $Path, [string] $Content)
|
|
487
|
-
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
488
|
-
[System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function Write-SupervisorHeartbeat {
|
|
492
|
-
param([bool] $OwnershipReady = $false)
|
|
493
|
-
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
494
|
-
$record = @{ schema_version = 1; supervisor_instance_id = $supervisorInstanceId; pid = $PID; process_started_at_utc_ticks = $launcherProcessStartedAtUtcTicks; helper_hash = $launcherHelperHash; ownership_ready = $OwnershipReady; last_seen = $now; protocol_version = 1 } | ConvertTo-Json -Compress
|
|
495
|
-
try {
|
|
496
|
-
if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
|
|
497
|
-
$temp = Join-Path $crewSupervisorRoot ("heartbeat.{0}.tmp" -f $PID)
|
|
498
|
-
Write-Utf8NoBom -Path $temp -Content $record
|
|
499
|
-
Move-Item -LiteralPath $temp -Destination $crewHeartbeatFile -Force
|
|
500
|
-
} catch { /* heartbeat is best-effort */ }
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function Get-SupervisorLaunchArguments {
|
|
504
|
-
param([string] $ScriptPath = $PSCommandPath)
|
|
505
|
-
if ([string]::IsNullOrWhiteSpace($ScriptPath) -or $ScriptPath.Contains('"')) {
|
|
506
|
-
throw 'A valid launcher script path is required.'
|
|
507
|
-
}
|
|
508
|
-
return @(
|
|
509
|
-
'-NoLogo',
|
|
510
|
-
'-NoProfile',
|
|
511
|
-
'-NonInteractive',
|
|
512
|
-
'-ExecutionPolicy', 'Bypass',
|
|
513
|
-
'-File', ('"{0}"' -f $ScriptPath),
|
|
514
|
-
'-Mode', 'watch'
|
|
515
|
-
)
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function Get-SupervisorHeartbeatRecord {
|
|
519
|
-
param([int] $MaxAgeSeconds = 30)
|
|
520
|
-
try {
|
|
521
|
-
if (-not (Test-Path -LiteralPath $crewHeartbeatFile -PathType Leaf)) { return $null }
|
|
522
|
-
$record = Get-Content -LiteralPath $crewHeartbeatFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
523
|
-
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
524
|
-
$lastSeen = [long] $record.last_seen
|
|
525
|
-
$age = $now - $lastSeen
|
|
526
|
-
if ($record.schema_version -ne 1 -or $record.protocol_version -ne 1 -or [int] $record.pid -le 0) { return $null }
|
|
527
|
-
if ($age -lt -5000 -or $age -gt ([long] $MaxAgeSeconds * 1000)) { return $null }
|
|
528
|
-
$process = Get-Process -Id ([int] $record.pid) -ErrorAction Stop
|
|
529
|
-
$processStartProperty = $record.PSObject.Properties['process_started_at_utc_ticks']
|
|
530
|
-
if ($null -ne $processStartProperty -and $processStartProperty.Value) {
|
|
531
|
-
$expectedTicks = [long] $processStartProperty.Value
|
|
532
|
-
if ($process.StartTime.ToUniversalTime().Ticks -ne $expectedTicks) { return $null }
|
|
533
|
-
}
|
|
534
|
-
$ownershipProperty = $record.PSObject.Properties['ownership_ready']
|
|
535
|
-
$helperHashProperty = $record.PSObject.Properties['helper_hash']
|
|
536
|
-
$instanceProperty = $record.PSObject.Properties['supervisor_instance_id']
|
|
537
|
-
$identityReady = $null -ne $processStartProperty -and $processStartProperty.Value `
|
|
538
|
-
-and $null -ne $helperHashProperty -and $helperHashProperty.Value -match '^[a-f0-9]{64}$' `
|
|
539
|
-
-and $null -ne $instanceProperty -and -not [string]::IsNullOrWhiteSpace([string] $instanceProperty.Value)
|
|
540
|
-
$state = if ($null -eq $ownershipProperty) { 'legacy-v1' } elseif ($ownershipProperty.Value -eq $true -and $identityReady) { 'ready' } else { 'starting' }
|
|
541
|
-
return [pscustomobject]@{ State = $state; Record = $record }
|
|
542
|
-
} catch {
|
|
543
|
-
return $null
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function Get-FreshSupervisorHeartbeat {
|
|
548
|
-
param([int] $MaxAgeSeconds = 30)
|
|
549
|
-
$observed = Get-SupervisorHeartbeatRecord -MaxAgeSeconds $MaxAgeSeconds
|
|
550
|
-
if ($observed -and $observed.State -eq 'ready') { return $observed.Record }
|
|
551
|
-
return $null
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function Ensure-CrewSupervisorRunning {
|
|
555
|
-
param([int] $TimeoutSeconds = 90)
|
|
556
|
-
$watcher = $null
|
|
557
|
-
$observed = Get-SupervisorHeartbeatRecord
|
|
558
|
-
if ($observed -and $observed.State -eq 'legacy-v1') {
|
|
559
|
-
throw 'CREW_SUPERVISOR_UPGRADE_REQUIRED: a legacy watcher is active and must be handed off before interactive launch.'
|
|
560
|
-
}
|
|
561
|
-
if (-not $observed) {
|
|
562
|
-
$arguments = @(Get-SupervisorLaunchArguments -ScriptPath $PSCommandPath)
|
|
563
|
-
$watcher = Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WindowStyle Hidden -PassThru
|
|
564
|
-
Write-LaunchLog ('Started persistent Crew supervisor; PID={0}.' -f $watcher.Id)
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
$crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
|
|
568
|
-
if (-not $crew) { throw 'Crew-owned 3210 service definition is missing.' }
|
|
569
|
-
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
570
|
-
$nextProgress = (Get-Date).AddSeconds(5)
|
|
571
|
-
$lastHealthError = 'not checked'
|
|
572
|
-
do {
|
|
573
|
-
$heartbeat = Get-FreshSupervisorHeartbeat
|
|
574
|
-
$health = Get-HealthState $crew
|
|
575
|
-
$lastHealthError = $health.Error
|
|
576
|
-
if ($heartbeat -and $health.Ready) {
|
|
577
|
-
Write-LaunchLog ('Persistent supervisor ready; PID={0}; 3210 runtime={1}.' -f $heartbeat.pid, $health.Version)
|
|
578
|
-
return
|
|
579
|
-
}
|
|
580
|
-
if ((Get-Date) -ge $nextProgress) {
|
|
581
|
-
Write-LaunchLog ('Waiting for Crew on 3210; {0:n0}s remaining. Health: {1}' -f [Math]::Max(0, ($deadline - (Get-Date)).TotalSeconds), $lastHealthError)
|
|
582
|
-
$nextProgress = (Get-Date).AddSeconds(5)
|
|
583
|
-
}
|
|
584
|
-
Start-Sleep -Milliseconds 500
|
|
585
|
-
} while ((Get-Date) -lt $deadline)
|
|
586
|
-
|
|
587
|
-
throw ('Persistent supervisor did not make 3210 ready within {0}s. Last health error: {1}' -f $TimeoutSeconds, $lastHealthError)
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function Read-RestartRequests {
|
|
591
|
-
if (-not (Test-Path -LiteralPath $crewRequestsDir -PathType Container)) { return @() }
|
|
592
|
-
$requests = @()
|
|
593
|
-
Get-ChildItem -LiteralPath $crewRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue | ForEach-Object {
|
|
594
|
-
try {
|
|
595
|
-
$parsed = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
|
|
596
|
-
if ($parsed.schema_version -eq 1 -and $parsed.operation -eq 'restart' -and $parsed.request_id) {
|
|
597
|
-
$requests += $parsed
|
|
598
|
-
}
|
|
599
|
-
} catch { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
|
|
600
|
-
}
|
|
601
|
-
return $requests
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function Write-RestartResult {
|
|
605
|
-
param([object] $Request, [string] $State, [object] $Detail = $null)
|
|
606
|
-
try {
|
|
607
|
-
if (-not (Test-Path -LiteralPath $crewResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewResultsDir -Force | Out-Null }
|
|
608
|
-
$result = @{
|
|
609
|
-
schema_version = 1
|
|
610
|
-
request_id = $Request.request_id
|
|
611
|
-
operation = 'restart'
|
|
612
|
-
state = $State
|
|
613
|
-
runtime_id = $Request.runtime_id
|
|
614
|
-
written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
615
|
-
detail = $Detail
|
|
616
|
-
} | ConvertTo-Json -Depth 5
|
|
617
|
-
$file = Join-Path $crewResultsDir ("{0}.json" -f $Request.request_id)
|
|
618
|
-
$temp = Join-Path $crewResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
|
|
619
|
-
Write-Utf8NoBom -Path $temp -Content $result
|
|
620
|
-
Move-Item -LiteralPath $temp -Destination $file -Force
|
|
621
|
-
} catch { /* best effort */ }
|
|
622
|
-
Remove-Item -LiteralPath (Join-Path $crewRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function Invoke-CrewRestartRequests {
|
|
626
|
-
# Consume durable restart requests from the hub. Called by the watch loop.
|
|
627
|
-
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
628
|
-
foreach ($request in (Read-RestartRequests)) {
|
|
629
|
-
if (($request.expires_at -as [long]) -lt $now) {
|
|
630
|
-
Write-RestartResult $request 'RESTART_REQUEST_EXPIRED'
|
|
631
|
-
Write-LaunchLog ('Restart request {0} expired; not executed.' -f $request.request_id) 'WARN'
|
|
632
|
-
continue
|
|
633
|
-
}
|
|
634
|
-
# Ownership authority: the request must name the SAME runtime identity we
|
|
635
|
-
# own, and that identity must still be live on 3210.
|
|
636
|
-
$crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
|
|
637
|
-
if (-not $crew) { continue }
|
|
638
|
-
$health = Get-HealthState $crew
|
|
639
|
-
$liveIdentityOk = $false
|
|
640
|
-
try {
|
|
641
|
-
$response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
|
|
642
|
-
$liveRuntimeId = $response.extension.runtime.runtime_id
|
|
643
|
-
$liveIdentityOk = ($response.ok -eq $true) -and ($liveRuntimeId -eq $request.runtime_id)
|
|
644
|
-
} catch { $liveIdentityOk = $false }
|
|
645
|
-
if (-not $liveIdentityOk) {
|
|
646
|
-
Write-RestartResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
647
|
-
Write-LaunchLog ('Restart request {0} rejected: live runtime_id does not match the request.' -f $request.request_id) 'WARN'
|
|
648
|
-
continue
|
|
649
|
-
}
|
|
650
|
-
Write-LaunchLog ('Executing restart request {0} (reason: {1}).' -f $request.request_id, $request.reason)
|
|
651
|
-
$previousRuntimeId = $request.runtime_id
|
|
652
|
-
# Re-read the listener port right before the kill so the PID identity is
|
|
653
|
-
# fresh: Stop-OwnedListener requires a PID that equals the tracked
|
|
654
|
-
# listener and belongs to the current owned process tree.
|
|
655
|
-
$port = Get-PortState $crew.Port
|
|
656
|
-
if ($port.State -ne 'occupied' -or -not $port.Pid) {
|
|
657
|
-
Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
|
|
658
|
-
Write-LaunchLog ('Restart request {0} failed: no listener on port {1}.' -f $request.request_id, $crew.Port) 'ERROR'
|
|
659
|
-
continue
|
|
660
|
-
}
|
|
661
|
-
$stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
|
|
662
|
-
if (-not $stopped) {
|
|
663
|
-
Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
|
|
664
|
-
Write-LaunchLog ('Restart request {0} failed to stop the owned runtime.' -f $request.request_id) 'ERROR'
|
|
665
|
-
continue
|
|
666
|
-
}
|
|
667
|
-
Start-CrewService $crew
|
|
668
|
-
Wait-CrewServices
|
|
669
|
-
# Verify: runtime_id must have changed and the cohort must still match.
|
|
670
|
-
$newRuntimeId = $null
|
|
671
|
-
try {
|
|
672
|
-
$response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
|
|
673
|
-
$newRuntimeId = $response.extension.runtime.runtime_id
|
|
674
|
-
} catch { $newRuntimeId = $null }
|
|
675
|
-
if ($newRuntimeId -and $newRuntimeId -ne $previousRuntimeId) {
|
|
676
|
-
Write-RestartResult $request 'VERIFIED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
|
|
677
|
-
Write-LaunchLog ('Restart request {0} verified: runtime_id {1} -> {2}.' -f $request.request_id, $previousRuntimeId, $newRuntimeId)
|
|
678
|
-
} else {
|
|
679
|
-
Write-RestartResult $request 'VERIFY_FAILED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
|
|
680
|
-
Write-LaunchLog ('Restart request {0} verification failed: runtime_id did not change.' -f $request.request_id) 'ERROR'
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
$crewMaintenanceRequestsDir = Join-Path $crewSupervisorRoot 'maintenance-requests'
|
|
686
|
-
$crewMaintenanceResultsDir = Join-Path $crewSupervisorRoot 'maintenance-results'
|
|
687
|
-
$crewMaintenanceSessionFile = Join-Path $crewSupervisorRoot 'maintenance-session.json'
|
|
688
|
-
|
|
689
|
-
function Test-MaintenanceSessionActive {
|
|
690
|
-
# Fence for ordinary Crew auto-start. Returns $true when a STOPPED session
|
|
691
|
-
# holds the launch right OR when the session file is present but MALFORMED
|
|
692
|
-
# (fail closed: a corrupt session must not let supervision auto-start
|
|
693
|
-
# 3210, because the stopped window may belong to a live npx swap).
|
|
694
|
-
# ABSENT means no fence: ordinary supervision proceeds.
|
|
695
|
-
$session = Read-MaintenanceSession
|
|
696
|
-
if ($session -eq 'ABSENT') { return $false }
|
|
697
|
-
return $true
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function Set-MaintenanceSession {
|
|
701
|
-
param([object] $Request)
|
|
702
|
-
# Strong-failure semantics: the STOPPED result may only be published after
|
|
703
|
-
# the session is durably written AND read back with exact identity. Any
|
|
704
|
-
# failure returns $false and the caller must NOT claim the stopped window.
|
|
705
|
-
try {
|
|
706
|
-
$session = @{
|
|
707
|
-
schema_version = 1
|
|
708
|
-
state = 'STOPPED'
|
|
709
|
-
lease = $Request.lease
|
|
710
|
-
runtime_id = $Request.runtime_id
|
|
711
|
-
stopped_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
712
|
-
request_id = $Request.request_id
|
|
713
|
-
} | ConvertTo-Json -Compress
|
|
714
|
-
$temp = Join-Path $crewSupervisorRoot ("maintenance-session.{0}.tmp" -f $PID)
|
|
715
|
-
Write-Utf8NoBom -Path $temp -Content $session
|
|
716
|
-
Move-Item -LiteralPath $temp -Destination $crewMaintenanceSessionFile -Force
|
|
717
|
-
$back = Read-MaintenanceSession
|
|
718
|
-
if ($back -is [string]) { return $false }
|
|
719
|
-
return ($back.schema_version -eq 1 -and $back.state -eq 'STOPPED' -and $back.lease -eq $Request.lease -and $back.runtime_id -eq $Request.runtime_id -and $back.request_id -eq $Request.request_id)
|
|
720
|
-
} catch { return $false }
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
function Clear-MaintenanceSession {
|
|
724
|
-
# Verifiable one-shot consumption: returns $true only when the session
|
|
725
|
-
# file is provably ABSENT afterwards (otherwise the lease is NOT consumed
|
|
726
|
-
# and replay stays impossible because start re-validates the session).
|
|
727
|
-
try {
|
|
728
|
-
Remove-Item -LiteralPath $crewMaintenanceSessionFile -Force -ErrorAction Stop
|
|
729
|
-
} catch {
|
|
730
|
-
if (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf) { return $false }
|
|
731
|
-
return $true
|
|
732
|
-
}
|
|
733
|
-
return (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf))
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function Read-MaintenanceSession {
|
|
737
|
-
# Tri-state: 'ABSENT' (no file), a session object when valid, or the
|
|
738
|
-
# string 'MALFORMED' (present but unreadable/invalid). MALFORMED fails
|
|
739
|
-
# closed for Crew auto-start (see Test-MaintenanceSessionActive).
|
|
740
|
-
try {
|
|
741
|
-
if (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf)) { return 'ABSENT' }
|
|
742
|
-
$parsed = Get-Content -LiteralPath $crewMaintenanceSessionFile -Raw | ConvertFrom-Json -ErrorAction Stop
|
|
743
|
-
if ($parsed.schema_version -ne 1 -or $parsed.state -ne 'STOPPED' -or -not $parsed.lease -or -not $parsed.runtime_id -or -not $parsed.request_id) {
|
|
744
|
-
return 'MALFORMED'
|
|
745
|
-
}
|
|
746
|
-
return $parsed
|
|
747
|
-
} catch { return 'MALFORMED' }
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
function Write-MaintenanceResult {
|
|
751
|
-
param([object] $Request, [string] $State, [object] $Detail = $null)
|
|
752
|
-
try {
|
|
753
|
-
if (-not (Test-Path -LiteralPath $crewMaintenanceResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewMaintenanceResultsDir -Force | Out-Null }
|
|
754
|
-
$result = @{
|
|
755
|
-
schema_version = 1
|
|
756
|
-
request_id = $Request.request_id
|
|
757
|
-
operation = $Request.operation
|
|
758
|
-
state = $State
|
|
759
|
-
lease = $Request.lease
|
|
760
|
-
runtime_id = $Request.runtime_id
|
|
761
|
-
written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
762
|
-
detail = $Detail
|
|
763
|
-
} | ConvertTo-Json -Depth 5
|
|
764
|
-
$file = Join-Path $crewMaintenanceResultsDir ("{0}.json" -f $Request.request_id)
|
|
765
|
-
$temp = Join-Path $crewMaintenanceResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
|
|
766
|
-
Write-Utf8NoBom -Path $temp -Content $result
|
|
767
|
-
Move-Item -LiteralPath $temp -Destination $file -Force
|
|
768
|
-
} catch { /* best effort */ }
|
|
769
|
-
Remove-Item -LiteralPath (Join-Path $crewMaintenanceRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
function Invoke-CrewMaintenanceRequests {
|
|
773
|
-
# Consume maintenance transactions from npx lifecycle (cohort migration).
|
|
774
|
-
# The npx process owns the runtime TREE swap; this watcher owns the
|
|
775
|
-
# PROCESS stop/start around it. Two phases, both verified:
|
|
776
|
-
# maintenance-stop -> stop owned 3210, write STOPPED (+lease)
|
|
777
|
-
# maintenance-start -> start 3210, verify new identity + cohort, VERIFIED
|
|
778
|
-
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
779
|
-
if (-not (Test-Path -LiteralPath $crewMaintenanceRequestsDir -PathType Container)) { return }
|
|
780
|
-
$files = Get-ChildItem -LiteralPath $crewMaintenanceRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue
|
|
781
|
-
foreach ($file in $files) {
|
|
782
|
-
try {
|
|
783
|
-
$request = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
|
|
784
|
-
} catch {
|
|
785
|
-
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
|
|
786
|
-
continue
|
|
787
|
-
}
|
|
788
|
-
if ($request.schema_version -ne 1 -or -not $request.request_id -or -not $request.operation) {
|
|
789
|
-
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
|
|
790
|
-
continue
|
|
791
|
-
}
|
|
792
|
-
if (($request.expires_at -as [long]) -lt $now) {
|
|
793
|
-
Write-MaintenanceResult $request 'MAINTENANCE_EXPIRED'
|
|
794
|
-
Write-LaunchLog ('Maintenance request {0} ({1}) expired; not executed.' -f $request.request_id, $request.operation) 'WARN'
|
|
795
|
-
continue
|
|
796
|
-
}
|
|
797
|
-
$crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
|
|
798
|
-
if (-not $crew) { continue }
|
|
799
|
-
if ($request.operation -eq 'maintenance-stop') {
|
|
800
|
-
# Authority: the request must name the SAME live runtime identity we own.
|
|
801
|
-
$liveRuntimeId = $null
|
|
802
|
-
try {
|
|
803
|
-
$resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
|
|
804
|
-
$runtime = $resp.extension.runtime
|
|
805
|
-
$canonical = $resp.ok -eq $true `
|
|
806
|
-
-and $runtime.service -eq 'dsh-crew-hub' `
|
|
807
|
-
-and $runtime.execution_plane -eq 'hub-3210' `
|
|
808
|
-
-and $runtime.profile -eq 'dsh-crew' `
|
|
809
|
-
-and ($runtime.listen_port -as [int]) -eq 3210 `
|
|
810
|
-
-and ($runtime.protocol_version -as [int]) -eq 1
|
|
811
|
-
if ($canonical) { $liveRuntimeId = $runtime.runtime_id }
|
|
812
|
-
} catch { $liveRuntimeId = $null }
|
|
813
|
-
if (-not $liveRuntimeId -or $liveRuntimeId -ne $request.runtime_id) {
|
|
814
|
-
Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
815
|
-
Write-LaunchLog ('Maintenance-stop {0} rejected: live runtime_id mismatch.' -f $request.request_id) 'WARN'
|
|
816
|
-
continue
|
|
817
|
-
}
|
|
818
|
-
$port = Get-PortState $crew.Port
|
|
819
|
-
if ($port.State -ne 'occupied' -or -not $port.Pid) {
|
|
820
|
-
Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
|
|
821
|
-
continue
|
|
822
|
-
}
|
|
823
|
-
$stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
|
|
824
|
-
if ($stopped) {
|
|
825
|
-
# The stopped window now belongs to the npx lifecycle: persist the
|
|
826
|
-
# STOPPED session (lease + proven runtime_id) so ordinary
|
|
827
|
-
# supervision will NOT auto-start 3210 until the matching start.
|
|
828
|
-
# STRONG semantics: STOPPED is published ONLY after the session is
|
|
829
|
-
# durably written AND read back with exact identity. A session write
|
|
830
|
-
# failure must never tell npx it owns a stopped window it cannot
|
|
831
|
-
# later prove (that race auto-restarts 3210 mid tree-swap).
|
|
832
|
-
$sessionDurable = Set-MaintenanceSession $request
|
|
833
|
-
if ($sessionDurable) {
|
|
834
|
-
Write-MaintenanceResult $request 'STOPPED' @{ lease = $request.lease; stopped_runtime_id = $request.runtime_id }
|
|
835
|
-
Write-LaunchLog ('Maintenance-stop {0} executed; lease issued.' -f $request.request_id)
|
|
836
|
-
} else {
|
|
837
|
-
Write-MaintenanceResult $request 'SUPERVISOR_SESSION_PERSIST_FAILED'
|
|
838
|
-
Write-LaunchLog ('Maintenance-stop {0} stopped the process but the STOPPED session could not be persisted; NOT publishing STOPPED.' -f $request.request_id) 'ERROR'
|
|
839
|
-
}
|
|
840
|
-
} else {
|
|
841
|
-
Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
|
|
842
|
-
}
|
|
843
|
-
} elseif ($request.operation -eq 'maintenance-start') {
|
|
844
|
-
# Pair with the matching STOPPED session: the start request must carry
|
|
845
|
-
# the SAME lease and runtime_id the stop proved. A missing session,
|
|
846
|
-
# mismatched lease/identity, or a still-listening port all fail closed
|
|
847
|
-
# WITHOUT starting anything. The session is consumed on VERIFIED so
|
|
848
|
-
# the lease is one-shot (no replay).
|
|
849
|
-
$lease = $request.lease
|
|
850
|
-
$expectedCrew = $null
|
|
851
|
-
$expectedDsh = $null
|
|
852
|
-
if ($request.extra) {
|
|
853
|
-
$expectedCrew = $request.extra.expected_crew_version
|
|
854
|
-
$expectedDsh = $request.extra.expected_dsh_version
|
|
855
|
-
}
|
|
856
|
-
$session = Read-MaintenanceSession
|
|
857
|
-
if ($session -is [string] -or $session.lease -ne $lease -or $session.runtime_id -ne $request.runtime_id) {
|
|
858
|
-
Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
859
|
-
Write-LaunchLog ('Maintenance-start {0} rejected: no matching STOPPED session (lease/identity mismatch).' -f $request.request_id) 'WARN'
|
|
860
|
-
continue
|
|
861
|
-
}
|
|
862
|
-
$livePort = Get-PortState $crew.Port
|
|
863
|
-
if ($livePort.State -ne 'free') {
|
|
864
|
-
# occupied AND unknown both fail closed: the stopped window is not
|
|
865
|
-
# provably clean, so no start authority.
|
|
866
|
-
Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
867
|
-
Write-LaunchLog ('Maintenance-start {0} rejected: port {1} is not provably free (state={2}).' -f $request.request_id, $crew.Port, $livePort.State) 'WARN'
|
|
868
|
-
continue
|
|
869
|
-
}
|
|
870
|
-
Start-CrewService $crew
|
|
871
|
-
Wait-CrewServices
|
|
872
|
-
$newRuntime = $null
|
|
873
|
-
try {
|
|
874
|
-
$resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 5
|
|
875
|
-
if ($resp.ok -eq $true) { $newRuntime = $resp.extension.runtime }
|
|
876
|
-
} catch { $newRuntime = $null }
|
|
877
|
-
$ok = $newRuntime -and $newRuntime.runtime_id `
|
|
878
|
-
-and $newRuntime.service -eq 'dsh-crew-hub' `
|
|
879
|
-
-and $newRuntime.execution_plane -eq 'hub-3210' `
|
|
880
|
-
-and $newRuntime.profile -eq 'dsh-crew' `
|
|
881
|
-
-and ($newRuntime.listen_port -as [int]) -eq 3210 `
|
|
882
|
-
-and ($newRuntime.protocol_version -as [int]) -eq 1 `
|
|
883
|
-
-and ($expectedCrew -eq $null -or $newRuntime.runtime_version -eq $expectedCrew) `
|
|
884
|
-
-and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
|
|
885
|
-
if ($ok) {
|
|
886
|
-
# Verifiable one-shot consumption: only a provably-cleared session
|
|
887
|
-
# completes the transaction; otherwise the lease stays live and any
|
|
888
|
-
# replay is still fenced (no VERIFIED without consumption proof).
|
|
889
|
-
$consumed = Clear-MaintenanceSession
|
|
890
|
-
if ($consumed) {
|
|
891
|
-
Write-MaintenanceResult $request 'VERIFIED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id; runtime_version = $newRuntime.runtime_version; dsh_version = $newRuntime.dsh_version }
|
|
892
|
-
Write-LaunchLog ('Maintenance-start {0} verified: Crew {1} + DSH {2}.' -f $request.request_id, $newRuntime.runtime_version, $newRuntime.dsh_version)
|
|
893
|
-
} else {
|
|
894
|
-
Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id }
|
|
895
|
-
Write-LaunchLog ('Maintenance-start {0} identity verified but session consumption unproven; NOT marking VERIFIED.' -f $request.request_id) 'ERROR'
|
|
896
|
-
}
|
|
897
|
-
} else {
|
|
898
|
-
$failedRuntimeId = $null
|
|
899
|
-
if ($newRuntime) { $failedRuntimeId = $newRuntime.runtime_id }
|
|
900
|
-
Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $failedRuntimeId }
|
|
901
|
-
Write-LaunchLog ('Maintenance-start {0} verification failed.' -f $request.request_id) 'ERROR'
|
|
902
|
-
}
|
|
903
|
-
} else {
|
|
904
|
-
# Unknown maintenance op: remove and report.
|
|
905
|
-
Write-MaintenanceResult $request 'MAINTENANCE_UNKNOWN_OP'
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
function Get-PortState {
|
|
912
|
-
param([int] $Port)
|
|
913
|
-
try {
|
|
914
|
-
$listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
|
|
915
|
-
$occupied = @($listeners | Where-Object Port -eq $Port).Count -gt 0
|
|
916
|
-
if (-not $occupied) {
|
|
917
|
-
return [pscustomobject]@{ State = 'free'; Error = $null; Pid = $null }
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
$ownerPid = $null
|
|
921
|
-
try {
|
|
922
|
-
$ownerPid = (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | Select-Object -First 1).OwningProcess
|
|
923
|
-
} catch { }
|
|
924
|
-
return [pscustomobject]@{ State = 'occupied'; Error = $null; Pid = $ownerPid }
|
|
925
|
-
} catch {
|
|
926
|
-
return [pscustomobject]@{ State = 'unknown'; Error = ('Listener enumeration failed: {0}' -f $_.Exception.Message); Pid = $null }
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function Test-TrackedProcessIdentity {
|
|
931
|
-
param([int] $ProcessId, [long] $ExpectedStartTicks, [object[]] $ProcessTable)
|
|
932
|
-
if ($ProcessId -lt 1 -or $ExpectedStartTicks -lt 1) { return $false }
|
|
933
|
-
$record = @($ProcessTable | Where-Object { [int] $_.ProcessId -eq $ProcessId } | Select-Object -First 1)
|
|
934
|
-
if ($record.Count -eq 0) { return $false }
|
|
935
|
-
$startTicks = $null
|
|
936
|
-
if ($record[0].PSObject.Properties.Name -contains 'StartTicks') {
|
|
937
|
-
$startTicks = [long] $record[0].StartTicks
|
|
938
|
-
} else {
|
|
939
|
-
try { $startTicks = (Get-Process -Id $ProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks } catch { return $false }
|
|
940
|
-
}
|
|
941
|
-
return $startTicks -eq $ExpectedStartTicks
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function Get-TrackedProcessTree {
|
|
945
|
-
param([pscustomobject] $Service, [object[]] $ProcessTable = $null)
|
|
946
|
-
$processes = if ($null -ne $ProcessTable) { @($ProcessTable) } else { @(Get-CimInstance Win32_Process -ErrorAction Stop) }
|
|
947
|
-
$owned = [System.Collections.Generic.HashSet[int]]::new()
|
|
948
|
-
$rootMatches = Test-TrackedProcessIdentity -ProcessId $Service.RootPid -ExpectedStartTicks $Service.RootStartedAtUtcTicks -ProcessTable $processes
|
|
949
|
-
$hasTrackedListener = $Service.ListenerPid -and $Service.ListenerStartedAtUtcTicks
|
|
950
|
-
if ($hasTrackedListener) {
|
|
951
|
-
$listenerMatches = Test-TrackedProcessIdentity -ProcessId $Service.ListenerPid -ExpectedStartTicks $Service.ListenerStartedAtUtcTicks -ProcessTable $processes
|
|
952
|
-
if (-not $listenerMatches) { return @() }
|
|
953
|
-
[void] $owned.Add([int] $Service.ListenerPid)
|
|
954
|
-
} elseif (-not $rootMatches) {
|
|
955
|
-
return @()
|
|
956
|
-
}
|
|
957
|
-
if ($rootMatches) { [void] $owned.Add([int] $Service.RootPid) }
|
|
958
|
-
do {
|
|
959
|
-
$added = $false
|
|
960
|
-
foreach ($candidate in $processes) {
|
|
961
|
-
$candidateId = [int] $candidate.ProcessId
|
|
962
|
-
$parentId = [int] $candidate.ParentProcessId
|
|
963
|
-
if ($owned.Contains($parentId) -and $owned.Add($candidateId)) { $added = $true }
|
|
964
|
-
}
|
|
965
|
-
} while ($added)
|
|
966
|
-
return @($owned | ForEach-Object { [int] $_ })
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
function Set-TrackedListenerIdentity {
|
|
970
|
-
param([pscustomobject] $Service)
|
|
971
|
-
$port = Get-PortState $Service.Port
|
|
972
|
-
if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
|
|
973
|
-
$tree = @(Get-TrackedProcessTree -Service $Service)
|
|
974
|
-
if ($port.Pid -notin $tree) { return $false }
|
|
975
|
-
try {
|
|
976
|
-
$listener = Get-Process -Id $port.Pid -ErrorAction Stop
|
|
977
|
-
$Service.ListenerPid = [int] $port.Pid
|
|
978
|
-
$Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
|
|
979
|
-
return $true
|
|
980
|
-
} catch {
|
|
981
|
-
return $false
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
function Test-CrewServiceOwnership {
|
|
986
|
-
param(
|
|
987
|
-
[pscustomObject] $Service,
|
|
988
|
-
[pscustomObject] $PortState = $null,
|
|
989
|
-
[object[]] $ProcessTable = $null
|
|
990
|
-
)
|
|
991
|
-
if (-not $Service.ListenerPid -or -not $Service.ListenerStartedAtUtcTicks) { return $false }
|
|
992
|
-
$port = if ($null -ne $PortState) { $PortState } else { Get-PortState $Service.Port }
|
|
993
|
-
if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $Service.ListenerPid) { return $false }
|
|
994
|
-
try {
|
|
995
|
-
$tree = if ($null -ne $ProcessTable) {
|
|
996
|
-
@(Get-TrackedProcessTree -Service $Service -ProcessTable $ProcessTable)
|
|
997
|
-
} else {
|
|
998
|
-
@(Get-TrackedProcessTree -Service $Service)
|
|
999
|
-
}
|
|
1000
|
-
return ([int] $port.Pid -in $tree)
|
|
1001
|
-
} catch {
|
|
1002
|
-
return $false
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
function Stop-OwnedListener {
|
|
1007
|
-
param([pscustomobject] $Service, [int] $ListenerPid)
|
|
1008
|
-
if ($Mode -ne 'watch' -or -not $Service.ListenerPid -or $ListenerPid -ne $Service.ListenerPid) {
|
|
1009
|
-
return $false
|
|
1010
|
-
}
|
|
1011
|
-
try {
|
|
1012
|
-
$ownedProcessIds = @(Get-TrackedProcessTree -Service $Service)
|
|
1013
|
-
if ($ListenerPid -notin $ownedProcessIds) { return $false }
|
|
1014
|
-
Write-LaunchLog ('Supervisor confirmed owned listener PID={0} under tracked root PID={1}; stopping that process tree after {2} consecutive failed health checks.' -f $ListenerPid, $Service.RootPid, $Service.ConsecutiveFailures) 'WARN'
|
|
1015
|
-
foreach ($processId in $ownedProcessIds) {
|
|
1016
|
-
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
|
1017
|
-
}
|
|
1018
|
-
$deadline = (Get-Date).AddSeconds(5)
|
|
1019
|
-
do {
|
|
1020
|
-
$port = Get-PortState $Service.Port
|
|
1021
|
-
if ($port.State -eq 'free') { return $true }
|
|
1022
|
-
Start-Sleep -Milliseconds 250
|
|
1023
|
-
} while ((Get-Date) -lt $deadline)
|
|
1024
|
-
} catch {
|
|
1025
|
-
Write-LaunchLog ('Supervisor could not stop its owned listener safely: {0}' -f $_.Exception.Message) 'WARN'
|
|
1026
|
-
}
|
|
1027
|
-
return $false
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
function Assert-HistoryStartAllowed {
|
|
1031
|
-
param([string] $StatePath = (Join-Path $crewHome '..\history\active.json'))
|
|
1032
|
-
if (-not (Test-Path -LiteralPath $StatePath)) { return }
|
|
1033
|
-
try {
|
|
1034
|
-
$state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
1035
|
-
if ($state.schemaVersion -eq 1 -and $state.phase -in @('STARTING', 'VERIFYING', 'DONE', 'FAILED', 'ROLLED_BACK')) { return }
|
|
1036
|
-
} catch { }
|
|
1037
|
-
throw 'HISTORY_RECOVERY_REQUIRED: Crew history maintenance is unfinished. Run dsh-crew history recover. Official 3080 is unchanged.'
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
function Start-CrewService {
|
|
1041
|
-
param([pscustomobject] $Service)
|
|
1042
|
-
Assert-HistoryStartAllowed
|
|
1043
|
-
$serviceRunStamp = (Get-Date).ToString('yyyyMMdd-HHmmssfff')
|
|
1044
|
-
$stdout = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.out.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
|
|
1045
|
-
$stderr = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.err.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
|
|
1046
|
-
$previousHome = $env:DSH_HOME
|
|
1047
|
-
try {
|
|
1048
|
-
$env:DSH_HOME = $Service.Home
|
|
1049
|
-
$arguments = @('--profile', $Service.Profile, '--host', '127.0.0.1', '--port', [string] $Service.Port, '--no-open')
|
|
1050
|
-
$launchArguments = if ($dshCliIsNodeEntry) { @($dshCli) + $arguments } else { $arguments }
|
|
1051
|
-
$process = Start-Process -FilePath $dshCommand -ArgumentList $launchArguments -WindowStyle Hidden -PassThru `
|
|
1052
|
-
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
1053
|
-
$Service.Process = $process
|
|
1054
|
-
$Service.RootPid = $process.Id
|
|
1055
|
-
$Service.RootStartedAtUtcTicks = $process.StartTime.ToUniversalTime().Ticks
|
|
1056
|
-
$Service.ListenerPid = $null
|
|
1057
|
-
$Service.ListenerStartedAtUtcTicks = $null
|
|
1058
|
-
$Service.ConsecutiveFailures = 0
|
|
1059
|
-
$Service.State = 'starting'
|
|
1060
|
-
Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
|
|
1061
|
-
} finally {
|
|
1062
|
-
$env:DSH_HOME = $previousHome
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
function Wait-CrewServices {
|
|
1067
|
-
$deadline = (Get-Date).AddSeconds(90)
|
|
1068
|
-
while (@($services | Where-Object State -eq 'starting').Count -gt 0 -and (Get-Date) -lt $deadline) {
|
|
1069
|
-
foreach ($service in ($services | Where-Object State -eq 'starting')) {
|
|
1070
|
-
$health = Get-HealthState $service
|
|
1071
|
-
$service.LastError = $health.Error
|
|
1072
|
-
if ($health.Ready) {
|
|
1073
|
-
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
|
|
1074
|
-
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1075
|
-
}
|
|
1076
|
-
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
1077
|
-
throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
|
|
1078
|
-
}
|
|
1079
|
-
$service.State = 'ready'
|
|
1080
|
-
$service.ConsecutiveFailures = 0
|
|
1081
|
-
Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
|
|
1082
|
-
} elseif ($service.Process -and $service.Process.HasExited) {
|
|
1083
|
-
throw ('{0} exited before becoming ready; PID={1}; exit={2}; last health error: {3}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode, $health.Error)
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
if (@($services | Where-Object State -eq 'starting').Count -gt 0) { Start-Sleep -Milliseconds 500 }
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
$notReady = @($services | Where-Object {
|
|
1090
|
-
$_.State -ne 'ready'
|
|
1091
|
-
})
|
|
1092
|
-
if ($notReady.Count -gt 0) {
|
|
1093
|
-
$details = ($notReady | ForEach-Object { '{0}:{1} ({2})' -f $_.Profile, $_.Port, $_.LastError }) -join '; '
|
|
1094
|
-
throw "Startup health deadline exceeded: $details"
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
# Explicit diagnostic helper; not called by normal startup or supervision.
|
|
1099
|
-
function Write-LegacyBridgeDiagnostic {
|
|
1100
|
-
if (Test-LegacyBridgeAvailable) {
|
|
1101
|
-
Write-LaunchLog 'Diagnostic: legacy 3080 bridge still answers ping; Crew launcher owns 3210 directly, bridge ignored.'
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function Ensure-CrewServices {
|
|
1106
|
-
param([switch] $QuietHealthy)
|
|
1107
|
-
|
|
1108
|
-
foreach ($service in $services) {
|
|
1109
|
-
# Maintenance fence: while a STOPPED maintenance session is active for
|
|
1110
|
-
# the Crew backend, ordinary supervision must NOT auto-start 3210 —
|
|
1111
|
-
# the stopped window belongs to the npx lifecycle's tree swap. Only a
|
|
1112
|
-
# matching maintenance-start owns the launch right.
|
|
1113
|
-
if ($service.CrewOwned -and (Test-MaintenanceSessionActive)) {
|
|
1114
|
-
$service.State = 'maintenance'
|
|
1115
|
-
$service.LastError = $null
|
|
1116
|
-
continue
|
|
1117
|
-
}
|
|
1118
|
-
$health = Get-HealthState $service
|
|
1119
|
-
if ($health.Ready) {
|
|
1120
|
-
$wasReady = $service.State -eq 'ready'
|
|
1121
|
-
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
|
|
1122
|
-
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1123
|
-
}
|
|
1124
|
-
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
1125
|
-
throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
|
|
1126
|
-
}
|
|
1127
|
-
$service.State = 'ready'
|
|
1128
|
-
$service.ConsecutiveFailures = 0
|
|
1129
|
-
$service.LastError = $null
|
|
1130
|
-
if (-not $QuietHealthy -or -not $wasReady) {
|
|
1131
|
-
Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
|
|
1132
|
-
}
|
|
1133
|
-
continue
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
# The Crew launcher owns 3210 directly and always boots it below.
|
|
1137
|
-
# Legacy bridge presence is diagnosed only after 3210 is ready (see
|
|
1138
|
-
# Wait-CrewServices tail), never on the boot path.
|
|
1139
|
-
|
|
1140
|
-
$service.LastError = $health.Error
|
|
1141
|
-
$service.ConsecutiveFailures += 1
|
|
1142
|
-
if ($service.Process -and $service.Process.HasExited) {
|
|
1143
|
-
Write-LaunchLog ('{0} process exited after startup; PID={1}; exit={2}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode) 'WARN'
|
|
1144
|
-
$service.Process = $null
|
|
1145
|
-
}
|
|
1146
|
-
$service.State = 'pending'
|
|
1147
|
-
|
|
1148
|
-
$port = Get-PortState $service.Port
|
|
1149
|
-
if ($port.State -eq 'occupied') {
|
|
1150
|
-
if ($service.ConsecutiveFailures -lt 3) {
|
|
1151
|
-
throw ('Health check {0}/3 failed for {1}; owned process remains untouched until failure is confirmed. Health error: {2}' -f $service.ConsecutiveFailures, $service.Name, $health.Error)
|
|
1152
|
-
}
|
|
1153
|
-
if (Stop-OwnedListener -Service $service -ListenerPid $port.Pid) {
|
|
1154
|
-
$service.Process = $null
|
|
1155
|
-
$service.RootPid = $null
|
|
1156
|
-
$service.RootStartedAtUtcTicks = $null
|
|
1157
|
-
$service.ListenerPid = $null
|
|
1158
|
-
$service.ListenerStartedAtUtcTicks = $null
|
|
1159
|
-
$service.ConsecutiveFailures = 0
|
|
1160
|
-
$port = Get-PortState $service.Port
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
if ($port.State -eq 'occupied') {
|
|
1164
|
-
$owner = if ($port.Pid) { "; listener PID=$($port.Pid)" } else { '' }
|
|
1165
|
-
throw ('Port {0} is occupied, but {1} failed its health contract{2}. Health error: {3}' -f $service.Port, $service.Name, $owner, $health.Error)
|
|
1166
|
-
}
|
|
1167
|
-
if ($port.State -ne 'free') {
|
|
1168
|
-
throw ('Could not determine whether port {0} is available: {1}' -f $service.Port, $port.Error)
|
|
1169
|
-
}
|
|
1170
|
-
if ($Mode -eq 'watch') {
|
|
1171
|
-
Write-LaunchLog ('Supervisor detected {0} unavailable on {1}; restarting it.' -f $service.Name, $service.Port) 'WARN'
|
|
1172
|
-
}
|
|
1173
|
-
Start-CrewService $service
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
Wait-CrewServices
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function Start-ServiceSupervisor {
|
|
1180
|
-
$mutex = [System.Threading.Mutex]::new($false, 'Local\DSHCrewServiceSupervisor')
|
|
1181
|
-
$ownsMutex = $false
|
|
1182
|
-
try {
|
|
1183
|
-
try {
|
|
1184
|
-
$ownsMutex = $mutex.WaitOne(0)
|
|
1185
|
-
} catch [System.Threading.AbandonedMutexException] {
|
|
1186
|
-
$ownsMutex = $true
|
|
1187
|
-
}
|
|
1188
|
-
if (-not $ownsMutex) {
|
|
1189
|
-
Write-LaunchLog 'Supervisor already active; duplicate watcher exiting.'
|
|
1190
|
-
return
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
# Publish process identity before any 3210 startup/health wait. Handoff
|
|
1194
|
-
# callers can now distinguish a legitimate cold-starting target watcher
|
|
1195
|
-
# from the stale heartbeat left by the watcher it replaced.
|
|
1196
|
-
Write-SupervisorHeartbeat -OwnershipReady $false
|
|
1197
|
-
Write-LaunchLog 'Supervisor active; monitoring Crew-owned 3210 every 10 seconds.'
|
|
1198
|
-
$lastRecoveryError = $null
|
|
1199
|
-
$updateLockFile = Join-Path $crewHome '..\app\update-in-progress.lock'
|
|
1200
|
-
$staleLockNotified = $false
|
|
1201
|
-
while ($true) {
|
|
1202
|
-
try {
|
|
1203
|
-
# An installer-held update lock suspends recovery restarts: the tree
|
|
1204
|
-
# under Crew-owned state may be mid-migration, and a restart now
|
|
1205
|
-
# would boot a half-installed runtime/profile. A lock whose owner
|
|
1206
|
-
# process is dead is stale: log once and resume supervision instead
|
|
1207
|
-
# of staying observe-only forever.
|
|
1208
|
-
$updateHeld = $false
|
|
1209
|
-
if (Test-Path -LiteralPath $updateLockFile -PathType Leaf) {
|
|
1210
|
-
$lockAlive = $false
|
|
1211
|
-
try {
|
|
1212
|
-
$lockRecord = Get-Content -LiteralPath $updateLockFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
1213
|
-
$lockPid = [int] $lockRecord.pid
|
|
1214
|
-
if ($lockPid -gt 0) {
|
|
1215
|
-
Get-Process -Id $lockPid -ErrorAction Stop | Out-Null
|
|
1216
|
-
$lockAlive = $true
|
|
1217
|
-
}
|
|
1218
|
-
} catch { $lockAlive = $false }
|
|
1219
|
-
if ($lockAlive) {
|
|
1220
|
-
$updateHeld = $true
|
|
1221
|
-
$staleLockNotified = $false
|
|
1222
|
-
} elseif (-not $staleLockNotified) {
|
|
1223
|
-
Write-LaunchLog 'Stale update lock detected (owner process dead); resuming supervision. Re-run install/update to reconcile.' 'WARN'
|
|
1224
|
-
$staleLockNotified = $true
|
|
1225
|
-
}
|
|
1226
|
-
} else {
|
|
1227
|
-
$staleLockNotified = $false
|
|
1228
|
-
}
|
|
1229
|
-
# Maintenance stop/start is the updater's owned process handoff and
|
|
1230
|
-
# must remain live while the update lock fences ordinary recovery.
|
|
1231
|
-
Invoke-CrewMaintenanceRequests
|
|
1232
|
-
if ($updateHeld) {
|
|
1233
|
-
Write-LaunchLog 'Update in progress; maintenance only, ordinary recovery suspended.'
|
|
1234
|
-
$lastRecoveryError = $null
|
|
1235
|
-
} else {
|
|
1236
|
-
# Control protocol: publish this watcher's heartbeat and consume
|
|
1237
|
-
# any durable restart requests the hub wrote (3210 never spawns
|
|
1238
|
-
# itself). Maintenance was consumed above; now run the ordinary
|
|
1239
|
-
# health pass.
|
|
1240
|
-
Invoke-CrewRestartRequests
|
|
1241
|
-
Ensure-CrewServices -QuietHealthy
|
|
1242
|
-
if ($lastRecoveryError) {
|
|
1243
|
-
Write-LaunchLog 'Supervisor recovery succeeded; Crew-owned 3210 is healthy.'
|
|
1244
|
-
$lastRecoveryError = $null
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
$ownedServices = @($services | Where-Object { $_.CrewOwned -and (Test-CrewServiceOwnership -Service $_) })
|
|
1248
|
-
$expectedServices = @($services | Where-Object { $_.CrewOwned })
|
|
1249
|
-
Write-SupervisorHeartbeat -OwnershipReady ($expectedServices.Count -gt 0 -and $ownedServices.Count -eq $expectedServices.Count)
|
|
1250
|
-
} catch {
|
|
1251
|
-
$recoveryError = $_.Exception.Message
|
|
1252
|
-
Write-SupervisorHeartbeat -OwnershipReady $false
|
|
1253
|
-
if ($recoveryError -ne $lastRecoveryError) {
|
|
1254
|
-
Write-LaunchLog ('Supervisor recovery failed; will retry: {0}' -f $recoveryError) 'WARN'
|
|
1255
|
-
$lastRecoveryError = $recoveryError
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
Start-Sleep -Seconds 10
|
|
1259
|
-
}
|
|
1260
|
-
} finally {
|
|
1261
|
-
if ($ownsMutex) { $mutex.ReleaseMutex() }
|
|
1262
|
-
$mutex.Dispose()
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
|
|
1267
|
-
|
|
1268
|
-
try {
|
|
1269
|
-
New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
|
|
1270
|
-
Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
|
|
1271
|
-
if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
|
|
1272
|
-
throw "DSH CLI was not found at $dshCli. Configure DSH_CREW_DSH_CLI to a Crew-owned official CLI entry, or run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
|
|
1273
|
-
}
|
|
1274
|
-
Test-CrewDshCliPreflight
|
|
1275
|
-
if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
|
|
1276
|
-
throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
|
|
1277
|
-
}
|
|
1278
|
-
if ($Mode -eq 'open') { Open-OfficialFrontend }
|
|
1279
|
-
# Background/watch modes remain independent of the official frontend.
|
|
1280
|
-
|
|
1281
|
-
if ($Mode -eq 'watch') {
|
|
1282
|
-
Start-ServiceSupervisor
|
|
1283
|
-
Write-LaunchLog 'Supervisor stopped.' 'WARN'
|
|
1284
|
-
exit 0
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
Ensure-CrewSupervisorRunning
|
|
1288
|
-
|
|
1289
|
-
if ($Mode -eq 'open') {
|
|
1290
|
-
Write-LaunchLog 'Official frontend is on 3080; Crew is running silently on 3210.'
|
|
1291
|
-
}
|
|
1292
|
-
Write-LaunchLog ('Launcher completed successfully in {0:n1}s.' -f ((Get-Date) - $startedAt).TotalSeconds)
|
|
1293
|
-
exit 0
|
|
1294
|
-
} catch {
|
|
1295
|
-
Write-LaunchLog $_.Exception.Message 'ERROR'
|
|
1296
|
-
if ($Mode -eq 'open') {
|
|
1297
|
-
Write-Host ''
|
|
1298
|
-
Write-Host "Diagnostic log: $launcherLog" -ForegroundColor Yellow
|
|
1299
|
-
Write-Host 'Startup failed. Review the diagnostic log and the service stdout/stderr paths recorded above.' -ForegroundColor Yellow
|
|
1300
|
-
}
|
|
1301
|
-
exit 1
|
|
1302
|
-
}
|
|
1
|
+
# DSH Crew managed Windows launcher
|
|
2
|
+
[CmdletBinding()]
|
|
3
|
+
param(
|
|
4
|
+
[ValidateSet('background', 'open', 'watch')]
|
|
5
|
+
[string] $Mode = 'open'
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
Set-StrictMode -Version Latest
|
|
9
|
+
$ErrorActionPreference = 'Stop'
|
|
10
|
+
|
|
11
|
+
$crewHome = Join-Path $env:USERPROFILE '.config\dsh-crew\harness'
|
|
12
|
+
$configuredDshCli = [string] $env:DSH_CREW_DSH_CLI
|
|
13
|
+
if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { $configuredDshCli = '' }
|
|
14
|
+
if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
|
|
15
|
+
# Crew-managed npm runtime: the normal install path, written by dsh-crew
|
|
16
|
+
# update and carrying whatever cohort that release pinned. An explicit
|
|
17
|
+
# DSH_CREW_DSH_CLI always wins so an operator can still pin another entry.
|
|
18
|
+
$runtimeEntry = Join-Path $crewHome 'runtime\node_modules\@deepseek-ai\dsh\lib\bin.js'
|
|
19
|
+
if (Test-Path -LiteralPath $runtimeEntry -PathType Leaf) {
|
|
20
|
+
$configuredDshCli = $runtimeEntry
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
|
|
24
|
+
# Crew-managed source cohort: a runtime-source-<label>.json sidecar names the
|
|
25
|
+
# CLI entry. The sidecar's recorded version must equal the checkout's own
|
|
26
|
+
# manifest, so a stale sidecar can never pin a half-updated tree.
|
|
27
|
+
foreach ($sidecar in @(Get-ChildItem -LiteralPath $crewHome -Filter 'runtime-source-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
28
|
+
try {
|
|
29
|
+
$metadata = Get-Content -LiteralPath $sidecar.FullName -Raw | ConvertFrom-Json
|
|
30
|
+
if ($metadata.managed_by -ne 'dsh-crew') { continue }
|
|
31
|
+
$candidate = [string] $metadata.cli_entry
|
|
32
|
+
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
|
33
|
+
$sourceRoot = [IO.Path]::GetFullPath((Join-Path $crewHome ([IO.Path]::GetFileNameWithoutExtension($sidecar.Name))))
|
|
34
|
+
$candidateFull = [IO.Path]::GetFullPath($candidate)
|
|
35
|
+
$sourcePrefix = $sourceRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
36
|
+
$sourceManifest = Join-Path $sourceRoot 'apps\cli\package.json'
|
|
37
|
+
$sourceVersion = if (Test-Path -LiteralPath $sourceManifest -PathType Leaf) {
|
|
38
|
+
[string] (Get-Content -LiteralPath $sourceManifest -Raw | ConvertFrom-Json).version
|
|
39
|
+
} else { $null }
|
|
40
|
+
$candidateValid = $sourceVersion -and $sourceVersion -eq [string] $metadata.version `
|
|
41
|
+
-and $candidateFull.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase) `
|
|
42
|
+
-and (Test-Path -LiteralPath $candidateFull -PathType Leaf)
|
|
43
|
+
if ($candidateValid) {
|
|
44
|
+
$configuredDshCli = $candidateFull
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
} catch { }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
$dshCli = if ($configuredDshCli.Trim()) {
|
|
51
|
+
try { [IO.Path]::GetFullPath($configuredDshCli) } catch { $configuredDshCli }
|
|
52
|
+
} else {
|
|
53
|
+
Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
|
|
54
|
+
}
|
|
55
|
+
if ($configuredDshCli.Trim()) { $env:DSH_CREW_DSH_CLI = $dshCli }
|
|
56
|
+
# DSH_HOME that owns the selected CLI. The managed npm runtime shares the Crew
|
|
57
|
+
# home; a Crew-managed source cohort is its own home (its profiles, sessions and
|
|
58
|
+
# settings live inside that tree). Derived from the selected entry so an explicit
|
|
59
|
+
# DSH_CREW_DSH_CLI override resolves correctly too.
|
|
60
|
+
$dshHome = $crewHome
|
|
61
|
+
$sourceRootMatch = [regex]::Match($dshCli, '^(?<root>.+?[\\/]runtime-source-[^\\/]+)[\\/]')
|
|
62
|
+
if ($sourceRootMatch.Success) {
|
|
63
|
+
$candidateRoot = $sourceRootMatch.Groups['root'].Value
|
|
64
|
+
if (Test-Path -LiteralPath (Join-Path $candidateRoot 'profiles') -PathType Container) { $dshHome = $candidateRoot }
|
|
65
|
+
}
|
|
66
|
+
$dshCliIsNodeEntry = [IO.Path]::GetExtension($dshCli).ToLowerInvariant() -eq '.js'
|
|
67
|
+
$dshCommand = if ($dshCliIsNodeEntry) {
|
|
68
|
+
(Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
|
69
|
+
} else { $dshCli }
|
|
70
|
+
|
|
71
|
+
function Get-CrewDshManifest {
|
|
72
|
+
param([string] $Entry)
|
|
73
|
+
if (-not $Entry) { return $null }
|
|
74
|
+
try { $cursor = [IO.Path]::GetFullPath($Entry) } catch { return $null }
|
|
75
|
+
if ([IO.Path]::GetExtension($cursor).ToLowerInvariant() -eq '.js') {
|
|
76
|
+
$cursor = Split-Path -Parent $cursor
|
|
77
|
+
} else {
|
|
78
|
+
$cursor = Split-Path -Parent $cursor
|
|
79
|
+
}
|
|
80
|
+
for ($depth = 0; $depth -lt 12 -and $cursor; $depth += 1) {
|
|
81
|
+
$candidates = @(
|
|
82
|
+
(Join-Path $cursor 'package.json'),
|
|
83
|
+
(Join-Path $cursor 'node_modules\@deepseek-ai\dsh\package.json')
|
|
84
|
+
)
|
|
85
|
+
foreach ($candidate in $candidates) {
|
|
86
|
+
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
|
|
87
|
+
try {
|
|
88
|
+
$manifest = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json
|
|
89
|
+
$knownOfficial = -not $configuredDshCli.Trim() -or $manifest.name -eq '@deepseek-ai/dsh'
|
|
90
|
+
if ($knownOfficial -and [string] $manifest.version) { return $manifest }
|
|
91
|
+
} catch { }
|
|
92
|
+
}
|
|
93
|
+
$parent = Split-Path -Parent $cursor
|
|
94
|
+
if ($parent -eq $cursor) { break }
|
|
95
|
+
$cursor = $parent
|
|
96
|
+
}
|
|
97
|
+
return $null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$dshManifest = Get-CrewDshManifest -Entry $dshCli
|
|
101
|
+
$logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
|
|
102
|
+
$launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
|
|
103
|
+
$startedAt = Get-Date
|
|
104
|
+
$launcherProcessStartedAtUtcTicks = try { (Get-Process -Id $PID -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture) } catch { $null }
|
|
105
|
+
$supervisorInstanceId = [guid]::NewGuid().ToString()
|
|
106
|
+
$launcherHelperHash = $null
|
|
107
|
+
try {
|
|
108
|
+
$hashStream = [System.IO.File]::OpenRead($PSCommandPath)
|
|
109
|
+
try {
|
|
110
|
+
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
|
111
|
+
try { $launcherHelperHash = (($sha256.ComputeHash($hashStream) | ForEach-Object { $_.ToString('x2') }) -join '') }
|
|
112
|
+
finally { $sha256.Dispose() }
|
|
113
|
+
} finally { $hashStream.Dispose() }
|
|
114
|
+
} catch { $launcherHelperHash = $null }
|
|
115
|
+
$services = @(
|
|
116
|
+
[pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $dshHome; Port = 3210; Url = 'http://127.0.0.1:3210'; CrewOwned = $true; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
function Write-LaunchLog {
|
|
120
|
+
param([string] $Message, [ValidateSet('INFO', 'WARN', 'ERROR')] [string] $Level = 'INFO')
|
|
121
|
+
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffK'), $Level, $Message
|
|
122
|
+
Add-Content -LiteralPath $launcherLog -Value $line -Encoding UTF8
|
|
123
|
+
if ($Mode -eq 'open') {
|
|
124
|
+
if ($Level -eq 'ERROR') { Write-Host $line -ForegroundColor Red }
|
|
125
|
+
elseif ($Level -eq 'WARN') { Write-Host $line -ForegroundColor Yellow }
|
|
126
|
+
else { Write-Host $line }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function Resolve-OfficialHarnessCommand {
|
|
131
|
+
$shim = Get-Command dsh.cmd -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
|
132
|
+
$root = Join-Path (Split-Path -Parent $shim.Source) 'node_modules\@deepseek-ai\dsh'
|
|
133
|
+
$manifest = Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json
|
|
134
|
+
if ($manifest.name -ne '@deepseek-ai/dsh') { throw 'The dsh command is not a verified official Harness installation.' }
|
|
135
|
+
$entrySpec = [string] $manifest.bin.dsh
|
|
136
|
+
if (-not $entrySpec -or [IO.Path]::IsPathRooted($entrySpec) -or $entrySpec.Contains(':') -or ($entrySpec.Replace('\', '/').Split('/') -contains '..')) {
|
|
137
|
+
throw 'The official Harness CLI entry is invalid.'
|
|
138
|
+
}
|
|
139
|
+
$entry = [IO.Path]::GetFullPath((Join-Path $root $entrySpec))
|
|
140
|
+
$rootPrefix = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
141
|
+
if (-not $entry.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
|
|
142
|
+
throw 'The official Harness CLI entry was not found.'
|
|
143
|
+
}
|
|
144
|
+
$node = Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
|
145
|
+
return [pscustomobject]@{ NodePath = $node.Source; Entry = $entry }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function Test-OfficialHarnessListener {
|
|
149
|
+
param([int] $OwnerPid, [pscustomobject] $Official, [string] $Profile = 'web')
|
|
150
|
+
if ($OwnerPid -le 0) { return $false }
|
|
151
|
+
try {
|
|
152
|
+
$owner = Get-CimInstance Win32_Process -Filter ('ProcessId={0}' -f $OwnerPid) -ErrorAction Stop
|
|
153
|
+
if (-not $owner -or [IO.Path]::GetFileName([string] $owner.ExecutablePath) -ine 'node.exe') { return $false }
|
|
154
|
+
$line = ([string] $owner.CommandLine).Replace('/', '\')
|
|
155
|
+
$entry = [regex]::Escape($Official.Entry.Replace('/', '\'))
|
|
156
|
+
$node = [regex]::Escape($Official.NodePath.Replace('/', '\'))
|
|
157
|
+
$profileToken = [regex]::Escape($Profile)
|
|
158
|
+
$pattern = '^\s*(?:"?' + $node + '"?|"?node(?:\.exe)?"?)\s+"?' + $entry + '"?\s+(?:"?' + $profileToken + '"?|--profile\s+"?' + $profileToken + '"?)(?:\s|$)'
|
|
159
|
+
return $line -match $pattern
|
|
160
|
+
} catch { return $false }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Test-CrewDshCliPreflight {
|
|
164
|
+
if ($null -eq $dshManifest -or [string]::IsNullOrWhiteSpace([string] $dshManifest.version)) {
|
|
165
|
+
throw "The DSH CLI at $dshCli is not a verified @deepseek-ai/dsh package entry."
|
|
166
|
+
}
|
|
167
|
+
if ($dshCliIsNodeEntry -and -not (Test-Path -LiteralPath (Join-Path (Join-Path $dshHome 'profiles') 'web\package.json') -PathType Leaf)) {
|
|
168
|
+
throw ('The Harness web profile is missing under {0}. Run: dsh-crew update' -f $dshHome)
|
|
169
|
+
}
|
|
170
|
+
$versionArgs = if ($dshCliIsNodeEntry) { @($dshCli, '--version') } else { @('--version') }
|
|
171
|
+
$reported = try { (& $dshCommand @versionArgs 2>&1 | Out-String).Trim() } catch { '' }
|
|
172
|
+
if ($LASTEXITCODE -ne 0 -or $reported -ne [string] $dshManifest.version) {
|
|
173
|
+
throw ('DSH CLI preflight failed: manifest={0}, reported={1}' -f $dshManifest.version, ($reported -replace '\s+', ' '))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function Get-CrewWebSessionUrl {
|
|
178
|
+
param([string] $OutputLog = $null)
|
|
179
|
+
$logs = if ($OutputLog) {
|
|
180
|
+
@(Get-Item -LiteralPath $OutputLog -ErrorAction SilentlyContinue)
|
|
181
|
+
} else {
|
|
182
|
+
@(Get-ChildItem -LiteralPath $logRoot -Filter 'dsh-crew-web-*.out.log' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
|
|
183
|
+
}
|
|
184
|
+
foreach ($log in $logs) {
|
|
185
|
+
try {
|
|
186
|
+
$text = Get-Content -LiteralPath $log.FullName -Raw -ErrorAction Stop
|
|
187
|
+
$match = [regex]::Match($text, 'https?://127\.0\.0\.1:3080/\?token=[^\s\r\n]+')
|
|
188
|
+
if ($match.Success) { return $match.Value }
|
|
189
|
+
} catch { }
|
|
190
|
+
}
|
|
191
|
+
return $null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function Open-CrewBrowserUrl {
|
|
195
|
+
param([Parameter(Mandatory = $true)] [string] $Url)
|
|
196
|
+
$edge = Get-Command msedge.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
197
|
+
if (-not $edge) {
|
|
198
|
+
$edgeCandidates = @(
|
|
199
|
+
@(
|
|
200
|
+
(Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
|
|
201
|
+
(Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe'),
|
|
202
|
+
(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe')
|
|
203
|
+
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) }
|
|
204
|
+
)
|
|
205
|
+
if ($edgeCandidates.Count -gt 0) { $edge = [pscustomobject]@{ Source = $edgeCandidates[0] } }
|
|
206
|
+
}
|
|
207
|
+
if ($edge) {
|
|
208
|
+
Start-Process -FilePath $edge.Source -ArgumentList @('--new-window', $Url) | Out-Null
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
Start-Process $Url | Out-Null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function Test-CrewWebSessionUrl {
|
|
215
|
+
param([string] $Url)
|
|
216
|
+
if (-not $Url) { return $false }
|
|
217
|
+
try {
|
|
218
|
+
# The token endpoint intentionally answers with a 303 + Set-Cookie and
|
|
219
|
+
# redirects to `/`; do not follow it here or PowerShell loses the cookie
|
|
220
|
+
# and falsely reports the authenticated page as 404.
|
|
221
|
+
$response = Invoke-WebRequest -UseBasicParsing -MaximumRedirection 0 -Uri $Url -TimeoutSec 3
|
|
222
|
+
return [int] $response.StatusCode -ge 200 -and [int] $response.StatusCode -lt 400
|
|
223
|
+
} catch {
|
|
224
|
+
$status = if ($_.Exception.Response) { [int] $_.Exception.Response.StatusCode } else { 0 }
|
|
225
|
+
return $status -in @(301, 302, 303, 307, 308)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function Open-CrewManagedFrontend {
|
|
230
|
+
param([int] $TimeoutSeconds = 90)
|
|
231
|
+
# The 3080 frontend boots from the same Crew-managed Harness entry as 3210,
|
|
232
|
+
# against that entry's own DSH_HOME (npm runtime -> Crew home, source cohort
|
|
233
|
+
# -> its own tree). No official ~/.dsh state is read or written here.
|
|
234
|
+
$managedHome = $dshHome
|
|
235
|
+
$managedProfile = 'web'
|
|
236
|
+
$profileManifest = Join-Path (Join-Path $managedHome 'profiles') ('{0}\package.json' -f $managedProfile)
|
|
237
|
+
if (-not $dshCliIsNodeEntry -or -not (Test-Path -LiteralPath $profileManifest -PathType Leaf)) {
|
|
238
|
+
return $false
|
|
239
|
+
}
|
|
240
|
+
$official = [pscustomobject]@{ NodePath = $dshCommand; Entry = $dshCli; Profile = $managedProfile }
|
|
241
|
+
$frontend = Get-OfficialFrontendOverlay
|
|
242
|
+
$mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
|
|
243
|
+
$locked = $false
|
|
244
|
+
try {
|
|
245
|
+
try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
|
|
246
|
+
if (-not $locked) { throw 'Another desktop launch is still starting the Crew-managed Harness frontend.' }
|
|
247
|
+
$port = Get-PortState -Port 3080
|
|
248
|
+
if ($port.State -eq 'unknown') { throw $port.Error }
|
|
249
|
+
if ($port.State -eq 'occupied') {
|
|
250
|
+
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
|
|
251
|
+
throw 'Port 3080 is occupied by a non-Crew-managed process; it was left untouched.'
|
|
252
|
+
}
|
|
253
|
+
if (-not (Test-OfficialWebReady)) { throw 'The Crew-managed 3080 frontend is not ready; its process was left running.' }
|
|
254
|
+
$existingUrl = Get-CrewWebSessionUrl
|
|
255
|
+
if ($existingUrl -and (Test-CrewWebSessionUrl -Url $existingUrl)) {
|
|
256
|
+
Open-CrewBrowserUrl -Url $existingUrl
|
|
257
|
+
Write-LaunchLog 'Opened the existing Crew-managed Harness frontend on 3080 with its session URL.'
|
|
258
|
+
return $true
|
|
259
|
+
}
|
|
260
|
+
Write-LaunchLog 'The existing Crew-managed 3080 session URL is stale; restarting the verified listener.' 'WARN'
|
|
261
|
+
Stop-Process -Id $port.Pid -Force -ErrorAction Stop
|
|
262
|
+
$freeDeadline = (Get-Date).AddSeconds(5)
|
|
263
|
+
do { Start-Sleep -Milliseconds 250; $port = Get-PortState -Port 3080 } while ($port.State -ne 'free' -and (Get-Date) -lt $freeDeadline)
|
|
264
|
+
if ($port.State -ne 'free') { throw 'The stale Crew-managed 3080 listener did not release the port.' }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
$stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
|
|
268
|
+
$stdout = Join-Path $logRoot ('dsh-crew-web-{0}.out.log' -f $stamp)
|
|
269
|
+
$stderr = Join-Path $logRoot ('dsh-crew-web-{0}.err.log' -f $stamp)
|
|
270
|
+
$previousHome = $env:DSH_HOME
|
|
271
|
+
$previousCli = $env:DSH_CREW_DSH_CLI
|
|
272
|
+
try {
|
|
273
|
+
$env:DSH_HOME = $managedHome
|
|
274
|
+
$env:DSH_CREW_DSH_CLI = $dshCli
|
|
275
|
+
$arguments = @($dshCli, '--profile', $managedProfile, '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
|
|
276
|
+
$process = Start-Process -FilePath $dshCommand -ArgumentList $arguments -WindowStyle Hidden -PassThru `
|
|
277
|
+
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
278
|
+
} finally {
|
|
279
|
+
$env:DSH_HOME = $previousHome
|
|
280
|
+
$env:DSH_CREW_DSH_CLI = $previousCli
|
|
281
|
+
}
|
|
282
|
+
Write-LaunchLog ('Started Crew-managed Harness on 3080; PID={0}. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
|
|
283
|
+
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
284
|
+
do {
|
|
285
|
+
$port = Get-PortState -Port 3080
|
|
286
|
+
if ($port.State -eq 'occupied') {
|
|
287
|
+
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
|
|
288
|
+
throw 'Port 3080 became occupied by an unverified process; it was left untouched.'
|
|
289
|
+
}
|
|
290
|
+
if (Test-OfficialWebReady) {
|
|
291
|
+
$startedUrl = Get-CrewWebSessionUrl -OutputLog $stdout
|
|
292
|
+
if ($startedUrl) { Open-CrewBrowserUrl -Url $startedUrl; return $true }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if ($process.HasExited) { throw ('Crew-managed Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
|
|
296
|
+
Start-Sleep -Milliseconds 500
|
|
297
|
+
} while ((Get-Date) -lt $deadline)
|
|
298
|
+
throw ('The Crew-managed Harness did not become ready within {0}s; inspect {1}.' -f $TimeoutSeconds, $stderr)
|
|
299
|
+
} finally {
|
|
300
|
+
if ($locked) { $mutex.ReleaseMutex() }
|
|
301
|
+
$mutex.Dispose()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function Test-OfficialWebReady {
|
|
306
|
+
try {
|
|
307
|
+
$response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/' -TimeoutSec 2
|
|
308
|
+
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300
|
|
309
|
+
} catch {
|
|
310
|
+
# An authenticated official UI may reject this cookie-free health request.
|
|
311
|
+
if ($_.Exception.Response) { return [int] $_.Exception.Response.StatusCode -in @(401, 403) }
|
|
312
|
+
return $false
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function Get-OfficialFrontendOverlay {
|
|
317
|
+
$frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
|
|
318
|
+
$path = Join-Path $frontendRoot 'official-web.patch.json'
|
|
319
|
+
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'Crew frontend overlay is missing. Run dsh-crew update with the current GitHub candidate.' }
|
|
320
|
+
# Windows PowerShell 5.1 keeps a JSON root array as one pipeline object;
|
|
321
|
+
# direct `$patch[0].insert[0]` therefore resolves `insert` as IList.Insert
|
|
322
|
+
# instead of indexing the JSON property. Flatten both PowerShell generations
|
|
323
|
+
# explicitly and read properties through PSObject so StrictMode reports a
|
|
324
|
+
# useful validation error instead of "property id cannot be found".
|
|
325
|
+
$parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
|
|
326
|
+
$patch = @($parsed | ForEach-Object { $_ })
|
|
327
|
+
$root = if ($patch.Count -eq 1) { $patch[0] } else { $null }
|
|
328
|
+
$insertProperty = if ($null -ne $root) { $root.PSObject.Properties['insert'] } else { $null }
|
|
329
|
+
$insertValue = if ($null -ne $insertProperty) { $insertProperty.Value } else { $null }
|
|
330
|
+
$inserts = @($insertValue | ForEach-Object { $_ })
|
|
331
|
+
$bridge = if ($inserts.Count -eq 1) { $inserts[0] } else { $null }
|
|
332
|
+
$idProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['id'] } else { $null }
|
|
333
|
+
$nameProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['name'] } else { $null }
|
|
334
|
+
if ($patch.Count -ne 1 -or $inserts.Count -ne 1 -or $null -eq $idProperty -or [string] $idProperty.Value -ne 'dsh-crew-official-web-bridge' -or $null -eq $nameProperty) {
|
|
335
|
+
throw 'Crew frontend overlay has an unexpected structure.'
|
|
336
|
+
}
|
|
337
|
+
$uri = [Uri] ([string] $nameProperty.Value)
|
|
338
|
+
if (-not $uri.IsFile) { throw 'Crew frontend must load from its local snapshot.' }
|
|
339
|
+
$entry = [IO.Path]::GetFullPath($uri.LocalPath)
|
|
340
|
+
$prefix = [IO.Path]::GetFullPath((Join-Path $frontendRoot 'revisions')) + [IO.Path]::DirectorySeparatorChar
|
|
341
|
+
if (-not $entry.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Crew frontend snapshot is outside its owned directory.' }
|
|
342
|
+
$metadata = Get-Content -LiteralPath (Join-Path (Split-Path -Parent $entry) 'package.json') -Raw | ConvertFrom-Json
|
|
343
|
+
$revision = [string] $metadata.dshCrewFrontendRevision
|
|
344
|
+
if ($metadata.dshCrewManagedFrontend -ne $true -or $revision -notmatch '^[a-f0-9]{64}$' -or -not (Test-Path -LiteralPath $entry -PathType Leaf)) {
|
|
345
|
+
throw 'Crew frontend snapshot is incomplete.'
|
|
346
|
+
}
|
|
347
|
+
return [pscustomobject]@{ Path = $path; Revision = $revision }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function Test-OfficialFrontendAttached {
|
|
351
|
+
param([string] $Revision)
|
|
352
|
+
try {
|
|
353
|
+
$response = Invoke-RestMethod -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/bridge-status' -TimeoutSec 2
|
|
354
|
+
return $response.ok -eq $true -and $response.surface -eq 'official-bridge' -and $response.frontend_revision -eq $Revision
|
|
355
|
+
} catch { return $false }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function Open-OfficialFrontend {
|
|
359
|
+
param([int] $TimeoutSeconds = 90)
|
|
360
|
+
# Preferred path: the Crew-managed Harness serves 3080 with the Crew panel
|
|
361
|
+
# already loaded. The legacy fallback below (separate official install in
|
|
362
|
+
# ~/.dsh) is only reached when no Crew-managed frontend is available.
|
|
363
|
+
if (Open-CrewManagedFrontend -TimeoutSeconds $TimeoutSeconds) { return }
|
|
364
|
+
$official = Resolve-OfficialHarnessCommand
|
|
365
|
+
$frontend = Get-OfficialFrontendOverlay
|
|
366
|
+
$mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
|
|
367
|
+
$locked = $false
|
|
368
|
+
try {
|
|
369
|
+
try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
|
|
370
|
+
if (-not $locked) { throw 'Another desktop launch is still starting the official frontend.' }
|
|
371
|
+
$port = Get-PortState -Port 3080
|
|
372
|
+
if ($port.State -eq 'unknown') { throw $port.Error }
|
|
373
|
+
if ($port.State -eq 'occupied') {
|
|
374
|
+
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 is occupied by an unverified process; it was left untouched.' }
|
|
375
|
+
if (-not (Test-OfficialWebReady)) { throw 'The official 3080 frontend is not ready; its process was left running.' }
|
|
376
|
+
if (-not (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { throw 'The running official frontend has not loaded the current Crew panel. Stop that official instance when idle, then launch from the desktop again. It was left untouched.' }
|
|
377
|
+
Start-Process 'http://127.0.0.1:3080/' | Out-Null
|
|
378
|
+
Write-LaunchLog 'Opened the existing official Harness frontend on 3080.'
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
$stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
|
|
383
|
+
$stdout = Join-Path $logRoot ('dsh-official-web-{0}.out.log' -f $stamp)
|
|
384
|
+
$stderr = Join-Path $logRoot ('dsh-official-web-{0}.err.log' -f $stamp)
|
|
385
|
+
$previousHome = $env:DSH_HOME
|
|
386
|
+
try {
|
|
387
|
+
# The official application owns its normal state; no Crew plugin/profile
|
|
388
|
+
# registration or repair is performed in this home by the launcher.
|
|
389
|
+
$env:DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
|
|
390
|
+
$arguments = @(('"{0}"' -f $official.Entry), 'web', '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
|
|
391
|
+
$process = Start-Process -FilePath $official.NodePath -ArgumentList $arguments -WindowStyle Hidden -PassThru `
|
|
392
|
+
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
393
|
+
} finally { $env:DSH_HOME = $previousHome }
|
|
394
|
+
Write-LaunchLog ('Started official Harness on 3080; PID={0}. The official CLI opens the browser. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
|
|
395
|
+
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
396
|
+
$nextProgress = (Get-Date).AddSeconds(5)
|
|
397
|
+
do {
|
|
398
|
+
$port = Get-PortState -Port 3080
|
|
399
|
+
if ($port.State -eq 'occupied') {
|
|
400
|
+
if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official)) { throw 'Port 3080 became occupied by an unverified process; it was left untouched.' }
|
|
401
|
+
if ((Test-OfficialWebReady) -and (Test-OfficialFrontendAttached -Revision $frontend.Revision)) { return }
|
|
402
|
+
}
|
|
403
|
+
if ($process.HasExited) { throw ('Official Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
|
|
404
|
+
if ((Get-Date) -ge $nextProgress) {
|
|
405
|
+
Write-LaunchLog 'Waiting for the official Harness frontend on 3080...'
|
|
406
|
+
$nextProgress = (Get-Date).AddSeconds(5)
|
|
407
|
+
}
|
|
408
|
+
Start-Sleep -Milliseconds 500
|
|
409
|
+
} while ((Get-Date) -lt $deadline)
|
|
410
|
+
throw ('Official Harness did not become ready within {0}s; inspect {1}. No official process was stopped.' -f $TimeoutSeconds, $stderr)
|
|
411
|
+
} finally {
|
|
412
|
+
if ($locked) { $mutex.ReleaseMutex() }
|
|
413
|
+
$mutex.Dispose()
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function Get-HealthState {
|
|
418
|
+
param([pscustomObject] $Service)
|
|
419
|
+
# Preserve the installed extension health contract used by maintenance.
|
|
420
|
+
if ($Service.CrewOwned) {
|
|
421
|
+
try {
|
|
422
|
+
$response = Invoke-RestMethod -Uri ($Service.Url + '/_dsh/dsh-crew/extension') -TimeoutSec 2
|
|
423
|
+
$runtime = $response.extension.runtime
|
|
424
|
+
$version = $runtime.runtime_version
|
|
425
|
+
# A stale 3210 process (booted before the runtime tree was swapped) can
|
|
426
|
+
# keep serving from memory while the disk tree is a different cohort.
|
|
427
|
+
# Require the reported dsh_version to equal the installed disk
|
|
428
|
+
# @deepseek-ai/dsh version so supervisor never treats a
|
|
429
|
+
# disk-rc.1/memory-alpha.5 (or vice versa) process as healthy.
|
|
430
|
+
# FAIL CLOSED: an unreadable/missing disk manifest is NOT healthy —
|
|
431
|
+
# the process cannot be proven to match the tree it will next boot.
|
|
432
|
+
$expectedDshVersion = $null
|
|
433
|
+
$diskReadable = $false
|
|
434
|
+
$activeDshManifest = Get-CrewDshManifest -Entry $dshCli
|
|
435
|
+
if ($null -ne $activeDshManifest -and [string] $activeDshManifest.version) {
|
|
436
|
+
$expectedDshVersion = [string] $activeDshManifest.version
|
|
437
|
+
$diskReadable = $true
|
|
438
|
+
}
|
|
439
|
+
$cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
|
|
440
|
+
if ($response.ok -eq $true -and $version -and $cohortMatches) {
|
|
441
|
+
return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
|
|
442
|
+
}
|
|
443
|
+
if (-not $diskReadable) {
|
|
444
|
+
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
|
|
445
|
+
}
|
|
446
|
+
if ($version -and -not $cohortMatches) {
|
|
447
|
+
return [pscustomobject]@{ Ready = $false; Version = [string] $version; Error = ('runtime cohort mismatch: hub reports dsh_version={0} but disk runtime is {1}' -f $runtime.dsh_version, $expectedDshVersion) }
|
|
448
|
+
}
|
|
449
|
+
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Response did not contain a ready runtime contract.' }
|
|
450
|
+
} catch {
|
|
451
|
+
return [pscustomobject]@{ Ready = $false; Version = $null; Error = $_.Exception.Message }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
# The launcher supervises only the Crew-owned 3210 service. Any non-Crew
|
|
455
|
+
# service is never started or health-gated here; the legacy 3080 bridge
|
|
456
|
+
# is probed only diagnostically after 3210 readiness (see Write-LegacyBridgeDiagnostic).
|
|
457
|
+
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'Unknown service: only the Crew-owned 3210 service is supervised.' }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
# Optional legacy compatibility probe: returns true only when a 3080 that
|
|
461
|
+
# still hosts the Crew bridge answers its ping endpoint. The Crew launcher
|
|
462
|
+
# never depends on it; 3210 always boots directly first.
|
|
463
|
+
function Test-LegacyBridgeAvailable {
|
|
464
|
+
try {
|
|
465
|
+
$response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/ping' -TimeoutSec 3
|
|
466
|
+
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
|
|
467
|
+
} catch {
|
|
468
|
+
return $false
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
# ---- Crew supervisor control protocol (heartbeat + restart requests) --------
|
|
473
|
+
# The hub (3210) never spawns itself. It writes a durable restart request;
|
|
474
|
+
# this watcher is the only process authority and executes it after proving
|
|
475
|
+
# ownership (persisted identity + live runtime_id match).
|
|
476
|
+
|
|
477
|
+
$crewSupervisorRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\supervisor'
|
|
478
|
+
$crewHeartbeatFile = Join-Path $crewSupervisorRoot 'heartbeat.json'
|
|
479
|
+
$crewRequestsDir = Join-Path $crewSupervisorRoot 'restart-requests'
|
|
480
|
+
$crewResultsDir = Join-Path $crewSupervisorRoot 'restart-results'
|
|
481
|
+
|
|
482
|
+
# UTF-8 WITHOUT BOM: Windows PowerShell 5.1's Set-Content -Encoding UTF8
|
|
483
|
+
# writes a BOM, and the Node hub's JSON.parse rejects it (heartbeat/request
|
|
484
|
+
# files would be unreadable -> supervisor judged unavailable).
|
|
485
|
+
function Write-Utf8NoBom {
|
|
486
|
+
param([string] $Path, [string] $Content)
|
|
487
|
+
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
488
|
+
[System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function Write-SupervisorHeartbeat {
|
|
492
|
+
param([bool] $OwnershipReady = $false)
|
|
493
|
+
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
494
|
+
$record = @{ schema_version = 1; supervisor_instance_id = $supervisorInstanceId; pid = $PID; process_started_at_utc_ticks = $launcherProcessStartedAtUtcTicks; helper_hash = $launcherHelperHash; ownership_ready = $OwnershipReady; last_seen = $now; protocol_version = 1 } | ConvertTo-Json -Compress
|
|
495
|
+
try {
|
|
496
|
+
if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
|
|
497
|
+
$temp = Join-Path $crewSupervisorRoot ("heartbeat.{0}.tmp" -f $PID)
|
|
498
|
+
Write-Utf8NoBom -Path $temp -Content $record
|
|
499
|
+
Move-Item -LiteralPath $temp -Destination $crewHeartbeatFile -Force
|
|
500
|
+
} catch { /* heartbeat is best-effort */ }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function Get-SupervisorLaunchArguments {
|
|
504
|
+
param([string] $ScriptPath = $PSCommandPath)
|
|
505
|
+
if ([string]::IsNullOrWhiteSpace($ScriptPath) -or $ScriptPath.Contains('"')) {
|
|
506
|
+
throw 'A valid launcher script path is required.'
|
|
507
|
+
}
|
|
508
|
+
return @(
|
|
509
|
+
'-NoLogo',
|
|
510
|
+
'-NoProfile',
|
|
511
|
+
'-NonInteractive',
|
|
512
|
+
'-ExecutionPolicy', 'Bypass',
|
|
513
|
+
'-File', ('"{0}"' -f $ScriptPath),
|
|
514
|
+
'-Mode', 'watch'
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function Get-SupervisorHeartbeatRecord {
|
|
519
|
+
param([int] $MaxAgeSeconds = 30)
|
|
520
|
+
try {
|
|
521
|
+
if (-not (Test-Path -LiteralPath $crewHeartbeatFile -PathType Leaf)) { return $null }
|
|
522
|
+
$record = Get-Content -LiteralPath $crewHeartbeatFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
523
|
+
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
524
|
+
$lastSeen = [long] $record.last_seen
|
|
525
|
+
$age = $now - $lastSeen
|
|
526
|
+
if ($record.schema_version -ne 1 -or $record.protocol_version -ne 1 -or [int] $record.pid -le 0) { return $null }
|
|
527
|
+
if ($age -lt -5000 -or $age -gt ([long] $MaxAgeSeconds * 1000)) { return $null }
|
|
528
|
+
$process = Get-Process -Id ([int] $record.pid) -ErrorAction Stop
|
|
529
|
+
$processStartProperty = $record.PSObject.Properties['process_started_at_utc_ticks']
|
|
530
|
+
if ($null -ne $processStartProperty -and $processStartProperty.Value) {
|
|
531
|
+
$expectedTicks = [long] $processStartProperty.Value
|
|
532
|
+
if ($process.StartTime.ToUniversalTime().Ticks -ne $expectedTicks) { return $null }
|
|
533
|
+
}
|
|
534
|
+
$ownershipProperty = $record.PSObject.Properties['ownership_ready']
|
|
535
|
+
$helperHashProperty = $record.PSObject.Properties['helper_hash']
|
|
536
|
+
$instanceProperty = $record.PSObject.Properties['supervisor_instance_id']
|
|
537
|
+
$identityReady = $null -ne $processStartProperty -and $processStartProperty.Value `
|
|
538
|
+
-and $null -ne $helperHashProperty -and $helperHashProperty.Value -match '^[a-f0-9]{64}$' `
|
|
539
|
+
-and $null -ne $instanceProperty -and -not [string]::IsNullOrWhiteSpace([string] $instanceProperty.Value)
|
|
540
|
+
$state = if ($null -eq $ownershipProperty) { 'legacy-v1' } elseif ($ownershipProperty.Value -eq $true -and $identityReady) { 'ready' } else { 'starting' }
|
|
541
|
+
return [pscustomobject]@{ State = $state; Record = $record }
|
|
542
|
+
} catch {
|
|
543
|
+
return $null
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function Get-FreshSupervisorHeartbeat {
|
|
548
|
+
param([int] $MaxAgeSeconds = 30)
|
|
549
|
+
$observed = Get-SupervisorHeartbeatRecord -MaxAgeSeconds $MaxAgeSeconds
|
|
550
|
+
if ($observed -and $observed.State -eq 'ready') { return $observed.Record }
|
|
551
|
+
return $null
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function Ensure-CrewSupervisorRunning {
|
|
555
|
+
param([int] $TimeoutSeconds = 90)
|
|
556
|
+
$watcher = $null
|
|
557
|
+
$observed = Get-SupervisorHeartbeatRecord
|
|
558
|
+
if ($observed -and $observed.State -eq 'legacy-v1') {
|
|
559
|
+
throw 'CREW_SUPERVISOR_UPGRADE_REQUIRED: a legacy watcher is active and must be handed off before interactive launch.'
|
|
560
|
+
}
|
|
561
|
+
if (-not $observed) {
|
|
562
|
+
$arguments = @(Get-SupervisorLaunchArguments -ScriptPath $PSCommandPath)
|
|
563
|
+
$watcher = Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WindowStyle Hidden -PassThru
|
|
564
|
+
Write-LaunchLog ('Started persistent Crew supervisor; PID={0}.' -f $watcher.Id)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
$crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
|
|
568
|
+
if (-not $crew) { throw 'Crew-owned 3210 service definition is missing.' }
|
|
569
|
+
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
570
|
+
$nextProgress = (Get-Date).AddSeconds(5)
|
|
571
|
+
$lastHealthError = 'not checked'
|
|
572
|
+
do {
|
|
573
|
+
$heartbeat = Get-FreshSupervisorHeartbeat
|
|
574
|
+
$health = Get-HealthState $crew
|
|
575
|
+
$lastHealthError = $health.Error
|
|
576
|
+
if ($heartbeat -and $health.Ready) {
|
|
577
|
+
Write-LaunchLog ('Persistent supervisor ready; PID={0}; 3210 runtime={1}.' -f $heartbeat.pid, $health.Version)
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
if ((Get-Date) -ge $nextProgress) {
|
|
581
|
+
Write-LaunchLog ('Waiting for Crew on 3210; {0:n0}s remaining. Health: {1}' -f [Math]::Max(0, ($deadline - (Get-Date)).TotalSeconds), $lastHealthError)
|
|
582
|
+
$nextProgress = (Get-Date).AddSeconds(5)
|
|
583
|
+
}
|
|
584
|
+
Start-Sleep -Milliseconds 500
|
|
585
|
+
} while ((Get-Date) -lt $deadline)
|
|
586
|
+
|
|
587
|
+
throw ('Persistent supervisor did not make 3210 ready within {0}s. Last health error: {1}' -f $TimeoutSeconds, $lastHealthError)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function Read-RestartRequests {
|
|
591
|
+
if (-not (Test-Path -LiteralPath $crewRequestsDir -PathType Container)) { return @() }
|
|
592
|
+
$requests = @()
|
|
593
|
+
Get-ChildItem -LiteralPath $crewRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue | ForEach-Object {
|
|
594
|
+
try {
|
|
595
|
+
$parsed = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
|
|
596
|
+
if ($parsed.schema_version -eq 1 -and $parsed.operation -eq 'restart' -and $parsed.request_id) {
|
|
597
|
+
$requests += $parsed
|
|
598
|
+
}
|
|
599
|
+
} catch { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
|
|
600
|
+
}
|
|
601
|
+
return $requests
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function Write-RestartResult {
|
|
605
|
+
param([object] $Request, [string] $State, [object] $Detail = $null)
|
|
606
|
+
try {
|
|
607
|
+
if (-not (Test-Path -LiteralPath $crewResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewResultsDir -Force | Out-Null }
|
|
608
|
+
$result = @{
|
|
609
|
+
schema_version = 1
|
|
610
|
+
request_id = $Request.request_id
|
|
611
|
+
operation = 'restart'
|
|
612
|
+
state = $State
|
|
613
|
+
runtime_id = $Request.runtime_id
|
|
614
|
+
written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
615
|
+
detail = $Detail
|
|
616
|
+
} | ConvertTo-Json -Depth 5
|
|
617
|
+
$file = Join-Path $crewResultsDir ("{0}.json" -f $Request.request_id)
|
|
618
|
+
$temp = Join-Path $crewResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
|
|
619
|
+
Write-Utf8NoBom -Path $temp -Content $result
|
|
620
|
+
Move-Item -LiteralPath $temp -Destination $file -Force
|
|
621
|
+
} catch { /* best effort */ }
|
|
622
|
+
Remove-Item -LiteralPath (Join-Path $crewRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function Invoke-CrewRestartRequests {
|
|
626
|
+
# Consume durable restart requests from the hub. Called by the watch loop.
|
|
627
|
+
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
628
|
+
foreach ($request in (Read-RestartRequests)) {
|
|
629
|
+
if (($request.expires_at -as [long]) -lt $now) {
|
|
630
|
+
Write-RestartResult $request 'RESTART_REQUEST_EXPIRED'
|
|
631
|
+
Write-LaunchLog ('Restart request {0} expired; not executed.' -f $request.request_id) 'WARN'
|
|
632
|
+
continue
|
|
633
|
+
}
|
|
634
|
+
# Ownership authority: the request must name the SAME runtime identity we
|
|
635
|
+
# own, and that identity must still be live on 3210.
|
|
636
|
+
$crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
|
|
637
|
+
if (-not $crew) { continue }
|
|
638
|
+
$health = Get-HealthState $crew
|
|
639
|
+
$liveIdentityOk = $false
|
|
640
|
+
try {
|
|
641
|
+
$response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
|
|
642
|
+
$liveRuntimeId = $response.extension.runtime.runtime_id
|
|
643
|
+
$liveIdentityOk = ($response.ok -eq $true) -and ($liveRuntimeId -eq $request.runtime_id)
|
|
644
|
+
} catch { $liveIdentityOk = $false }
|
|
645
|
+
if (-not $liveIdentityOk) {
|
|
646
|
+
Write-RestartResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
647
|
+
Write-LaunchLog ('Restart request {0} rejected: live runtime_id does not match the request.' -f $request.request_id) 'WARN'
|
|
648
|
+
continue
|
|
649
|
+
}
|
|
650
|
+
Write-LaunchLog ('Executing restart request {0} (reason: {1}).' -f $request.request_id, $request.reason)
|
|
651
|
+
$previousRuntimeId = $request.runtime_id
|
|
652
|
+
# Re-read the listener port right before the kill so the PID identity is
|
|
653
|
+
# fresh: Stop-OwnedListener requires a PID that equals the tracked
|
|
654
|
+
# listener and belongs to the current owned process tree.
|
|
655
|
+
$port = Get-PortState $crew.Port
|
|
656
|
+
if ($port.State -ne 'occupied' -or -not $port.Pid) {
|
|
657
|
+
Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
|
|
658
|
+
Write-LaunchLog ('Restart request {0} failed: no listener on port {1}.' -f $request.request_id, $crew.Port) 'ERROR'
|
|
659
|
+
continue
|
|
660
|
+
}
|
|
661
|
+
$stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
|
|
662
|
+
if (-not $stopped) {
|
|
663
|
+
Write-RestartResult $request 'SUPERVISOR_STOP_FAILED'
|
|
664
|
+
Write-LaunchLog ('Restart request {0} failed to stop the owned runtime.' -f $request.request_id) 'ERROR'
|
|
665
|
+
continue
|
|
666
|
+
}
|
|
667
|
+
Start-CrewService $crew
|
|
668
|
+
Wait-CrewServices
|
|
669
|
+
# Verify: runtime_id must have changed and the cohort must still match.
|
|
670
|
+
$newRuntimeId = $null
|
|
671
|
+
try {
|
|
672
|
+
$response = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
|
|
673
|
+
$newRuntimeId = $response.extension.runtime.runtime_id
|
|
674
|
+
} catch { $newRuntimeId = $null }
|
|
675
|
+
if ($newRuntimeId -and $newRuntimeId -ne $previousRuntimeId) {
|
|
676
|
+
Write-RestartResult $request 'VERIFIED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
|
|
677
|
+
Write-LaunchLog ('Restart request {0} verified: runtime_id {1} -> {2}.' -f $request.request_id, $previousRuntimeId, $newRuntimeId)
|
|
678
|
+
} else {
|
|
679
|
+
Write-RestartResult $request 'VERIFY_FAILED' @{ previous_runtime_id = $previousRuntimeId; runtime_id = $newRuntimeId }
|
|
680
|
+
Write-LaunchLog ('Restart request {0} verification failed: runtime_id did not change.' -f $request.request_id) 'ERROR'
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
$crewMaintenanceRequestsDir = Join-Path $crewSupervisorRoot 'maintenance-requests'
|
|
686
|
+
$crewMaintenanceResultsDir = Join-Path $crewSupervisorRoot 'maintenance-results'
|
|
687
|
+
$crewMaintenanceSessionFile = Join-Path $crewSupervisorRoot 'maintenance-session.json'
|
|
688
|
+
|
|
689
|
+
function Test-MaintenanceSessionActive {
|
|
690
|
+
# Fence for ordinary Crew auto-start. Returns $true when a STOPPED session
|
|
691
|
+
# holds the launch right OR when the session file is present but MALFORMED
|
|
692
|
+
# (fail closed: a corrupt session must not let supervision auto-start
|
|
693
|
+
# 3210, because the stopped window may belong to a live npx swap).
|
|
694
|
+
# ABSENT means no fence: ordinary supervision proceeds.
|
|
695
|
+
$session = Read-MaintenanceSession
|
|
696
|
+
if ($session -eq 'ABSENT') { return $false }
|
|
697
|
+
return $true
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function Set-MaintenanceSession {
|
|
701
|
+
param([object] $Request)
|
|
702
|
+
# Strong-failure semantics: the STOPPED result may only be published after
|
|
703
|
+
# the session is durably written AND read back with exact identity. Any
|
|
704
|
+
# failure returns $false and the caller must NOT claim the stopped window.
|
|
705
|
+
try {
|
|
706
|
+
$session = @{
|
|
707
|
+
schema_version = 1
|
|
708
|
+
state = 'STOPPED'
|
|
709
|
+
lease = $Request.lease
|
|
710
|
+
runtime_id = $Request.runtime_id
|
|
711
|
+
stopped_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
712
|
+
request_id = $Request.request_id
|
|
713
|
+
} | ConvertTo-Json -Compress
|
|
714
|
+
$temp = Join-Path $crewSupervisorRoot ("maintenance-session.{0}.tmp" -f $PID)
|
|
715
|
+
Write-Utf8NoBom -Path $temp -Content $session
|
|
716
|
+
Move-Item -LiteralPath $temp -Destination $crewMaintenanceSessionFile -Force
|
|
717
|
+
$back = Read-MaintenanceSession
|
|
718
|
+
if ($back -is [string]) { return $false }
|
|
719
|
+
return ($back.schema_version -eq 1 -and $back.state -eq 'STOPPED' -and $back.lease -eq $Request.lease -and $back.runtime_id -eq $Request.runtime_id -and $back.request_id -eq $Request.request_id)
|
|
720
|
+
} catch { return $false }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function Clear-MaintenanceSession {
|
|
724
|
+
# Verifiable one-shot consumption: returns $true only when the session
|
|
725
|
+
# file is provably ABSENT afterwards (otherwise the lease is NOT consumed
|
|
726
|
+
# and replay stays impossible because start re-validates the session).
|
|
727
|
+
try {
|
|
728
|
+
Remove-Item -LiteralPath $crewMaintenanceSessionFile -Force -ErrorAction Stop
|
|
729
|
+
} catch {
|
|
730
|
+
if (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf) { return $false }
|
|
731
|
+
return $true
|
|
732
|
+
}
|
|
733
|
+
return (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf))
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function Read-MaintenanceSession {
|
|
737
|
+
# Tri-state: 'ABSENT' (no file), a session object when valid, or the
|
|
738
|
+
# string 'MALFORMED' (present but unreadable/invalid). MALFORMED fails
|
|
739
|
+
# closed for Crew auto-start (see Test-MaintenanceSessionActive).
|
|
740
|
+
try {
|
|
741
|
+
if (-not (Test-Path -LiteralPath $crewMaintenanceSessionFile -PathType Leaf)) { return 'ABSENT' }
|
|
742
|
+
$parsed = Get-Content -LiteralPath $crewMaintenanceSessionFile -Raw | ConvertFrom-Json -ErrorAction Stop
|
|
743
|
+
if ($parsed.schema_version -ne 1 -or $parsed.state -ne 'STOPPED' -or -not $parsed.lease -or -not $parsed.runtime_id -or -not $parsed.request_id) {
|
|
744
|
+
return 'MALFORMED'
|
|
745
|
+
}
|
|
746
|
+
return $parsed
|
|
747
|
+
} catch { return 'MALFORMED' }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function Write-MaintenanceResult {
|
|
751
|
+
param([object] $Request, [string] $State, [object] $Detail = $null)
|
|
752
|
+
try {
|
|
753
|
+
if (-not (Test-Path -LiteralPath $crewMaintenanceResultsDir -PathType Container)) { New-Item -ItemType Directory -Path $crewMaintenanceResultsDir -Force | Out-Null }
|
|
754
|
+
$result = @{
|
|
755
|
+
schema_version = 1
|
|
756
|
+
request_id = $Request.request_id
|
|
757
|
+
operation = $Request.operation
|
|
758
|
+
state = $State
|
|
759
|
+
lease = $Request.lease
|
|
760
|
+
runtime_id = $Request.runtime_id
|
|
761
|
+
written_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
762
|
+
detail = $Detail
|
|
763
|
+
} | ConvertTo-Json -Depth 5
|
|
764
|
+
$file = Join-Path $crewMaintenanceResultsDir ("{0}.json" -f $Request.request_id)
|
|
765
|
+
$temp = Join-Path $crewMaintenanceResultsDir ("{0}.{1}.tmp" -f $Request.request_id, $PID)
|
|
766
|
+
Write-Utf8NoBom -Path $temp -Content $result
|
|
767
|
+
Move-Item -LiteralPath $temp -Destination $file -Force
|
|
768
|
+
} catch { /* best effort */ }
|
|
769
|
+
Remove-Item -LiteralPath (Join-Path $crewMaintenanceRequestsDir ("{0}.json" -f $Request.request_id)) -Force -ErrorAction SilentlyContinue
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function Invoke-CrewMaintenanceRequests {
|
|
773
|
+
# Consume maintenance transactions from npx lifecycle (cohort migration).
|
|
774
|
+
# The npx process owns the runtime TREE swap; this watcher owns the
|
|
775
|
+
# PROCESS stop/start around it. Two phases, both verified:
|
|
776
|
+
# maintenance-stop -> stop owned 3210, write STOPPED (+lease)
|
|
777
|
+
# maintenance-start -> start 3210, verify new identity + cohort, VERIFIED
|
|
778
|
+
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
779
|
+
if (-not (Test-Path -LiteralPath $crewMaintenanceRequestsDir -PathType Container)) { return }
|
|
780
|
+
$files = Get-ChildItem -LiteralPath $crewMaintenanceRequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue
|
|
781
|
+
foreach ($file in $files) {
|
|
782
|
+
try {
|
|
783
|
+
$request = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
|
|
784
|
+
} catch {
|
|
785
|
+
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
|
|
786
|
+
continue
|
|
787
|
+
}
|
|
788
|
+
if ($request.schema_version -ne 1 -or -not $request.request_id -or -not $request.operation) {
|
|
789
|
+
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
|
|
790
|
+
continue
|
|
791
|
+
}
|
|
792
|
+
if (($request.expires_at -as [long]) -lt $now) {
|
|
793
|
+
Write-MaintenanceResult $request 'MAINTENANCE_EXPIRED'
|
|
794
|
+
Write-LaunchLog ('Maintenance request {0} ({1}) expired; not executed.' -f $request.request_id, $request.operation) 'WARN'
|
|
795
|
+
continue
|
|
796
|
+
}
|
|
797
|
+
$crew = $services | Where-Object { $_.CrewOwned } | Select-Object -First 1
|
|
798
|
+
if (-not $crew) { continue }
|
|
799
|
+
if ($request.operation -eq 'maintenance-stop') {
|
|
800
|
+
# Authority: the request must name the SAME live runtime identity we own.
|
|
801
|
+
$liveRuntimeId = $null
|
|
802
|
+
try {
|
|
803
|
+
$resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 3
|
|
804
|
+
$runtime = $resp.extension.runtime
|
|
805
|
+
$canonical = $resp.ok -eq $true `
|
|
806
|
+
-and $runtime.service -eq 'dsh-crew-hub' `
|
|
807
|
+
-and $runtime.execution_plane -eq 'hub-3210' `
|
|
808
|
+
-and $runtime.profile -eq 'dsh-crew' `
|
|
809
|
+
-and ($runtime.listen_port -as [int]) -eq 3210 `
|
|
810
|
+
-and ($runtime.protocol_version -as [int]) -eq 1
|
|
811
|
+
if ($canonical) { $liveRuntimeId = $runtime.runtime_id }
|
|
812
|
+
} catch { $liveRuntimeId = $null }
|
|
813
|
+
if (-not $liveRuntimeId -or $liveRuntimeId -ne $request.runtime_id) {
|
|
814
|
+
Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
815
|
+
Write-LaunchLog ('Maintenance-stop {0} rejected: live runtime_id mismatch.' -f $request.request_id) 'WARN'
|
|
816
|
+
continue
|
|
817
|
+
}
|
|
818
|
+
$port = Get-PortState $crew.Port
|
|
819
|
+
if ($port.State -ne 'occupied' -or -not $port.Pid) {
|
|
820
|
+
Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
|
|
821
|
+
continue
|
|
822
|
+
}
|
|
823
|
+
$stopped = Stop-OwnedListener -Service $crew -ListenerPid ([int] $port.Pid)
|
|
824
|
+
if ($stopped) {
|
|
825
|
+
# The stopped window now belongs to the npx lifecycle: persist the
|
|
826
|
+
# STOPPED session (lease + proven runtime_id) so ordinary
|
|
827
|
+
# supervision will NOT auto-start 3210 until the matching start.
|
|
828
|
+
# STRONG semantics: STOPPED is published ONLY after the session is
|
|
829
|
+
# durably written AND read back with exact identity. A session write
|
|
830
|
+
# failure must never tell npx it owns a stopped window it cannot
|
|
831
|
+
# later prove (that race auto-restarts 3210 mid tree-swap).
|
|
832
|
+
$sessionDurable = Set-MaintenanceSession $request
|
|
833
|
+
if ($sessionDurable) {
|
|
834
|
+
Write-MaintenanceResult $request 'STOPPED' @{ lease = $request.lease; stopped_runtime_id = $request.runtime_id }
|
|
835
|
+
Write-LaunchLog ('Maintenance-stop {0} executed; lease issued.' -f $request.request_id)
|
|
836
|
+
} else {
|
|
837
|
+
Write-MaintenanceResult $request 'SUPERVISOR_SESSION_PERSIST_FAILED'
|
|
838
|
+
Write-LaunchLog ('Maintenance-stop {0} stopped the process but the STOPPED session could not be persisted; NOT publishing STOPPED.' -f $request.request_id) 'ERROR'
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
Write-MaintenanceResult $request 'SUPERVISOR_STOP_FAILED'
|
|
842
|
+
}
|
|
843
|
+
} elseif ($request.operation -eq 'maintenance-start') {
|
|
844
|
+
# Pair with the matching STOPPED session: the start request must carry
|
|
845
|
+
# the SAME lease and runtime_id the stop proved. A missing session,
|
|
846
|
+
# mismatched lease/identity, or a still-listening port all fail closed
|
|
847
|
+
# WITHOUT starting anything. The session is consumed on VERIFIED so
|
|
848
|
+
# the lease is one-shot (no replay).
|
|
849
|
+
$lease = $request.lease
|
|
850
|
+
$expectedCrew = $null
|
|
851
|
+
$expectedDsh = $null
|
|
852
|
+
if ($request.extra) {
|
|
853
|
+
$expectedCrew = $request.extra.expected_crew_version
|
|
854
|
+
$expectedDsh = $request.extra.expected_dsh_version
|
|
855
|
+
}
|
|
856
|
+
$session = Read-MaintenanceSession
|
|
857
|
+
if ($session -is [string] -or $session.lease -ne $lease -or $session.runtime_id -ne $request.runtime_id) {
|
|
858
|
+
Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
859
|
+
Write-LaunchLog ('Maintenance-start {0} rejected: no matching STOPPED session (lease/identity mismatch).' -f $request.request_id) 'WARN'
|
|
860
|
+
continue
|
|
861
|
+
}
|
|
862
|
+
$livePort = Get-PortState $crew.Port
|
|
863
|
+
if ($livePort.State -ne 'free') {
|
|
864
|
+
# occupied AND unknown both fail closed: the stopped window is not
|
|
865
|
+
# provably clean, so no start authority.
|
|
866
|
+
Write-MaintenanceResult $request 'SUPERVISOR_OWNERSHIP_CONFLICT'
|
|
867
|
+
Write-LaunchLog ('Maintenance-start {0} rejected: port {1} is not provably free (state={2}).' -f $request.request_id, $crew.Port, $livePort.State) 'WARN'
|
|
868
|
+
continue
|
|
869
|
+
}
|
|
870
|
+
Start-CrewService $crew
|
|
871
|
+
Wait-CrewServices
|
|
872
|
+
$newRuntime = $null
|
|
873
|
+
try {
|
|
874
|
+
$resp = Invoke-RestMethod -Uri 'http://127.0.0.1:3210/_dsh/dsh-crew/extension' -TimeoutSec 5
|
|
875
|
+
if ($resp.ok -eq $true) { $newRuntime = $resp.extension.runtime }
|
|
876
|
+
} catch { $newRuntime = $null }
|
|
877
|
+
$ok = $newRuntime -and $newRuntime.runtime_id `
|
|
878
|
+
-and $newRuntime.service -eq 'dsh-crew-hub' `
|
|
879
|
+
-and $newRuntime.execution_plane -eq 'hub-3210' `
|
|
880
|
+
-and $newRuntime.profile -eq 'dsh-crew' `
|
|
881
|
+
-and ($newRuntime.listen_port -as [int]) -eq 3210 `
|
|
882
|
+
-and ($newRuntime.protocol_version -as [int]) -eq 1 `
|
|
883
|
+
-and ($expectedCrew -eq $null -or $newRuntime.runtime_version -eq $expectedCrew) `
|
|
884
|
+
-and ($expectedDsh -eq $null -or $newRuntime.dsh_version -eq $expectedDsh)
|
|
885
|
+
if ($ok) {
|
|
886
|
+
# Verifiable one-shot consumption: only a provably-cleared session
|
|
887
|
+
# completes the transaction; otherwise the lease stays live and any
|
|
888
|
+
# replay is still fenced (no VERIFIED without consumption proof).
|
|
889
|
+
$consumed = Clear-MaintenanceSession
|
|
890
|
+
if ($consumed) {
|
|
891
|
+
Write-MaintenanceResult $request 'VERIFIED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id; runtime_version = $newRuntime.runtime_version; dsh_version = $newRuntime.dsh_version }
|
|
892
|
+
Write-LaunchLog ('Maintenance-start {0} verified: Crew {1} + DSH {2}.' -f $request.request_id, $newRuntime.runtime_version, $newRuntime.dsh_version)
|
|
893
|
+
} else {
|
|
894
|
+
Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $newRuntime.runtime_id }
|
|
895
|
+
Write-LaunchLog ('Maintenance-start {0} identity verified but session consumption unproven; NOT marking VERIFIED.' -f $request.request_id) 'ERROR'
|
|
896
|
+
}
|
|
897
|
+
} else {
|
|
898
|
+
$failedRuntimeId = $null
|
|
899
|
+
if ($newRuntime) { $failedRuntimeId = $newRuntime.runtime_id }
|
|
900
|
+
Write-MaintenanceResult $request 'VERIFY_FAILED' @{ lease = $lease; runtime_id = $failedRuntimeId }
|
|
901
|
+
Write-LaunchLog ('Maintenance-start {0} verification failed.' -f $request.request_id) 'ERROR'
|
|
902
|
+
}
|
|
903
|
+
} else {
|
|
904
|
+
# Unknown maintenance op: remove and report.
|
|
905
|
+
Write-MaintenanceResult $request 'MAINTENANCE_UNKNOWN_OP'
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
function Get-PortState {
|
|
912
|
+
param([int] $Port)
|
|
913
|
+
try {
|
|
914
|
+
$listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
|
|
915
|
+
$occupied = @($listeners | Where-Object Port -eq $Port).Count -gt 0
|
|
916
|
+
if (-not $occupied) {
|
|
917
|
+
return [pscustomobject]@{ State = 'free'; Error = $null; Pid = $null }
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
$ownerPid = $null
|
|
921
|
+
try {
|
|
922
|
+
$ownerPid = (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | Select-Object -First 1).OwningProcess
|
|
923
|
+
} catch { }
|
|
924
|
+
return [pscustomobject]@{ State = 'occupied'; Error = $null; Pid = $ownerPid }
|
|
925
|
+
} catch {
|
|
926
|
+
return [pscustomobject]@{ State = 'unknown'; Error = ('Listener enumeration failed: {0}' -f $_.Exception.Message); Pid = $null }
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function Test-TrackedProcessIdentity {
|
|
931
|
+
param([int] $ProcessId, [long] $ExpectedStartTicks, [object[]] $ProcessTable)
|
|
932
|
+
if ($ProcessId -lt 1 -or $ExpectedStartTicks -lt 1) { return $false }
|
|
933
|
+
$record = @($ProcessTable | Where-Object { [int] $_.ProcessId -eq $ProcessId } | Select-Object -First 1)
|
|
934
|
+
if ($record.Count -eq 0) { return $false }
|
|
935
|
+
$startTicks = $null
|
|
936
|
+
if ($record[0].PSObject.Properties.Name -contains 'StartTicks') {
|
|
937
|
+
$startTicks = [long] $record[0].StartTicks
|
|
938
|
+
} else {
|
|
939
|
+
try { $startTicks = (Get-Process -Id $ProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks } catch { return $false }
|
|
940
|
+
}
|
|
941
|
+
return $startTicks -eq $ExpectedStartTicks
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function Get-TrackedProcessTree {
|
|
945
|
+
param([pscustomobject] $Service, [object[]] $ProcessTable = $null)
|
|
946
|
+
$processes = if ($null -ne $ProcessTable) { @($ProcessTable) } else { @(Get-CimInstance Win32_Process -ErrorAction Stop) }
|
|
947
|
+
$owned = [System.Collections.Generic.HashSet[int]]::new()
|
|
948
|
+
$rootMatches = Test-TrackedProcessIdentity -ProcessId $Service.RootPid -ExpectedStartTicks $Service.RootStartedAtUtcTicks -ProcessTable $processes
|
|
949
|
+
$hasTrackedListener = $Service.ListenerPid -and $Service.ListenerStartedAtUtcTicks
|
|
950
|
+
if ($hasTrackedListener) {
|
|
951
|
+
$listenerMatches = Test-TrackedProcessIdentity -ProcessId $Service.ListenerPid -ExpectedStartTicks $Service.ListenerStartedAtUtcTicks -ProcessTable $processes
|
|
952
|
+
if (-not $listenerMatches) { return @() }
|
|
953
|
+
[void] $owned.Add([int] $Service.ListenerPid)
|
|
954
|
+
} elseif (-not $rootMatches) {
|
|
955
|
+
return @()
|
|
956
|
+
}
|
|
957
|
+
if ($rootMatches) { [void] $owned.Add([int] $Service.RootPid) }
|
|
958
|
+
do {
|
|
959
|
+
$added = $false
|
|
960
|
+
foreach ($candidate in $processes) {
|
|
961
|
+
$candidateId = [int] $candidate.ProcessId
|
|
962
|
+
$parentId = [int] $candidate.ParentProcessId
|
|
963
|
+
if ($owned.Contains($parentId) -and $owned.Add($candidateId)) { $added = $true }
|
|
964
|
+
}
|
|
965
|
+
} while ($added)
|
|
966
|
+
return @($owned | ForEach-Object { [int] $_ })
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function Set-TrackedListenerIdentity {
|
|
970
|
+
param([pscustomobject] $Service)
|
|
971
|
+
$port = Get-PortState $Service.Port
|
|
972
|
+
if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
|
|
973
|
+
$tree = @(Get-TrackedProcessTree -Service $Service)
|
|
974
|
+
if ($port.Pid -notin $tree) { return $false }
|
|
975
|
+
try {
|
|
976
|
+
$listener = Get-Process -Id $port.Pid -ErrorAction Stop
|
|
977
|
+
$Service.ListenerPid = [int] $port.Pid
|
|
978
|
+
$Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
|
|
979
|
+
return $true
|
|
980
|
+
} catch {
|
|
981
|
+
return $false
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function Test-CrewServiceOwnership {
|
|
986
|
+
param(
|
|
987
|
+
[pscustomObject] $Service,
|
|
988
|
+
[pscustomObject] $PortState = $null,
|
|
989
|
+
[object[]] $ProcessTable = $null
|
|
990
|
+
)
|
|
991
|
+
if (-not $Service.ListenerPid -or -not $Service.ListenerStartedAtUtcTicks) { return $false }
|
|
992
|
+
$port = if ($null -ne $PortState) { $PortState } else { Get-PortState $Service.Port }
|
|
993
|
+
if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $Service.ListenerPid) { return $false }
|
|
994
|
+
try {
|
|
995
|
+
$tree = if ($null -ne $ProcessTable) {
|
|
996
|
+
@(Get-TrackedProcessTree -Service $Service -ProcessTable $ProcessTable)
|
|
997
|
+
} else {
|
|
998
|
+
@(Get-TrackedProcessTree -Service $Service)
|
|
999
|
+
}
|
|
1000
|
+
return ([int] $port.Pid -in $tree)
|
|
1001
|
+
} catch {
|
|
1002
|
+
return $false
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function Stop-OwnedListener {
|
|
1007
|
+
param([pscustomobject] $Service, [int] $ListenerPid)
|
|
1008
|
+
if ($Mode -ne 'watch' -or -not $Service.ListenerPid -or $ListenerPid -ne $Service.ListenerPid) {
|
|
1009
|
+
return $false
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
$ownedProcessIds = @(Get-TrackedProcessTree -Service $Service)
|
|
1013
|
+
if ($ListenerPid -notin $ownedProcessIds) { return $false }
|
|
1014
|
+
Write-LaunchLog ('Supervisor confirmed owned listener PID={0} under tracked root PID={1}; stopping that process tree after {2} consecutive failed health checks.' -f $ListenerPid, $Service.RootPid, $Service.ConsecutiveFailures) 'WARN'
|
|
1015
|
+
foreach ($processId in $ownedProcessIds) {
|
|
1016
|
+
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
|
1017
|
+
}
|
|
1018
|
+
$deadline = (Get-Date).AddSeconds(5)
|
|
1019
|
+
do {
|
|
1020
|
+
$port = Get-PortState $Service.Port
|
|
1021
|
+
if ($port.State -eq 'free') { return $true }
|
|
1022
|
+
Start-Sleep -Milliseconds 250
|
|
1023
|
+
} while ((Get-Date) -lt $deadline)
|
|
1024
|
+
} catch {
|
|
1025
|
+
Write-LaunchLog ('Supervisor could not stop its owned listener safely: {0}' -f $_.Exception.Message) 'WARN'
|
|
1026
|
+
}
|
|
1027
|
+
return $false
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function Assert-HistoryStartAllowed {
|
|
1031
|
+
param([string] $StatePath = (Join-Path $crewHome '..\history\active.json'))
|
|
1032
|
+
if (-not (Test-Path -LiteralPath $StatePath)) { return }
|
|
1033
|
+
try {
|
|
1034
|
+
$state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
1035
|
+
if ($state.schemaVersion -eq 1 -and $state.phase -in @('STARTING', 'VERIFYING', 'DONE', 'FAILED', 'ROLLED_BACK')) { return }
|
|
1036
|
+
} catch { }
|
|
1037
|
+
throw 'HISTORY_RECOVERY_REQUIRED: Crew history maintenance is unfinished. Run dsh-crew history recover. Official 3080 is unchanged.'
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function Start-CrewService {
|
|
1041
|
+
param([pscustomobject] $Service)
|
|
1042
|
+
Assert-HistoryStartAllowed
|
|
1043
|
+
$serviceRunStamp = (Get-Date).ToString('yyyyMMdd-HHmmssfff')
|
|
1044
|
+
$stdout = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.out.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
|
|
1045
|
+
$stderr = Join-Path $logRoot ('dsh-crew-{0}-{1}-{2}.err.log' -f $Service.Profile, $Service.Port, $serviceRunStamp)
|
|
1046
|
+
$previousHome = $env:DSH_HOME
|
|
1047
|
+
try {
|
|
1048
|
+
$env:DSH_HOME = $Service.Home
|
|
1049
|
+
$arguments = @('--profile', $Service.Profile, '--host', '127.0.0.1', '--port', [string] $Service.Port, '--no-open')
|
|
1050
|
+
$launchArguments = if ($dshCliIsNodeEntry) { @($dshCli) + $arguments } else { $arguments }
|
|
1051
|
+
$process = Start-Process -FilePath $dshCommand -ArgumentList $launchArguments -WindowStyle Hidden -PassThru `
|
|
1052
|
+
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
1053
|
+
$Service.Process = $process
|
|
1054
|
+
$Service.RootPid = $process.Id
|
|
1055
|
+
$Service.RootStartedAtUtcTicks = $process.StartTime.ToUniversalTime().Ticks
|
|
1056
|
+
$Service.ListenerPid = $null
|
|
1057
|
+
$Service.ListenerStartedAtUtcTicks = $null
|
|
1058
|
+
$Service.ConsecutiveFailures = 0
|
|
1059
|
+
$Service.State = 'starting'
|
|
1060
|
+
Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
|
|
1061
|
+
} finally {
|
|
1062
|
+
$env:DSH_HOME = $previousHome
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function Wait-CrewServices {
|
|
1067
|
+
$deadline = (Get-Date).AddSeconds(90)
|
|
1068
|
+
while (@($services | Where-Object State -eq 'starting').Count -gt 0 -and (Get-Date) -lt $deadline) {
|
|
1069
|
+
foreach ($service in ($services | Where-Object State -eq 'starting')) {
|
|
1070
|
+
$health = Get-HealthState $service
|
|
1071
|
+
$service.LastError = $health.Error
|
|
1072
|
+
if ($health.Ready) {
|
|
1073
|
+
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
|
|
1074
|
+
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1075
|
+
}
|
|
1076
|
+
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
1077
|
+
throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
|
|
1078
|
+
}
|
|
1079
|
+
$service.State = 'ready'
|
|
1080
|
+
$service.ConsecutiveFailures = 0
|
|
1081
|
+
Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
|
|
1082
|
+
} elseif ($service.Process -and $service.Process.HasExited) {
|
|
1083
|
+
throw ('{0} exited before becoming ready; PID={1}; exit={2}; last health error: {3}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode, $health.Error)
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (@($services | Where-Object State -eq 'starting').Count -gt 0) { Start-Sleep -Milliseconds 500 }
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
$notReady = @($services | Where-Object {
|
|
1090
|
+
$_.State -ne 'ready'
|
|
1091
|
+
})
|
|
1092
|
+
if ($notReady.Count -gt 0) {
|
|
1093
|
+
$details = ($notReady | ForEach-Object { '{0}:{1} ({2})' -f $_.Profile, $_.Port, $_.LastError }) -join '; '
|
|
1094
|
+
throw "Startup health deadline exceeded: $details"
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
# Explicit diagnostic helper; not called by normal startup or supervision.
|
|
1099
|
+
function Write-LegacyBridgeDiagnostic {
|
|
1100
|
+
if (Test-LegacyBridgeAvailable) {
|
|
1101
|
+
Write-LaunchLog 'Diagnostic: legacy 3080 bridge still answers ping; Crew launcher owns 3210 directly, bridge ignored.'
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function Ensure-CrewServices {
|
|
1106
|
+
param([switch] $QuietHealthy)
|
|
1107
|
+
|
|
1108
|
+
foreach ($service in $services) {
|
|
1109
|
+
# Maintenance fence: while a STOPPED maintenance session is active for
|
|
1110
|
+
# the Crew backend, ordinary supervision must NOT auto-start 3210 —
|
|
1111
|
+
# the stopped window belongs to the npx lifecycle's tree swap. Only a
|
|
1112
|
+
# matching maintenance-start owns the launch right.
|
|
1113
|
+
if ($service.CrewOwned -and (Test-MaintenanceSessionActive)) {
|
|
1114
|
+
$service.State = 'maintenance'
|
|
1115
|
+
$service.LastError = $null
|
|
1116
|
+
continue
|
|
1117
|
+
}
|
|
1118
|
+
$health = Get-HealthState $service
|
|
1119
|
+
if ($health.Ready) {
|
|
1120
|
+
$wasReady = $service.State -eq 'ready'
|
|
1121
|
+
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
|
|
1122
|
+
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1123
|
+
}
|
|
1124
|
+
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
1125
|
+
throw ('{0} is healthy on {1}, but its tracked listener identity is not owned.' -f $service.Name, $service.Port)
|
|
1126
|
+
}
|
|
1127
|
+
$service.State = 'ready'
|
|
1128
|
+
$service.ConsecutiveFailures = 0
|
|
1129
|
+
$service.LastError = $null
|
|
1130
|
+
if (-not $QuietHealthy -or -not $wasReady) {
|
|
1131
|
+
Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
|
|
1132
|
+
}
|
|
1133
|
+
continue
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
# The Crew launcher owns 3210 directly and always boots it below.
|
|
1137
|
+
# Legacy bridge presence is diagnosed only after 3210 is ready (see
|
|
1138
|
+
# Wait-CrewServices tail), never on the boot path.
|
|
1139
|
+
|
|
1140
|
+
$service.LastError = $health.Error
|
|
1141
|
+
$service.ConsecutiveFailures += 1
|
|
1142
|
+
if ($service.Process -and $service.Process.HasExited) {
|
|
1143
|
+
Write-LaunchLog ('{0} process exited after startup; PID={1}; exit={2}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode) 'WARN'
|
|
1144
|
+
$service.Process = $null
|
|
1145
|
+
}
|
|
1146
|
+
$service.State = 'pending'
|
|
1147
|
+
|
|
1148
|
+
$port = Get-PortState $service.Port
|
|
1149
|
+
if ($port.State -eq 'occupied') {
|
|
1150
|
+
if ($service.ConsecutiveFailures -lt 3) {
|
|
1151
|
+
throw ('Health check {0}/3 failed for {1}; owned process remains untouched until failure is confirmed. Health error: {2}' -f $service.ConsecutiveFailures, $service.Name, $health.Error)
|
|
1152
|
+
}
|
|
1153
|
+
if (Stop-OwnedListener -Service $service -ListenerPid $port.Pid) {
|
|
1154
|
+
$service.Process = $null
|
|
1155
|
+
$service.RootPid = $null
|
|
1156
|
+
$service.RootStartedAtUtcTicks = $null
|
|
1157
|
+
$service.ListenerPid = $null
|
|
1158
|
+
$service.ListenerStartedAtUtcTicks = $null
|
|
1159
|
+
$service.ConsecutiveFailures = 0
|
|
1160
|
+
$port = Get-PortState $service.Port
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if ($port.State -eq 'occupied') {
|
|
1164
|
+
$owner = if ($port.Pid) { "; listener PID=$($port.Pid)" } else { '' }
|
|
1165
|
+
throw ('Port {0} is occupied, but {1} failed its health contract{2}. Health error: {3}' -f $service.Port, $service.Name, $owner, $health.Error)
|
|
1166
|
+
}
|
|
1167
|
+
if ($port.State -ne 'free') {
|
|
1168
|
+
throw ('Could not determine whether port {0} is available: {1}' -f $service.Port, $port.Error)
|
|
1169
|
+
}
|
|
1170
|
+
if ($Mode -eq 'watch') {
|
|
1171
|
+
Write-LaunchLog ('Supervisor detected {0} unavailable on {1}; restarting it.' -f $service.Name, $service.Port) 'WARN'
|
|
1172
|
+
}
|
|
1173
|
+
Start-CrewService $service
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
Wait-CrewServices
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function Start-ServiceSupervisor {
|
|
1180
|
+
$mutex = [System.Threading.Mutex]::new($false, 'Local\DSHCrewServiceSupervisor')
|
|
1181
|
+
$ownsMutex = $false
|
|
1182
|
+
try {
|
|
1183
|
+
try {
|
|
1184
|
+
$ownsMutex = $mutex.WaitOne(0)
|
|
1185
|
+
} catch [System.Threading.AbandonedMutexException] {
|
|
1186
|
+
$ownsMutex = $true
|
|
1187
|
+
}
|
|
1188
|
+
if (-not $ownsMutex) {
|
|
1189
|
+
Write-LaunchLog 'Supervisor already active; duplicate watcher exiting.'
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
# Publish process identity before any 3210 startup/health wait. Handoff
|
|
1194
|
+
# callers can now distinguish a legitimate cold-starting target watcher
|
|
1195
|
+
# from the stale heartbeat left by the watcher it replaced.
|
|
1196
|
+
Write-SupervisorHeartbeat -OwnershipReady $false
|
|
1197
|
+
Write-LaunchLog 'Supervisor active; monitoring Crew-owned 3210 every 10 seconds.'
|
|
1198
|
+
$lastRecoveryError = $null
|
|
1199
|
+
$updateLockFile = Join-Path $crewHome '..\app\update-in-progress.lock'
|
|
1200
|
+
$staleLockNotified = $false
|
|
1201
|
+
while ($true) {
|
|
1202
|
+
try {
|
|
1203
|
+
# An installer-held update lock suspends recovery restarts: the tree
|
|
1204
|
+
# under Crew-owned state may be mid-migration, and a restart now
|
|
1205
|
+
# would boot a half-installed runtime/profile. A lock whose owner
|
|
1206
|
+
# process is dead is stale: log once and resume supervision instead
|
|
1207
|
+
# of staying observe-only forever.
|
|
1208
|
+
$updateHeld = $false
|
|
1209
|
+
if (Test-Path -LiteralPath $updateLockFile -PathType Leaf) {
|
|
1210
|
+
$lockAlive = $false
|
|
1211
|
+
try {
|
|
1212
|
+
$lockRecord = Get-Content -LiteralPath $updateLockFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
1213
|
+
$lockPid = [int] $lockRecord.pid
|
|
1214
|
+
if ($lockPid -gt 0) {
|
|
1215
|
+
Get-Process -Id $lockPid -ErrorAction Stop | Out-Null
|
|
1216
|
+
$lockAlive = $true
|
|
1217
|
+
}
|
|
1218
|
+
} catch { $lockAlive = $false }
|
|
1219
|
+
if ($lockAlive) {
|
|
1220
|
+
$updateHeld = $true
|
|
1221
|
+
$staleLockNotified = $false
|
|
1222
|
+
} elseif (-not $staleLockNotified) {
|
|
1223
|
+
Write-LaunchLog 'Stale update lock detected (owner process dead); resuming supervision. Re-run install/update to reconcile.' 'WARN'
|
|
1224
|
+
$staleLockNotified = $true
|
|
1225
|
+
}
|
|
1226
|
+
} else {
|
|
1227
|
+
$staleLockNotified = $false
|
|
1228
|
+
}
|
|
1229
|
+
# Maintenance stop/start is the updater's owned process handoff and
|
|
1230
|
+
# must remain live while the update lock fences ordinary recovery.
|
|
1231
|
+
Invoke-CrewMaintenanceRequests
|
|
1232
|
+
if ($updateHeld) {
|
|
1233
|
+
Write-LaunchLog 'Update in progress; maintenance only, ordinary recovery suspended.'
|
|
1234
|
+
$lastRecoveryError = $null
|
|
1235
|
+
} else {
|
|
1236
|
+
# Control protocol: publish this watcher's heartbeat and consume
|
|
1237
|
+
# any durable restart requests the hub wrote (3210 never spawns
|
|
1238
|
+
# itself). Maintenance was consumed above; now run the ordinary
|
|
1239
|
+
# health pass.
|
|
1240
|
+
Invoke-CrewRestartRequests
|
|
1241
|
+
Ensure-CrewServices -QuietHealthy
|
|
1242
|
+
if ($lastRecoveryError) {
|
|
1243
|
+
Write-LaunchLog 'Supervisor recovery succeeded; Crew-owned 3210 is healthy.'
|
|
1244
|
+
$lastRecoveryError = $null
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
$ownedServices = @($services | Where-Object { $_.CrewOwned -and (Test-CrewServiceOwnership -Service $_) })
|
|
1248
|
+
$expectedServices = @($services | Where-Object { $_.CrewOwned })
|
|
1249
|
+
Write-SupervisorHeartbeat -OwnershipReady ($expectedServices.Count -gt 0 -and $ownedServices.Count -eq $expectedServices.Count)
|
|
1250
|
+
} catch {
|
|
1251
|
+
$recoveryError = $_.Exception.Message
|
|
1252
|
+
Write-SupervisorHeartbeat -OwnershipReady $false
|
|
1253
|
+
if ($recoveryError -ne $lastRecoveryError) {
|
|
1254
|
+
Write-LaunchLog ('Supervisor recovery failed; will retry: {0}' -f $recoveryError) 'WARN'
|
|
1255
|
+
$lastRecoveryError = $recoveryError
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
Start-Sleep -Seconds 10
|
|
1259
|
+
}
|
|
1260
|
+
} finally {
|
|
1261
|
+
if ($ownsMutex) { $mutex.ReleaseMutex() }
|
|
1262
|
+
$mutex.Dispose()
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
|
|
1267
|
+
|
|
1268
|
+
try {
|
|
1269
|
+
New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
|
|
1270
|
+
Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
|
|
1271
|
+
if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
|
|
1272
|
+
throw "DSH CLI was not found at $dshCli. Configure DSH_CREW_DSH_CLI to a Crew-owned official CLI entry, or run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
|
|
1273
|
+
}
|
|
1274
|
+
Test-CrewDshCliPreflight
|
|
1275
|
+
if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
|
|
1276
|
+
throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
|
|
1277
|
+
}
|
|
1278
|
+
if ($Mode -eq 'open') { Open-OfficialFrontend }
|
|
1279
|
+
# Background/watch modes remain independent of the official frontend.
|
|
1280
|
+
|
|
1281
|
+
if ($Mode -eq 'watch') {
|
|
1282
|
+
Start-ServiceSupervisor
|
|
1283
|
+
Write-LaunchLog 'Supervisor stopped.' 'WARN'
|
|
1284
|
+
exit 0
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
Ensure-CrewSupervisorRunning
|
|
1288
|
+
|
|
1289
|
+
if ($Mode -eq 'open') {
|
|
1290
|
+
Write-LaunchLog 'Official frontend is on 3080; Crew is running silently on 3210.'
|
|
1291
|
+
}
|
|
1292
|
+
Write-LaunchLog ('Launcher completed successfully in {0:n1}s.' -f ((Get-Date) - $startedAt).TotalSeconds)
|
|
1293
|
+
exit 0
|
|
1294
|
+
} catch {
|
|
1295
|
+
Write-LaunchLog $_.Exception.Message 'ERROR'
|
|
1296
|
+
if ($Mode -eq 'open') {
|
|
1297
|
+
Write-Host ''
|
|
1298
|
+
Write-Host "Diagnostic log: $launcherLog" -ForegroundColor Yellow
|
|
1299
|
+
Write-Host 'Startup failed. Review the diagnostic log and the service stdout/stderr paths recorded above.' -ForegroundColor Yellow
|
|
1300
|
+
}
|
|
1301
|
+
exit 1
|
|
1302
|
+
}
|