@a11ign/screenreader-fleet 0.0.0-reserved.0 → 0.2.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.
Files changed (147) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +94 -2
  3. package/dist/capture-client.d.mts +49 -0
  4. package/dist/capture-client.d.mts.map +1 -0
  5. package/dist/capture-client.mjs +352 -0
  6. package/dist/capture-client.mjs.map +1 -0
  7. package/dist/check-worker-code.d.mts +34 -0
  8. package/dist/check-worker-code.d.mts.map +1 -0
  9. package/dist/check-worker-code.mjs +142 -0
  10. package/dist/check-worker-code.mjs.map +1 -0
  11. package/dist/cli-flags.d.mts +71 -0
  12. package/dist/cli-flags.d.mts.map +1 -0
  13. package/dist/cli-flags.mjs +207 -0
  14. package/dist/cli-flags.mjs.map +1 -0
  15. package/dist/code-drift.d.mts +140 -0
  16. package/dist/code-drift.d.mts.map +1 -0
  17. package/dist/code-drift.mjs +284 -0
  18. package/dist/code-drift.mjs.map +1 -0
  19. package/dist/command-line-census.d.mts +33 -0
  20. package/dist/command-line-census.d.mts.map +1 -0
  21. package/dist/command-line-census.mjs +96 -0
  22. package/dist/command-line-census.mjs.map +1 -0
  23. package/dist/compare-workers.d.mts +3 -0
  24. package/dist/compare-workers.d.mts.map +1 -0
  25. package/dist/compare-workers.mjs +332 -0
  26. package/dist/compare-workers.mjs.map +1 -0
  27. package/dist/control-plane-isolation.d.mts +45 -0
  28. package/dist/control-plane-isolation.d.mts.map +1 -0
  29. package/dist/control-plane-isolation.mjs +67 -0
  30. package/dist/control-plane-isolation.mjs.map +1 -0
  31. package/dist/deploy-worker.d.mts +3 -0
  32. package/dist/deploy-worker.d.mts.map +1 -0
  33. package/dist/deploy-worker.mjs +333 -0
  34. package/dist/deploy-worker.mjs.map +1 -0
  35. package/dist/doctor.d.mts +216 -0
  36. package/dist/doctor.d.mts.map +1 -0
  37. package/dist/doctor.mjs +962 -0
  38. package/dist/doctor.mjs.map +1 -0
  39. package/dist/fleet-consistency.d.mts +235 -0
  40. package/dist/fleet-consistency.d.mts.map +1 -0
  41. package/dist/fleet-consistency.mjs +436 -0
  42. package/dist/fleet-consistency.mjs.map +1 -0
  43. package/dist/fleet-env.d.mts +228 -0
  44. package/dist/fleet-env.d.mts.map +1 -0
  45. package/dist/fleet-env.mjs +509 -0
  46. package/dist/fleet-env.mjs.map +1 -0
  47. package/dist/fleet-scripts.d.mts +11 -0
  48. package/dist/fleet-scripts.d.mts.map +1 -0
  49. package/dist/fleet-scripts.mjs +41 -0
  50. package/dist/fleet-scripts.mjs.map +1 -0
  51. package/dist/git-safe-env.d.mts +10 -0
  52. package/dist/git-safe-env.d.mts.map +1 -0
  53. package/dist/git-safe-env.mjs +44 -0
  54. package/dist/git-safe-env.mjs.map +1 -0
  55. package/dist/guest-run.d.mts +26 -0
  56. package/dist/guest-run.d.mts.map +1 -0
  57. package/dist/guest-run.mjs +164 -0
  58. package/dist/guest-run.mjs.map +1 -0
  59. package/dist/host-address.d.mts +33 -0
  60. package/dist/host-address.d.mts.map +1 -0
  61. package/dist/host-address.mjs +105 -0
  62. package/dist/host-address.mjs.map +1 -0
  63. package/dist/host-capacity.d.mts +64 -0
  64. package/dist/host-capacity.d.mts.map +1 -0
  65. package/dist/host-capacity.mjs +152 -0
  66. package/dist/host-capacity.mjs.map +1 -0
  67. package/dist/host-metrics.d.mts +116 -0
  68. package/dist/host-metrics.d.mts.map +1 -0
  69. package/dist/host-metrics.mjs +201 -0
  70. package/dist/host-metrics.mjs.map +1 -0
  71. package/dist/index.d.ts +23 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +25 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/local-vm.d.ts +125 -0
  76. package/dist/local-vm.d.ts.map +1 -0
  77. package/dist/local-vm.js +360 -0
  78. package/dist/local-vm.js.map +1 -0
  79. package/dist/measure-guard.d.mts +34 -0
  80. package/dist/measure-guard.d.mts.map +1 -0
  81. package/dist/measure-guard.mjs +73 -0
  82. package/dist/measure-guard.mjs.map +1 -0
  83. package/dist/normalise-fleet.d.mts +2 -0
  84. package/dist/normalise-fleet.d.mts.map +1 -0
  85. package/dist/normalise-fleet.mjs +76 -0
  86. package/dist/normalise-fleet.mjs.map +1 -0
  87. package/dist/npm-cli-executable.d.mts +42 -0
  88. package/dist/npm-cli-executable.d.mts.map +1 -0
  89. package/dist/npm-cli-executable.mjs +159 -0
  90. package/dist/npm-cli-executable.mjs.map +1 -0
  91. package/dist/probe-outcome.d.mts +89 -0
  92. package/dist/probe-outcome.d.mts.map +1 -0
  93. package/dist/probe-outcome.mjs +104 -0
  94. package/dist/probe-outcome.mjs.map +1 -0
  95. package/dist/protocol-guard.d.mts +34 -0
  96. package/dist/protocol-guard.d.mts.map +1 -0
  97. package/dist/protocol-guard.mjs +121 -0
  98. package/dist/protocol-guard.mjs.map +1 -0
  99. package/dist/source-walk.d.mts +12 -0
  100. package/dist/source-walk.d.mts.map +1 -0
  101. package/dist/source-walk.mjs +56 -0
  102. package/dist/source-walk.mjs.map +1 -0
  103. package/dist/transient-fault.d.mts +6 -0
  104. package/dist/transient-fault.d.mts.map +1 -0
  105. package/dist/transient-fault.mjs +86 -0
  106. package/dist/transient-fault.mjs.map +1 -0
  107. package/dist/utm-deprecated.d.mts +6 -0
  108. package/dist/utm-deprecated.d.mts.map +1 -0
  109. package/dist/utm-deprecated.mjs +23 -0
  110. package/dist/utm-deprecated.mjs.map +1 -0
  111. package/dist/worker-code-check.d.mts +29 -0
  112. package/dist/worker-code-check.d.mts.map +1 -0
  113. package/dist/worker-code-check.mjs +85 -0
  114. package/dist/worker-code-check.mjs.map +1 -0
  115. package/dist/worker-health.d.mts +56 -0
  116. package/dist/worker-health.d.mts.map +1 -0
  117. package/dist/worker-health.mjs +73 -0
  118. package/dist/worker-health.mjs.map +1 -0
  119. package/dist/worker-http.d.mts +103 -0
  120. package/dist/worker-http.d.mts.map +1 -0
  121. package/dist/worker-http.mjs +277 -0
  122. package/dist/worker-http.mjs.map +1 -0
  123. package/dist/worker-stats.d.mts +66 -0
  124. package/dist/worker-stats.d.mts.map +1 -0
  125. package/dist/worker-stats.mjs +143 -0
  126. package/dist/worker-stats.mjs.map +1 -0
  127. package/package.json +96 -4
  128. package/src/local-worker/autounattend.xml +280 -0
  129. package/src/local-worker/build-vm.sh +218 -0
  130. package/src/local-worker/clone-worker.sh +141 -0
  131. package/src/local-worker/create-utm-vm.sh +202 -0
  132. package/src/local-worker/fetch-windows-iso.sh +238 -0
  133. package/src/local-worker/first-boot.cmd +58 -0
  134. package/src/local-worker/worker-ctl.sh +442 -0
  135. package/src/provisioning/README.md +28 -0
  136. package/src/provisioning/apply-foreground-lock-timeout.ps1 +71 -0
  137. package/src/provisioning/bare-metal/README.md +213 -0
  138. package/src/provisioning/bare-metal/a11y-bootstrap.service +58 -0
  139. package/src/provisioning/bare-metal/autounattend.xml +428 -0
  140. package/src/provisioning/bare-metal/serve-bootstrap.sh +86 -0
  141. package/src/provisioning/bootstrap-control-plane.sh +463 -0
  142. package/src/provisioning/bootstrap-windows-worker.ps1 +649 -0
  143. package/src/provisioning/build-lean-worker-image.ps1 +275 -0
  144. package/src/provisioning/diagnose-nvda-worker.ps1 +174 -0
  145. package/src/provisioning/provision-nvda-worker.ps1 +827 -0
  146. package/src/provisioning/set-display-mode.ps1 +411 -0
  147. package/src/provisioning/stamp-provision-revision.ps1 +184 -0
@@ -0,0 +1,411 @@
1
+ # Pin the display mode (width x height) the fleet's capture window holds, and prove it landed.
2
+ #
3
+ # #1567/#1819: originally a `win_powershell` task, run directly over the SSH connection Ansible uses.
4
+ # EnumDisplaySettings failed there -- `ok=0` even as the console-session `witness` account, not only under
5
+ # `become`/SYSTEM -- because an OpenSSH-spawned PowerShell child does not attach to the interactive window
6
+ # station the console session's desktop uses, the same "SSH has no interactive desktop" fact
7
+ # `tasks/run-interactive.yml`'s header already names for `SystemParametersInfo` and guidepup. That move
8
+ # was NECESSARY and it stands; this script is still called through the interactive one-shot task.
9
+ #
10
+ # ## #1955: it was necessary and it was not SUFFICIENT, and this is the part that was missing
11
+ #
12
+ # Run through `run-interactive.yml` against the real fleet on 2026-09-22 it failed identically on 10 of 10
13
+ # workers. Measured on a11y-worker-2 (orchestrator, 2026-09-22T18:58Z), the interactive route is provably
14
+ # doing its job -- `sessionId=1`, `windowStation='WinSta0'`, `desktop='Default'`, and
15
+ # `GetSystemMetrics` reporting `screen=1024x768 monitors=1`. The desktop has a display. The call still
16
+ # failed. So the fault was never the session, and #1833 did not miss.
17
+ #
18
+ # The fault is THE DEVICE ARGUMENT. Measured on the same box, in the same interactive task, seconds apart:
19
+ #
20
+ # EnumDisplaySettingsW($null, ENUM_CURRENT_SETTINGS) -> False, mode 0x0
21
+ # EnumDisplaySettingsW('\\.\DISPLAY1', ENUM_CURRENT_SETTINGS) -> True, mode 1024x768
22
+ # EnumDisplayDevicesA($null, 0) -> False EnumDisplayDevicesW($null, 0) -> False
23
+ # GetDC(null) + GetDeviceCaps -> HORZRES=1024 VERTRES=768 BITSPIXEL=32
24
+ # Screen.AllScreens -> '\\.\DISPLAY1' 1024x768 primary
25
+ #
26
+ # THOSE FIVE READINGS, AND NO MORE THAN THOSE FIVE -- and READ WHAT `$null` IN THEM ACTUALLY SENT, because
27
+ # it is not what it says. PowerShell converts `$null` to `[string]::Empty` when it binds a `string`
28
+ # parameter; only `[NullString]::Value` sends a genuine NULL. Measured under pwsh 7.6.6 against a C#
29
+ # `s == null` probe, not reasoned: literal `$null` and a variable holding `$null` both arrive as
30
+ # "not-null, length 0", and `[NullString]::Value` arrives as NULL. So every line above that reads `$null`
31
+ # passed a device NAMED `""` -- and no display device is called that, on this fleet or anywhere else.
32
+ #
33
+ # WHICH MAKES THE CHEAPER EXPLANATION THE RIGHT ONE, and #1968's first telling of this file got it wrong.
34
+ # The ten-host failure was `EnumDisplaySettings($null, ...)` in this script's previous version: a
35
+ # PowerShell binding trap, refused by any Windows box, not a property of these workers. What the readings
36
+ # above do establish is narrower and still enough to act on: an EMPTY-named call refuses and a call naming
37
+ # `\\.\DISPLAY1` answers. They establish NOTHING about a real NULL device, because no call in the list
38
+ # ever passed one.
39
+ #
40
+ # THE FIX IS THEREFORE TO STOP ASKING FOR "the default device" AND TO NAME ONE -- unchanged by the above,
41
+ # and the reading that shows it working (worker-7, below) is unaffected. The primary display's device name
42
+ # comes from `MonitorFromPoint` + `GetMonitorInfoW`, and it is passed to `EnumDisplaySettingsW` and
43
+ # `ChangeDisplaySettingsExW`. `ChangeDisplaySettings` (no `Ex`) cannot take a device name at all, which is
44
+ # why it is gone.
45
+ #
46
+ # The diagnostic's own nameless arm now passes `[NullString]::Value`, so the NEXT fleet run is the first
47
+ # reading that has ever actually asked the NULL question. Until one comes back, this file claims the
48
+ # empty-name finding and no more.
49
+ #
50
+ # That the lookup path runs here is a READING too, not an assumption. With this script in place on
51
+ # a11y-worker-7 (orchestrator, 2026-09-22T19:07Z) it printed
52
+ # `context: current mode reads 640 x 480 on '\\.\DISPLAY1'`, and the name in that line is precisely what
53
+ # `MonitorFromPoint` + `GetMonitorInfoW` returned -- so both ran and both answered. Neither of them takes a
54
+ # device name, so neither is evidence about NULL-versus-named; they are the way OUT of that question.
55
+ #
56
+ # ## Why the context block prints on every run
57
+ #
58
+ # The failure this row exists for returned ONE sentence -- `EnumDisplaySettings could not read the current
59
+ # display mode` -- and that sentence could not tell apart (1) the task landed in session 0 after all,
60
+ # (2) it landed in session 1 but on a desktop with no display, and (3) it reached the right desktop and
61
+ # the call refused anyway. Three causes, three files, one sentence: a fleet play that cannot separate them
62
+ # buys nothing, and today's cost a full ten-host run for exactly that. The block prints on success too,
63
+ # because a passing reading and a failing one are only comparable if they report the same fields.
64
+ #
65
+ # `SetLastError = true` is on the P/Invokes, but READ THE ERROR CODES WITH SUSPICION and the script says so
66
+ # where it prints them: none of these functions is documented to call SetLastError on failure, and measured
67
+ # here `203` (ERROR_ENVVAR_NOT_FOUND) came back from the FAILING calls and from the SUCCEEDING one alike.
68
+ # It is a stale value from an unrelated call, which makes it exactly the shape of evidence this repo is
69
+ # most wary of -- a number that looks like a finding and is not.
70
+ #
71
+ # Config comes from the environment rather than param(), matching diagnose-nvda-worker.ps1's reasoning --
72
+ # the caller is a single templated Ansible string either way, and an env assignment ahead of the call
73
+ # avoids one more layer of quoting inside `run-interactive.yml`'s already-quoted -Command string:
74
+ # A11Y_DISPLAY_WIDTH, A11Y_DISPLAY_HEIGHT
75
+ #
76
+ # Losing check-mode support is the real trade here: the old `win_powershell` task honoured
77
+ # `ansible-playbook --check`; a scheduled task run through `run-interactive.yml` does not, the same as
78
+ # every other interactive step in this role (`provision.yml`'s call into provision-nvda-worker.ps1).
79
+
80
+ $ErrorActionPreference = 'Stop'
81
+
82
+ $Width = [int] $env:A11Y_DISPLAY_WIDTH
83
+ $Height = [int] $env:A11Y_DISPLAY_HEIGHT
84
+ if ($Width -le 0 -or $Height -le 0) {
85
+ Write-Output "A11Y_DISPLAY_WIDTH/A11Y_DISPLAY_HEIGHT must both be positive integers, got $Width x $Height"
86
+ exit 1
87
+ }
88
+
89
+ if (-not ('A11yDisplay.NativeMethods' -as [type])) {
90
+ Add-Type -TypeDefinition '
91
+ using System;
92
+ using System.Runtime.InteropServices;
93
+ using System.Text;
94
+ namespace A11yDisplay {
95
+ // DEVMODEW. The Unicode form is deliberate: the measured working call on the fleet is
96
+ // EnumDisplaySettingsW with a named device, so the whole path is W rather than a mix.
97
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
98
+ public struct DEVMODE {
99
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName;
100
+ public short dmSpecVersion;
101
+ public short dmDriverVersion;
102
+ public short dmSize;
103
+ public short dmDriverExtra;
104
+ public int dmFields;
105
+ public int dmPositionX;
106
+ public int dmPositionY;
107
+ public int dmDisplayOrientation;
108
+ public int dmDisplayFixedOutput;
109
+ public short dmColor;
110
+ public short dmDuplex;
111
+ public short dmYResolution;
112
+ public short dmTTOption;
113
+ public short dmCollate;
114
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName;
115
+ public short dmLogPixels;
116
+ public int dmBitsPerPel;
117
+ public int dmPelsWidth;
118
+ public int dmPelsHeight;
119
+ public int dmDisplayFlags;
120
+ public int dmDisplayFrequency;
121
+ public int dmICMMethod;
122
+ public int dmICMIntent;
123
+ public int dmMediaType;
124
+ public int dmDitherType;
125
+ public int dmReserved1;
126
+ public int dmReserved2;
127
+ public int dmPanningWidth;
128
+ public int dmPanningHeight;
129
+ }
130
+ [StructLayout(LayoutKind.Sequential)]
131
+ public struct RECT { public int left; public int top; public int right; public int bottom; }
132
+ [StructLayout(LayoutKind.Sequential)]
133
+ public struct POINT { public int x; public int y; }
134
+ // MONITORINFOEXW: MONITORINFO plus szDevice, the `\\.\DISPLAYn` name this script exists to obtain.
135
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
136
+ public struct MONITORINFOEX {
137
+ public int cbSize;
138
+ public RECT rcMonitor;
139
+ public RECT rcWork;
140
+ public int dwFlags;
141
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szDevice;
142
+ }
143
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
144
+ public struct DISPLAY_DEVICE {
145
+ public int cb;
146
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DeviceName;
147
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceString;
148
+ public int StateFlags;
149
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceID;
150
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceKey;
151
+ }
152
+ public class NativeMethods {
153
+ [DllImport("user32.dll", EntryPoint = "EnumDisplaySettingsW", SetLastError = true, CharSet = CharSet.Unicode)]
154
+ public static extern bool EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode);
155
+ [DllImport("user32.dll", EntryPoint = "ChangeDisplaySettingsExW", SetLastError = true, CharSet = CharSet.Unicode)]
156
+ public static extern int ChangeDisplaySettingsEx(string deviceName, ref DEVMODE devMode, IntPtr window, int flags, IntPtr param);
157
+ [DllImport("user32.dll", EntryPoint = "EnumDisplayDevicesW", SetLastError = true, CharSet = CharSet.Unicode)]
158
+ public static extern bool EnumDisplayDevices(string device, int devNum, ref DISPLAY_DEVICE info, int flags);
159
+ [DllImport("user32.dll", SetLastError = true)]
160
+ public static extern IntPtr MonitorFromPoint(POINT pt, int flags);
161
+ [DllImport("user32.dll", EntryPoint = "GetMonitorInfoW", SetLastError = true, CharSet = CharSet.Unicode)]
162
+ public static extern bool GetMonitorInfo(IntPtr monitor, ref MONITORINFOEX info);
163
+ [DllImport("user32.dll", SetLastError = true)]
164
+ public static extern IntPtr GetProcessWindowStation();
165
+ [DllImport("user32.dll", SetLastError = true)]
166
+ public static extern IntPtr GetThreadDesktop(int threadId);
167
+ [DllImport("user32.dll", SetLastError = true)]
168
+ public static extern bool GetUserObjectInformation(IntPtr handle, int index, StringBuilder info, int length, out int lengthNeeded);
169
+ [DllImport("user32.dll", SetLastError = true)]
170
+ public static extern int GetSystemMetrics(int index);
171
+ [DllImport("kernel32.dll")]
172
+ public static extern int GetCurrentThreadId();
173
+ }
174
+ }
175
+ '
176
+ }
177
+
178
+ $ENUM_CURRENT_SETTINGS = -1
179
+ $DM_PELSWIDTH = 0x00080000
180
+ $DM_PELSHEIGHT = 0x00100000
181
+ $CDS_UPDATEREGISTRY = 0x01
182
+ $DISP_CHANGE_SUCCESSFUL = 0
183
+ $MONITOR_DEFAULTTOPRIMARY = 1
184
+ $UOI_NAME = 2
185
+ $USER_OBJECT_NAME_CHARS = 256
186
+ $SM_CXSCREEN = 0
187
+ $SM_CYSCREEN = 1
188
+ $SM_CMONITORS = 80
189
+
190
+ # Printed beside every result and trusted by nobody -- see this file's header on why `203` came back from
191
+ # the failing calls and the succeeding one alike.
192
+ function Get-LastWin32Error {
193
+ [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
194
+ }
195
+
196
+ # The NAME of a window station or desktop HANDLE. Reported rather than inferred: "is this the interactive
197
+ # desktop" is one of the three cases the old one-line failure could not answer, and `WinSta0` + `Default`
198
+ # is what rules it out.
199
+ function Get-UserObjectName {
200
+ param([IntPtr] $Handle)
201
+ if ($Handle -eq [IntPtr]::Zero) {
202
+ return "(null handle, Win32 $(Get-LastWin32Error))"
203
+ }
204
+ $name = New-Object System.Text.StringBuilder $USER_OBJECT_NAME_CHARS
205
+ $needed = 0
206
+ if ([A11yDisplay.NativeMethods]::GetUserObjectInformation(
207
+ $Handle, $UOI_NAME, $name, $name.Capacity, [ref] $needed)) {
208
+ return $name.ToString()
209
+ }
210
+ return "(unreadable, Win32 $(Get-LastWin32Error))"
211
+ }
212
+
213
+ # THE FIX, and the one line in this script that had to change. `$null` was MEANT to say "whatever the
214
+ # system considers the default display device" and never did: PowerShell binds it to a `string` parameter
215
+ # as `[string]::Empty`, so the call asked for a device NAMED `""`, which any Windows box refuses. See this
216
+ # file's header for the measurement. MonitorFromPoint(0,0, DEFAULTTOPRIMARY) names the primary monitor and
217
+ # GetMonitorInfoW reads its `\\.\DISPLAYn` back, which is a question that has an answer.
218
+ #
219
+ # Returns $null on failure rather than throwing, so the caller can print the whole context block before
220
+ # giving up -- a refusal that reports nothing is what made the previous failure unrepeatable.
221
+ #
222
+ # IT PRINTS NOTHING, and says why it failed through `[ref] $Reason` instead. In PowerShell everything a
223
+ # function writes JOINS ITS RETURN VALUE, so an earlier draft's `Write-Output` on the failure path came
224
+ # back to the caller AS the device name: `$deviceName` held the diagnostic sentence, `-not $deviceName`
225
+ # was false, the script never took its own failure branch, and the message was never printed either. A
226
+ # function that both reports and returns can do neither here.
227
+ function Get-PrimaryDisplayName {
228
+ param([ref] $Reason)
229
+ $origin = New-Object A11yDisplay.POINT
230
+ $origin.x = 0
231
+ $origin.y = 0
232
+ $monitor = [A11yDisplay.NativeMethods]::MonitorFromPoint($origin, $MONITOR_DEFAULTTOPRIMARY)
233
+ if ($monitor -eq [IntPtr]::Zero) {
234
+ $Reason.Value = "MonitorFromPoint found no primary monitor, last Win32 $(Get-LastWin32Error) -- unreliable"
235
+ return $null
236
+ }
237
+ $info = New-Object A11yDisplay.MONITORINFOEX
238
+ $info.cbSize = [System.Runtime.InteropServices.Marshal]::SizeOf($info)
239
+ if (-not [A11yDisplay.NativeMethods]::GetMonitorInfo($monitor, [ref] $info)) {
240
+ $Reason.Value = "GetMonitorInfo refused for monitor $monitor, last Win32 $(Get-LastWin32Error) -- unreliable"
241
+ return $null
242
+ }
243
+ return $info.szDevice
244
+ }
245
+
246
+ # One pass of `EnumDisplayDevices` over `$Device`. A NULL device enumerates the ADAPTERS on this desktop;
247
+ # a `\\.\DISPLAYn` name enumerates the MONITORS on that adapter.
248
+ #
249
+ # The formatted lines come back through `[ref] $Lines` and THE COUNT IS THE RETURN VALUE, an int. Both
250
+ # halves of that shape are reviewer-2's blocker on #1968 at `a58c7e44`, and the defect it replaces was
251
+ # worse than the report: this function used to `return ,$lines` while the caller counted
252
+ # `@(Get-EnumeratedDevices ...)`. PowerShell's unary comma deliberately emits the collection as ONE
253
+ # pipeline object, `@()` collects that single object, and `.Count` was therefore **1 for every
254
+ # population**. Measured under pwsh 7.6.6, not reasoned: 0 lines -> 1, 1 line -> 1, 3 lines -> 1. So the
255
+ # caller's `if ($adapters.Count -gt 0)` was taken on every run, the named same-API control NEVER
256
+ # executed, and an empty enumeration reported `enumerated 1 adapter(s)` -- a count of the wrapper,
257
+ # printed as a count of devices, in exactly the case the control exists to separate.
258
+ #
259
+ # A COUNT MUST NOT CROSS A FUNCTION BOUNDARY AS A COLLECTION. An int cannot be unrolled, re-wrapped or
260
+ # collected, so the shape that produced this defect cannot be spelled here again; the lines take the
261
+ # `[ref]` route `Get-PrimaryDisplayName` already uses for the same PowerShell reason. `display-mode.test.ts`
262
+ # runs this pair under a real PowerShell against a stubbed API and reads the count off the output, because
263
+ # no assertion over the script's TEXT can tell "the control is written" from "the control ran".
264
+ #
265
+ # `$Device` is deliberately untyped, AND THAT IS NOT ENOUGH ON ITS OWN. `[string] $Device` would coerce
266
+ # `$null` to the empty string at the PowerShell parameter -- but so does the .NET method binding one line
267
+ # further down, whatever this parameter is declared as, which is the trap this file's header now records.
268
+ # The caller therefore hands in `[NullString]::Value` rather than `$null`, and it survives an untyped
269
+ # parameter unchanged (measured, same probe). `""` is a different argument to this API than NULL, and
270
+ # telling the two apart is the one distinction this whole script turns on.
271
+ function Get-EnumeratedDevices {
272
+ param($Device, [string] $Label, [ref] $Lines)
273
+ $found = @()
274
+ $info = New-Object A11yDisplay.DISPLAY_DEVICE
275
+ $index = 0
276
+ while ($true) {
277
+ $info.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($info)
278
+ if (-not [A11yDisplay.NativeMethods]::EnumDisplayDevices($Device, $index, [ref] $info, 0)) {
279
+ break
280
+ }
281
+ $flags = '0x{0:X8}' -f $info.StateFlags
282
+ $found += "context: $Label[$index] name='$($info.DeviceName)' adapter='$($info.DeviceString)' stateFlags=$flags (cb=$($info.cb))"
283
+ $index++
284
+ }
285
+ $Lines.Value = $found
286
+ return $found.Count
287
+ }
288
+
289
+ # Every display DEVICE this process can enumerate -- and, when that comes back empty, THE SAME API ASKED
290
+ # AGAIN WITH A NAME. Kept although nothing depends on it any more: it is the block that measures whether
291
+ # a nameless call works on this fleet, a question no reading has actually answered yet (every earlier one
292
+ # asked about `""`), and a future run where it starts answering is a real change this is the only thing
293
+ # that would show.
294
+ #
295
+ # THE CONTROL IS THE POINT, and it is reviewer-2's blocker on #1968. `EnumDisplayDevices(NULL, 0)`
296
+ # returning False at index 0 is TWO findings wearing one face -- "this desktop has no display adapters"
297
+ # and "this function refuses a nameless call here" (a thing no reading has shown yet -- the
298
+ # EnumDisplaySettings refusals were all EMPTY-named, see this file's header) -- and an
299
+ # empty population that cannot tell them apart is the shape this repository refuses everywhere else.
300
+ # `Screen.AllScreens` and the named `EnumDisplaySettingsW` are a DIFFERENT API answering a different
301
+ # question, so neither settles it. The control therefore has to be THIS function with a device name: if it
302
+ # answers, the empty NULL population is about the ARGUMENT; if it refuses too, the reading is about the
303
+ # function instead, and the script says so rather than claiming the stronger of the two.
304
+ #
305
+ # The two counts below are the ints `Get-EnumeratedDevices` returns, cast with `[int]` at the assignment:
306
+ # that cast is the thing that makes a stray extra output object fail LOUDLY here rather than turn the
307
+ # count into a collection again, which is how the count stopped being the count in the first place.
308
+ function Write-DisplayDevices {
309
+ param($PrimaryDevice)
310
+ $adapterLines = @()
311
+ [int] $adapters = Get-EnumeratedDevices -Device ([NullString]::Value) -Label 'device' -Lines ([ref] $adapterLines)
312
+ $adapterLines | ForEach-Object { Write-Output $_ }
313
+ if ($adapters -gt 0) {
314
+ Write-Output "context: EnumDisplayDevices(NULL) enumerated $adapters adapter(s)"
315
+ return
316
+ }
317
+ if (-not $PrimaryDevice) {
318
+ Write-Output ("context: EnumDisplayDevices(NULL, 0) enumerated NOTHING and no NAMED control could be run" +
319
+ " (no primary device name), so this reading cannot tell an absent display from a refused nameless call")
320
+ return
321
+ }
322
+ $monitorLines = @()
323
+ [int] $monitors = Get-EnumeratedDevices -Device $PrimaryDevice -Label 'monitor' -Lines ([ref] $monitorLines)
324
+ $monitorLines | ForEach-Object { Write-Output $_ }
325
+ if ($monitors -gt 0) {
326
+ Write-Output ("context: EnumDisplayDevices(NULL, 0) enumerated NOTHING while the same call NAMED" +
327
+ " '$PrimaryDevice' enumerated $monitors monitor(s) -- the function answers here, and it is" +
328
+ " the NULL device that is refused")
329
+ } else {
330
+ Write-Output ("context: EnumDisplayDevices enumerated NOTHING for NULL AND for the NAMED control" +
331
+ " '$PrimaryDevice' -- the control refused too, so this says nothing about the NULL device in" +
332
+ " particular (last Win32 $(Get-LastWin32Error) -- unreliable)")
333
+ }
334
+ }
335
+
336
+ # What the SESSION's desktop itself thinks it has, read through a call that takes no device name at all.
337
+ # This is the line that separates "this desktop has no display" from "this desktop has one and the call
338
+ # refused": on 2026-09-22 it reported a real screen while the enumeration reported none, and that
339
+ # disagreement is what pointed at the argument rather than at the environment.
340
+ function Write-ScreenMetrics {
341
+ $width = [A11yDisplay.NativeMethods]::GetSystemMetrics($SM_CXSCREEN)
342
+ $height = [A11yDisplay.NativeMethods]::GetSystemMetrics($SM_CYSCREEN)
343
+ $monitors = [A11yDisplay.NativeMethods]::GetSystemMetrics($SM_CMONITORS)
344
+ Write-Output "context: GetSystemMetrics screen=${width}x${height} monitors=$monitors"
345
+ }
346
+
347
+ function Write-DisplayContext {
348
+ param($PrimaryDevice)
349
+ $process = [System.Diagnostics.Process]::GetCurrentProcess()
350
+ $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
351
+ Write-Output "context: sessionId=$($process.SessionId) pid=$($process.Id) user=$($identity.Name)"
352
+ $station = [A11yDisplay.NativeMethods]::GetProcessWindowStation()
353
+ $threadId = [A11yDisplay.NativeMethods]::GetCurrentThreadId()
354
+ $desktop = [A11yDisplay.NativeMethods]::GetThreadDesktop($threadId)
355
+ Write-Output "context: windowStation='$(Get-UserObjectName $station)' desktop='$(Get-UserObjectName $desktop)'"
356
+ Write-ScreenMetrics
357
+ Write-DisplayDevices -PrimaryDevice $PrimaryDevice
358
+ }
359
+
360
+ # The name is resolved BEFORE the context block rather than after it, because the block's device
361
+ # enumeration needs it for its control. Resolution prints nothing of its own, so a failure here still
362
+ # reports through the same block below, in the same order, on a failing run and a passing one alike.
363
+ $nameRefusal = $null
364
+ $deviceName = Get-PrimaryDisplayName -Reason ([ref] $nameRefusal)
365
+ if ($nameRefusal) {
366
+ Write-Output "context: $nameRefusal"
367
+ }
368
+ Write-DisplayContext -PrimaryDevice $deviceName
369
+
370
+ if (-not $deviceName) {
371
+ Write-Output 'no primary display device could be named, so there is nothing to set the mode on'
372
+ exit 1
373
+ }
374
+ Write-Output "context: primary display device='$deviceName' wanted=$Width x $Height"
375
+
376
+ $mode = New-Object A11yDisplay.DEVMODE
377
+ $mode.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($mode)
378
+ if (-not [A11yDisplay.NativeMethods]::EnumDisplaySettings($deviceName, $ENUM_CURRENT_SETTINGS, [ref] $mode)) {
379
+ Write-Output "EnumDisplaySettings could not read the current display mode of '$deviceName' (dmSize $($mode.dmSize), last Win32 $(Get-LastWin32Error) -- unreliable)"
380
+ exit 1
381
+ }
382
+
383
+ Write-Output "context: current mode reads $($mode.dmPelsWidth) x $($mode.dmPelsHeight) on '$deviceName'"
384
+
385
+ if ($mode.dmPelsWidth -eq $Width -and $mode.dmPelsHeight -eq $Height) {
386
+ Write-Output "already $Width x $Height"
387
+ exit 0
388
+ }
389
+
390
+ $mode.dmPelsWidth = $Width
391
+ $mode.dmPelsHeight = $Height
392
+ $mode.dmFields = $DM_PELSWIDTH -bor $DM_PELSHEIGHT
393
+ $result = [A11yDisplay.NativeMethods]::ChangeDisplaySettingsEx(
394
+ $deviceName, [ref] $mode, [IntPtr]::Zero, $CDS_UPDATEREGISTRY, [IntPtr]::Zero)
395
+ if ($result -ne $DISP_CHANGE_SUCCESSFUL) {
396
+ Write-Output "ChangeDisplaySettingsEx returned $result (wanted $DISP_CHANGE_SUCCESSFUL) setting $Width x $Height on '$deviceName'"
397
+ exit 1
398
+ }
399
+
400
+ # PROVE IT, IN THE SAME PROCESS THAT JUST WROTE IT -- the same "read back or it didn't happen" rule
401
+ # policy.yml's own trailing verify task follows.
402
+ $after = New-Object A11yDisplay.DEVMODE
403
+ $after.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($after)
404
+ [void][A11yDisplay.NativeMethods]::EnumDisplaySettings($deviceName, $ENUM_CURRENT_SETTINGS, [ref] $after)
405
+ if ($after.dmPelsWidth -ne $Width -or $after.dmPelsHeight -ne $Height) {
406
+ Write-Output "ChangeDisplaySettingsEx reported success but the mode reads $($after.dmPelsWidth) x $($after.dmPelsHeight) back, not $Width x $Height"
407
+ exit 1
408
+ }
409
+
410
+ Write-Output "set to $Width x $Height"
411
+ exit 0
@@ -0,0 +1,184 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Write provision-revision.txt -- the stamp that keys the capture cache on what provisioning ACTUALLY did.
4
+
5
+ .DESCRIPTION
6
+ ONE definition, called by both provisioning paths. It used to live inline at the end of
7
+ provision-nvda-worker.ps1, which meant the Ansible role could not stamp at all: a box provisioned or
8
+ RE-provisioned through `provision-role.yml` kept whatever stamp its first boot happened to write.
9
+
10
+ Measured consequence on this fleet -- four boxes, functionally identical, reporting four different
11
+ revisions purely because each first-booted at a different commit during one afternoon:
12
+
13
+ .107 = 5d4b877-c9d43b025889b77c
14
+ .59 = ae888e3-c9d43b025889b77c
15
+ .175 = b8b5af4-c9d43b025889b77c
16
+ .224 = 449a20c-c9d43b025889b77c
17
+
18
+ `fleet:status` correctly called that INCONSISTENT, and no amount of re-provisioning could converge it,
19
+ because nothing on the Ansible path wrote the file.
20
+
21
+ ## What is hashed, and why the role's defaults are in the list
22
+
23
+ The stamp exists so two guests with different NVDA/Edge configuration cannot share a cache entry. When
24
+ it was written, provisioning was the PowerShell script, so hashing three of its files described the
25
+ environment completely. It no longer does: `roles/worker/defaults/main.yml` is where the Edge policies,
26
+ the NIC power values and the quiet-desktop settings are now DEFINED, and the role applies them.
27
+
28
+ Leaving it out would have been the cosmetic version of this fix. Adding `ComponentUpdatesEnabled` to
29
+ that file changes what Edge does during a capture and would NOT have moved the stamp -- so captures
30
+ taken either side of it would have shared a cache key while describing different browsers. That is
31
+ precisely the failure the stamp is here to prevent.
32
+
33
+ Task files are deliberately NOT hashed. They change when the same settings are applied a different way
34
+ -- batching four registry writes into one call is a refactor, not an environment change -- and keying on
35
+ them would churn the cache for reasons that never reach a capture.
36
+
37
+ .NOTES
38
+ Missing files THROW. The previous version filtered them out with `Where-Object { $_ }`, so when the repo
39
+ was restructured into packages/ all three paths vanished at once and `$combined` silently fell back to
40
+ 'unknown' -- the stamp stopped describing anything and varied only by git SHA. A check that discards its
41
+ own inputs cannot report that it found nothing.
42
+ #>
43
+ param(
44
+ [Parameter(Mandatory = $true)][string] $RepoPath
45
+ )
46
+
47
+ Set-StrictMode -Version Latest
48
+ $ErrorActionPreference = 'Stop'
49
+
50
+ # TWO OF THE FIVE PATHS ARE NOT WRITTEN HERE (ADR 0039 item 6d, #3397). Where the worker layer lives is
51
+ # declared in `packages/control/layers.json`, and what its launchers reach outside it in the layer's own
52
+ # `src/launcher-reach.cmd`, which `run-capture-check.cmd` `call`s. Both are READ, so a path cannot change in
53
+ # the launcher and stay behind in the stamp. A declaration that is absent or does not say THROWS: the stamp
54
+ # must not fall back to a literal, because a literal that was right yesterday is the stamp describing less
55
+ # than it claims. The VALUES are unchanged, so `provisionRevision` is too -- `layer-launchers.test.ts`
56
+ # asserts that this row's own diff names none of the five files.
57
+ function Get-LayerFile {
58
+ param([Parameter(Mandatory = $true)][string] $Layer, [Parameter(Mandatory = $true)][string] $Relative)
59
+ $manifest = Get-Content -Raw -LiteralPath (Join-Path $RepoPath 'packages\control\layers.json') | ConvertFrom-Json
60
+ "$($manifest.layers.$Layer.path)/$Relative"
61
+ }
62
+
63
+ function Get-DeclaredReach {
64
+ param([Parameter(Mandatory = $true)][string] $Name)
65
+ $declaration = Join-Path $RepoPath ((Get-LayerFile -Layer 'nvda-worker' -Relative 'src/launcher-reach.cmd') -replace '/', '\')
66
+ if (-not (Test-Path -LiteralPath $declaration)) {
67
+ throw "provision stamp: the launcher declaration $declaration is missing. Refusing to guess where the launchers reach."
68
+ }
69
+ $line = Select-String -LiteralPath $declaration -Pattern "^set `"$Name=(.+)`"\s*$" | Select-Object -First 1
70
+ if (-not $line) { throw "provision stamp: $declaration does not declare $Name." }
71
+ $line.Matches[0].Groups[1].Value -replace '\\', '/'
72
+ }
73
+
74
+ $RUN_SERVER = Get-LayerFile -Layer 'nvda-worker' -Relative 'src/run-server.cmd'
75
+ $FOREGROUND_LOCK = Get-DeclaredReach -Name 'FLT'
76
+
77
+ # The single definition. Explicit paths rather than a filename search, because `main.yml` is not unique
78
+ # in this repo and a search would silently pick the wrong one.
79
+ $ENVIRONMENT_FILES = @(
80
+ 'packages/worker-fleet/src/provisioning/provision-nvda-worker.ps1'
81
+ $RUN_SERVER
82
+ $FOREGROUND_LOCK
83
+ 'packages/control/ansible/roles/worker/defaults/main.yml'
84
+ # SPEECH VIEWER, added 2026-09-05, and it is the same shape as `apply-foreground-lock-timeout.ps1`
85
+ # above: a script that carries its own hardcoded ENVIRONMENT value rather than reading one from
86
+ # `defaults/main.yml`. Hashing the script is how such a value gets into the stamp at all.
87
+ #
88
+ # WHY IT MATTERS MORE THAN THE OTHERS. This module's own header calls `showSpeechViewerAtStartup`
89
+ # "the highest-value setting on a worker and the one that fails most quietly": with it ON, every
90
+ # interaction probe returns "NVDA Speech Viewer" instead of the page's response. A whole corpus
91
+ # would be captured, complete and wrong.
92
+ #
93
+ # AND IT IS A REMEDY THAT REACHED ONE PATH AND NOT THE OTHER -- this repo's most expensive shape,
94
+ # found INSIDE the mechanism built to catch it. `provision-nvda-worker.ps1` (hashed since forever)
95
+ # inlines the identical `showSpeechViewerAtStartup = True -> False` fix at its Step 5, and throws if
96
+ # it did not take. The Ansible path does the same work in this module and was hashed by nothing, so
97
+ # a regression there -- or a fresh box shipping guidepup's default ON -- would leave the stamp
98
+ # unmoved, `fleet-consistency` reading the fleet as fine, and every capture silently unusable.
99
+ #
100
+ # Checked against every other cache-key field before adding it, rather than assumed: not in
101
+ # `CAPTURE_SETTINGS` (which has one entry, `speech.reportLanguage`, and `getSettings()` does not
102
+ # touch this section of `nvda.ini`), not in `/health`, and unrelated to `browserVersion`,
103
+ # `guidepupVersion`, `windowsVersion`, `architecture` or `captureProtocol`. Only `/diagnostics`
104
+ # reports it, on demand, outside `environmentKey()` entirely. Nothing else catches it.
105
+ #
106
+ # THE STRONGER FIX IS RECORDED AND NOT DONE HERE: reading the LIVE value out of `nvda.ini` onto
107
+ # `/health` and folding it into `screenReaderSettings` would verify the setting is IN EFFECT rather
108
+ # than that provisioning INTENDED it -- this file's own distinction, and the better one. It changes
109
+ # `environmentKey()`, so it must ride a deliberate cache-key change rather than land mid-recapture.
110
+ 'packages/control/ansible/collections/ansible_collections/a11y/worker/plugins/modules/a11y_speech_viewer.ps1'
111
+ )
112
+
113
+ # LINE ENDINGS ARE NORMALISED BEFORE HASHING, AND THE PREVIOUS VERSION'S OMISSION WAS A LATENT SPLIT.
114
+ #
115
+ # This used to be `Get-FileHash`, which hashes the file's BYTES -- so the stamp depended on how git had
116
+ # checked the file out. Windows git converts to CRLF by default and nothing in this repo pins that: there
117
+ # is no `.gitattributes`. Measured 2026-09-04, the same four blobs at one commit:
118
+ #
119
+ # CRLF dbb7d33409a9341d <- what the whole fleet reported
120
+ # LF 1052b80ca42398c7 <- what a checkout with core.autocrlf=false would report
121
+ #
122
+ # `provisionRevision` is a capture cache key AND a `fleet-consistency` MUST_MATCH field, compared for
123
+ # EQUALITY. So one box cloned with a different `core.autocrlf` would read INCONSISTENT for ever, block
124
+ # every capture run, and -- this is the part that makes it worth fixing rather than documenting --
125
+ # RE-PROVISIONING COULD NOT CONVERGE IT, because the box would faithfully recompute the same wrong hash.
126
+ # It is the one drift in this fleet with no remedy at the operator's disposal.
127
+ #
128
+ # The header above says what the stamp is for: "two guests with different NVDA/Edge configuration cannot
129
+ # share a cache entry". A line ending is not configuration, so hashing it is not merely risky, it is
130
+ # measuring the wrong thing -- the same argument that already excludes the git SHA and the task files.
131
+ #
132
+ # Normalising costs one stamp move, paid once, and it was bundled with a move happening anyway.
133
+ $hashes = foreach ($relative in $ENVIRONMENT_FILES) {
134
+ $full = Join-Path $RepoPath ($relative -replace '/', '\')
135
+ if (-not (Test-Path -LiteralPath $full)) {
136
+ throw "provision stamp: $relative is missing under $RepoPath. Refusing to write a stamp that " +
137
+ "describes less than it claims -- fix the path or the checkout."
138
+ }
139
+ # ReadAllText also drops a UTF-8 BOM, which is the same class of difference arriving by another door:
140
+ # a file re-saved by an editor that adds one would otherwise move the stamp for no reason.
141
+ $text = [IO.File]::ReadAllText($full) -replace "`r`n", "`n"
142
+ $sha = [Security.Cryptography.SHA256]::Create()
143
+ ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))) -replace '-', '')
144
+ }
145
+
146
+ $bytes = [Text.Encoding]::UTF8.GetBytes(($hashes -join ''))
147
+ $combined = ([BitConverter]::ToString(
148
+ [Security.Cryptography.SHA256]::Create().ComputeHash($bytes)) -replace '-', '').Substring(0, 16).ToLower()
149
+
150
+ # THE COMMIT IS RECORDED, AND IS DELIBERATELY NOT PART OF THE STAMP.
151
+ #
152
+ # It used to be: the stamp read `<short sha>-<content hash>`. That made an ordinary commit -- one touching
153
+ # none of the four files above, none of provisioning, nothing a capture can observe -- change a CAPTURE
154
+ # CACHE KEY and a `fleet-consistency` MUST_MATCH field. The consequences are not theoretical:
155
+ #
156
+ # - re-provisioning a fleet after any commit invalidates every cached capture, so a full recapture
157
+ # (~6 h) is the price of a documentation change that happened to land first;
158
+ # - a box provisioned even minutes after its peers reads INCONSISTENT and blocks every capture run;
159
+ # - measured 2026-08-25: a11y-worker-6 failed provisioning and could not simply be re-run, because by
160
+ # then HEAD had moved and re-running would have stamped it differently from the four boxes that had
161
+ # just succeeded. Four healthy machines faced re-provisioning for a SHA.
162
+ #
163
+ # This repo already made exactly this decision one field over, and wrote down why: `workerCode` is
164
+ # deliberately OUTSIDE the capture cache key because "that hash changes when a comment changes, and
165
+ # invalidating the WHOLE corpus over a reworded comment is how a cache becomes something people turn
166
+ # off" (capture-cache.mjs). A git SHA changes for strictly more reasons than a code hash does.
167
+ #
168
+ # The content hash already answers the question the stamp exists to answer -- do two guests have different
169
+ # NVDA/Edge configuration? -- and it answers it by describing the configuration rather than by naming a
170
+ # moment. That is also why task files are excluded above: "batching four registry writes into one call is a
171
+ # refactor, not an environment change". The SHA contradicted that reasoning; removing it restores it.
172
+ #
173
+ # The commit is still worth having, for diagnosis rather than for keying, so it goes in its own file next
174
+ # to the stamp. `provisionRevision` is compared for EQUALITY and never parsed (capture-cache.mjs,
175
+ # fleet-consistency.mjs), so nothing downstream reads the two halves apart.
176
+ $gitSha = try { (git -C $RepoPath rev-parse --short HEAD 2>$null) } catch { $null }
177
+ $commitPath = Join-Path $RepoPath 'provision-commit.txt'
178
+ $(if ($gitSha) { $gitSha } else { 'nogit' }) | Out-File -LiteralPath $commitPath -Encoding ascii -NoNewline
179
+
180
+ $stamp = $combined
181
+
182
+ $stampPath = Join-Path $RepoPath 'provision-revision.txt'
183
+ $stamp | Out-File -LiteralPath $stampPath -Encoding ascii -NoNewline
184
+ $stamp