@loucompanion/forge-bridge 0.1.3-dev.d1ca67076aa9 → 0.1.3-dev.e7b62d8fdd33
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/README.md +127 -0
- package/dist/bridge/bridge.base.d.ts +8 -1
- package/dist/bridge/bridge.service.d.ts +93 -3
- package/dist/bridge/bridge.service.js +1358 -47
- package/dist/bridge/bridge.types.d.ts +202 -1
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +47 -1
- package/dist/cli/cli.service.d.ts +45 -2
- package/dist/cli/cli.service.js +378 -26
- package/dist/cli/local-config.d.ts +7 -0
- package/dist/cli/local-config.js +51 -3
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
- package/dist/runtime/attempt-process-supervisor.js +386 -0
- package/dist/runtime/docker-engine-probe.d.ts +83 -0
- package/dist/runtime/docker-engine-probe.js +300 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +46 -0
- package/dist/runtime/host-listener-inspector.js +349 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +197 -0
- package/dist/runtime/host-runtime-lease.js +2203 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/memory-capacity.d.ts +68 -0
- package/dist/runtime/memory-capacity.js +121 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +284 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +84 -0
- package/dist/sandbox/preflight.js +521 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +121 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +93 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +21 -0
- package/dist/session/managed-attempt-tools.js +912 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +335 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +256 -26
- package/dist/session/phase-completion.d.ts +12 -2
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +51 -10
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +24 -8
- package/dist/session/phase-completion.runtime.d.ts +13 -1
- package/dist/session/phase-completion.runtime.js +189 -8
- package/dist/session/phase-completion.schemas.js +117 -4
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +153 -22
- package/dist/session/pre-publication-validation.d.ts +46 -0
- package/dist/session/pre-publication-validation.js +208 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +68 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +26 -0
- package/dist/session/provider-cli/provider-cli.process.js +474 -23
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +16 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +224 -24
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +26 -3
- package/dist/session/provider-cli/provider-cli.service.js +650 -108
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +91 -16
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +38 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +549 -58
- package/dist/session/session.types.d.ts +124 -1
- package/dist/session/session.types.js +231 -4
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
- package/dist/shared/process/dotnet-toolchain.js +179 -0
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +59 -0
- package/dist/shared/process/host-process-supervisor.js +330 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +16 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +39 -1
- package/dist/worktree/dependency-warmer.js +351 -8
- package/dist/worktree/index.d.ts +2 -1
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +13 -2
- package/dist/worktree/worktree.service.js +238 -47
- package/dist/worktree/worktree.types.d.ts +5 -1
- package/package.json +8 -4
- package/runtime/windows-job-supervisor.ps1 +444 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BoundedCommandRunner } from "../sandbox/command-runner.js";
|
|
4
|
+
/**
|
|
5
|
+
* Probes ordinary Docker-daemon access for host-run Testcontainers and project
|
|
6
|
+
* services. This is deliberately independent from the stricter Forge sandbox
|
|
7
|
+
* preflight: a usable Docker Desktop engine may satisfy docker_engine even
|
|
8
|
+
* when it is not approved to host an agent sandbox.
|
|
9
|
+
*/
|
|
10
|
+
export class DockerEngineProbe {
|
|
11
|
+
commands;
|
|
12
|
+
cacheMs;
|
|
13
|
+
cached = null;
|
|
14
|
+
sampledAtMs = 0;
|
|
15
|
+
inFlight = null;
|
|
16
|
+
consecutiveFailures = 0;
|
|
17
|
+
lastRecoveryAttemptAtMs = null;
|
|
18
|
+
docker;
|
|
19
|
+
context;
|
|
20
|
+
platform;
|
|
21
|
+
dockerHost;
|
|
22
|
+
now;
|
|
23
|
+
wait;
|
|
24
|
+
recovery;
|
|
25
|
+
recoveryExecutor;
|
|
26
|
+
onStatus;
|
|
27
|
+
constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 15_000) {
|
|
28
|
+
this.commands = commands;
|
|
29
|
+
this.cacheMs = cacheMs;
|
|
30
|
+
this.docker = options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
|
|
31
|
+
this.context = options.dockerContext?.trim() || process.env.DOCKER_CONTEXT?.trim() || "default";
|
|
32
|
+
this.platform = options.platform ?? process.platform;
|
|
33
|
+
this.dockerHost = options.dockerHost ?? process.env.DOCKER_HOST?.trim() ?? "";
|
|
34
|
+
this.now = options.now ?? Date.now;
|
|
35
|
+
this.wait = options.wait ?? (delayMs => new Promise(resolve => setTimeout(resolve, delayMs)));
|
|
36
|
+
this.recovery = {
|
|
37
|
+
enabled: options.recovery?.enabled ?? false,
|
|
38
|
+
failureThreshold: positiveInteger(options.recovery?.failureThreshold, 3),
|
|
39
|
+
confirmationDelayMs: positiveInteger(options.recovery?.confirmationDelayMs, 30_000),
|
|
40
|
+
cooldownMs: positiveInteger(options.recovery?.cooldownMs, 10 * 60_000),
|
|
41
|
+
startupTimeoutMs: positiveInteger(options.recovery?.startupTimeoutMs, 2 * 60_000),
|
|
42
|
+
pollIntervalMs: positiveInteger(options.recovery?.pollIntervalMs, 2_000),
|
|
43
|
+
canRecover: options.recovery?.canRecover,
|
|
44
|
+
};
|
|
45
|
+
this.recoveryExecutor = options.recoveryExecutor ?? new WindowsDockerDesktopRecoveryExecutor(this.platform, commands);
|
|
46
|
+
this.onStatus = options.onStatus ?? (() => undefined);
|
|
47
|
+
}
|
|
48
|
+
current() {
|
|
49
|
+
return this.cached;
|
|
50
|
+
}
|
|
51
|
+
async refresh(force = false) {
|
|
52
|
+
if (!force && this.cached && this.now() - this.sampledAtMs < this.cacheMs) {
|
|
53
|
+
return this.cached;
|
|
54
|
+
}
|
|
55
|
+
if (this.inFlight)
|
|
56
|
+
return await this.inFlight;
|
|
57
|
+
const operation = this.inspectSafely()
|
|
58
|
+
.then(result => this.recoverIfEligible(result))
|
|
59
|
+
.then((result) => {
|
|
60
|
+
this.cached = result;
|
|
61
|
+
this.sampledAtMs = this.now();
|
|
62
|
+
return result;
|
|
63
|
+
})
|
|
64
|
+
.finally(() => {
|
|
65
|
+
if (this.inFlight === operation)
|
|
66
|
+
this.inFlight = null;
|
|
67
|
+
});
|
|
68
|
+
this.inFlight = operation;
|
|
69
|
+
return await operation;
|
|
70
|
+
}
|
|
71
|
+
async inspectSafely() {
|
|
72
|
+
return await this.inspect().catch((error) => unavailable(this.context, "docker_engine_unreachable", error instanceof Error ? error.message : String(error)));
|
|
73
|
+
}
|
|
74
|
+
async recoverIfEligible(snapshot) {
|
|
75
|
+
if (snapshot.available) {
|
|
76
|
+
this.consecutiveFailures = 0;
|
|
77
|
+
return snapshot;
|
|
78
|
+
}
|
|
79
|
+
this.consecutiveFailures += 1;
|
|
80
|
+
if (!this.recovery.enabled
|
|
81
|
+
|| !this.isLocalDockerDesktopContext()
|
|
82
|
+
|| this.consecutiveFailures < this.recovery.failureThreshold
|
|
83
|
+
|| this.recovery.canRecover?.() === false) {
|
|
84
|
+
return snapshot;
|
|
85
|
+
}
|
|
86
|
+
const now = this.now();
|
|
87
|
+
if (this.lastRecoveryAttemptAtMs !== null
|
|
88
|
+
&& now - this.lastRecoveryAttemptAtMs < this.recovery.cooldownMs) {
|
|
89
|
+
return snapshot;
|
|
90
|
+
}
|
|
91
|
+
this.onStatus(`Docker engine remained unavailable for ${this.consecutiveFailures} consecutive probes; waiting for one fresh confirmation before recovery.`);
|
|
92
|
+
await this.wait(this.recovery.confirmationDelayMs);
|
|
93
|
+
const confirmed = await this.inspectSafely();
|
|
94
|
+
if (confirmed.available) {
|
|
95
|
+
this.consecutiveFailures = 0;
|
|
96
|
+
this.onStatus("Docker engine recovered during the confirmation window; skipped Docker Desktop recovery.");
|
|
97
|
+
return confirmed;
|
|
98
|
+
}
|
|
99
|
+
if (this.recovery.canRecover?.() === false) {
|
|
100
|
+
this.onStatus("Docker Desktop recovery was deferred because runner work became active during confirmation.");
|
|
101
|
+
return confirmed;
|
|
102
|
+
}
|
|
103
|
+
this.consecutiveFailures += 1;
|
|
104
|
+
this.lastRecoveryAttemptAtMs = this.now();
|
|
105
|
+
this.onStatus(`Docker engine remained unavailable after confirmation; starting one bounded Docker Desktop recovery.`);
|
|
106
|
+
const recovery = await this.recoveryExecutor.restart().catch((error) => ({
|
|
107
|
+
started: false,
|
|
108
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
109
|
+
}));
|
|
110
|
+
if (!recovery.started) {
|
|
111
|
+
const reason = recovery.reason?.trim() || "Docker Desktop recovery could not be started.";
|
|
112
|
+
this.onStatus(`Docker Desktop recovery failed to start: ${singleLine(reason)}`);
|
|
113
|
+
return unavailable(this.context, "docker_engine_recovery_failed", reason);
|
|
114
|
+
}
|
|
115
|
+
const deadline = this.now() + this.recovery.startupTimeoutMs;
|
|
116
|
+
while (this.now() < deadline) {
|
|
117
|
+
await this.wait(Math.min(this.recovery.pollIntervalMs, Math.max(1, deadline - this.now())));
|
|
118
|
+
const verified = await this.inspectSafely();
|
|
119
|
+
if (verified.available) {
|
|
120
|
+
this.consecutiveFailures = 0;
|
|
121
|
+
this.onStatus("Docker Desktop recovery completed and the engine passed a fresh daemon probe.");
|
|
122
|
+
return verified;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
this.onStatus("Docker Desktop recovery timed out before the engine became reachable; cooldown is active.");
|
|
126
|
+
return unavailable(this.context, "docker_engine_recovery_timeout", "Docker Desktop restart was requested, but the engine did not become reachable before the recovery timeout.");
|
|
127
|
+
}
|
|
128
|
+
isLocalDockerDesktopContext() {
|
|
129
|
+
return this.platform === "win32"
|
|
130
|
+
&& !this.dockerHost
|
|
131
|
+
&& (this.context === "default" || this.context === "desktop-linux");
|
|
132
|
+
}
|
|
133
|
+
async inspect() {
|
|
134
|
+
const dockerArgs = this.context === "default" ? [] : ["--context", this.context];
|
|
135
|
+
const result = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
|
|
136
|
+
if (result.exitCode !== 0) {
|
|
137
|
+
return unavailable(this.context, "docker_engine_unreachable", result.stderr || result.stdout || "Docker daemon is unreachable.");
|
|
138
|
+
}
|
|
139
|
+
let info;
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
142
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
143
|
+
throw new Error("not an object");
|
|
144
|
+
info = parsed;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return unavailable(this.context, "docker_engine_invalid_response", "Docker returned an invalid info document.");
|
|
148
|
+
}
|
|
149
|
+
const version = clean(info.ServerVersion);
|
|
150
|
+
return {
|
|
151
|
+
available: true,
|
|
152
|
+
sampledAt: new Date().toISOString(),
|
|
153
|
+
runtimeVersion: version ? `docker/${version}` : "docker/unknown",
|
|
154
|
+
operatingSystem: clean(info.OSType)?.toLowerCase() ?? null,
|
|
155
|
+
context: this.context,
|
|
156
|
+
unavailableCode: null,
|
|
157
|
+
unavailableReason: null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Restarts only the local Docker Desktop installation. It never targets a
|
|
163
|
+
* remote daemon and is used only behind the explicit bridge recovery policy.
|
|
164
|
+
*/
|
|
165
|
+
export class WindowsDockerDesktopRecoveryExecutor {
|
|
166
|
+
platform;
|
|
167
|
+
commands;
|
|
168
|
+
programFiles;
|
|
169
|
+
wait;
|
|
170
|
+
constructor(platform = process.platform, commands = new BoundedCommandRunner(), programFiles = process.env.ProgramFiles ?? "C:\\Program Files", wait = delayMs => new Promise(resolve => setTimeout(resolve, delayMs))) {
|
|
171
|
+
this.platform = platform;
|
|
172
|
+
this.commands = commands;
|
|
173
|
+
this.programFiles = programFiles;
|
|
174
|
+
this.wait = wait;
|
|
175
|
+
}
|
|
176
|
+
async restart() {
|
|
177
|
+
if (this.platform !== "win32") {
|
|
178
|
+
return { started: false, reason: "Automatic Docker Desktop recovery is supported only on Windows." };
|
|
179
|
+
}
|
|
180
|
+
const installRoot = path.join(this.programFiles, "Docker", "Docker");
|
|
181
|
+
const desktopExecutable = path.join(installRoot, "Docker Desktop.exe");
|
|
182
|
+
if (!fs.existsSync(desktopExecutable)) {
|
|
183
|
+
return { started: false, reason: "Docker Desktop executable was not found in the standard installation directory." };
|
|
184
|
+
}
|
|
185
|
+
const processState = await this.readProcessState();
|
|
186
|
+
if (processState === "unknown") {
|
|
187
|
+
return { started: false, reason: "Docker Desktop process state could not be determined safely." };
|
|
188
|
+
}
|
|
189
|
+
if (processState === "stopped") {
|
|
190
|
+
if (!await this.cleanupOrphanedCompanions()) {
|
|
191
|
+
return { started: false, reason: "Orphaned Docker Desktop companion processes could not be verified and cleaned safely." };
|
|
192
|
+
}
|
|
193
|
+
await this.wait(1_000);
|
|
194
|
+
return await this.startDesktop(desktopExecutable);
|
|
195
|
+
}
|
|
196
|
+
const dockerCli = path.join(installRoot, "DockerCli.exe");
|
|
197
|
+
if (fs.existsSync(dockerCli)) {
|
|
198
|
+
await this.commands.run(dockerCli, ["-Shutdown"], {
|
|
199
|
+
timeoutMs: 15_000,
|
|
200
|
+
maxOutputBytes: 64 * 1024,
|
|
201
|
+
}).catch(() => undefined);
|
|
202
|
+
}
|
|
203
|
+
const stopScript = [
|
|
204
|
+
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
205
|
+
"Get-Process | Where-Object { $_.ProcessName -in @('Docker Desktop', 'com.docker.backend') } | Stop-Process -Force",
|
|
206
|
+
].join("; ");
|
|
207
|
+
await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", stopScript], {
|
|
208
|
+
timeoutMs: 15_000,
|
|
209
|
+
maxOutputBytes: 64 * 1024,
|
|
210
|
+
}).catch(() => undefined);
|
|
211
|
+
await this.commands.run("wsl.exe", ["--terminate", "docker-desktop"], {
|
|
212
|
+
timeoutMs: 15_000,
|
|
213
|
+
maxOutputBytes: 64 * 1024,
|
|
214
|
+
}).catch(() => undefined);
|
|
215
|
+
await this.wait(5_000);
|
|
216
|
+
const stoppedState = await this.readProcessState();
|
|
217
|
+
if (stoppedState !== "stopped") {
|
|
218
|
+
return {
|
|
219
|
+
started: false,
|
|
220
|
+
reason: stoppedState === "running"
|
|
221
|
+
? "Docker Desktop processes did not stop before relaunch."
|
|
222
|
+
: "Docker Desktop shutdown state could not be verified safely.",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (!await this.cleanupOrphanedCompanions()) {
|
|
226
|
+
return { started: false, reason: "Orphaned Docker Desktop companion processes could not be verified and cleaned safely." };
|
|
227
|
+
}
|
|
228
|
+
await this.wait(1_000);
|
|
229
|
+
return await this.startDesktop(desktopExecutable);
|
|
230
|
+
}
|
|
231
|
+
async readProcessState() {
|
|
232
|
+
const script = [
|
|
233
|
+
"$ErrorActionPreference = 'Stop'",
|
|
234
|
+
"$processes = @(Get-Process -Name 'Docker Desktop','com.docker.backend' -ErrorAction SilentlyContinue)",
|
|
235
|
+
"if ($processes.Count -gt 0) { Write-Output 'running' } else { Write-Output 'stopped' }",
|
|
236
|
+
].join("; ");
|
|
237
|
+
const result = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
|
|
238
|
+
if (!result || result.exitCode !== 0)
|
|
239
|
+
return "unknown";
|
|
240
|
+
const state = result.stdout.trim().toLowerCase();
|
|
241
|
+
return state === "running" || state === "stopped" ? state : "unknown";
|
|
242
|
+
}
|
|
243
|
+
async cleanupOrphanedCompanions() {
|
|
244
|
+
const dockerRoot = path.join(this.programFiles, "Docker").replaceAll("'", "''");
|
|
245
|
+
const script = [
|
|
246
|
+
"$ErrorActionPreference = 'Stop'",
|
|
247
|
+
`$dockerRoot = [System.IO.Path]::GetFullPath('${dockerRoot}').TrimEnd('\\') + '\\'`,
|
|
248
|
+
"$names = @('com.docker.build.exe','docker-sandbox.exe','docker-agent.exe','local-sandboxesd.exe','docker-sandboxd.exe')",
|
|
249
|
+
"$candidates = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -in $names -and $_.ExecutablePath -and [System.IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($dockerRoot, [System.StringComparison]::OrdinalIgnoreCase) })",
|
|
250
|
+
"foreach ($candidate in $candidates) { $parent = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $candidate.ParentProcessId) -ErrorAction SilentlyContinue; $parentIsDockerBackend = $null -ne $parent -and $parent.Name -eq 'com.docker.backend.exe' -and $parent.ExecutablePath -and [System.IO.Path]::GetFullPath($parent.ExecutablePath).StartsWith($dockerRoot, [System.StringComparison]::OrdinalIgnoreCase); if (-not $parentIsDockerBackend) { Stop-Process -Id $candidate.ProcessId -Force -ErrorAction Stop } }",
|
|
251
|
+
].join("; ");
|
|
252
|
+
const result = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 10_000, maxOutputBytes: 32 * 1024 }).catch(() => null);
|
|
253
|
+
return result?.exitCode === 0;
|
|
254
|
+
}
|
|
255
|
+
async startDesktop(desktopExecutable) {
|
|
256
|
+
const quotedDesktop = desktopExecutable.replaceAll("'", "''");
|
|
257
|
+
let lastReason = "Docker Desktop failed to start.";
|
|
258
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
259
|
+
const startScript = [
|
|
260
|
+
"$ErrorActionPreference = 'Stop'",
|
|
261
|
+
`$desktop = Start-Process -FilePath '${quotedDesktop}' -WindowStyle Hidden -PassThru`,
|
|
262
|
+
"Start-Sleep -Seconds 3",
|
|
263
|
+
"if ($desktop.HasExited) { Write-Error ('Docker Desktop exited during startup with code ' + $desktop.ExitCode); exit 1 }",
|
|
264
|
+
].join("; ");
|
|
265
|
+
const start = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", startScript], { timeoutMs: 15_000, maxOutputBytes: 64 * 1024 }).catch((error) => ({
|
|
266
|
+
exitCode: 1,
|
|
267
|
+
stdout: "",
|
|
268
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
269
|
+
}));
|
|
270
|
+
if (start.exitCode === 0)
|
|
271
|
+
return { started: true };
|
|
272
|
+
lastReason = start.stderr || start.stdout || lastReason;
|
|
273
|
+
if (attempt < 3)
|
|
274
|
+
await this.wait(5_000);
|
|
275
|
+
}
|
|
276
|
+
return { started: false, reason: singleLine(lastReason) };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function unavailable(context, code, reason) {
|
|
280
|
+
return {
|
|
281
|
+
available: false,
|
|
282
|
+
sampledAt: new Date().toISOString(),
|
|
283
|
+
runtimeVersion: null,
|
|
284
|
+
operatingSystem: null,
|
|
285
|
+
context,
|
|
286
|
+
unavailableCode: code,
|
|
287
|
+
unavailableReason: singleLine(reason),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function clean(value) {
|
|
291
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
292
|
+
}
|
|
293
|
+
function singleLine(value) {
|
|
294
|
+
return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
|
|
295
|
+
}
|
|
296
|
+
function positiveInteger(value, fallback) {
|
|
297
|
+
if (value === undefined || !Number.isFinite(value) || value <= 0)
|
|
298
|
+
return fallback;
|
|
299
|
+
return Math.floor(value);
|
|
300
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface GeneratedOutputResetResult {
|
|
2
|
+
readonly quarantineDirectory: string;
|
|
3
|
+
readonly quarantinedPaths: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Moves only known, untracked test-output directories out of a worktree before
|
|
7
|
+
* a managed attempt starts. The quarantine lives in the attempt-owned output
|
|
8
|
+
* directory, so cleanup is recoverable and cannot cross the attempt boundary.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resetGeneratedOutputs(worktreePath: string, testOutputDirectory: string): Promise<GeneratedOutputResetResult>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const generatedDirectoryNames = new Set(["TestResults", "test-results", "playwright-report"]);
|
|
8
|
+
const skippedDirectoryNames = new Set([
|
|
9
|
+
".git",
|
|
10
|
+
".forge",
|
|
11
|
+
".idea",
|
|
12
|
+
".next",
|
|
13
|
+
".pnpm",
|
|
14
|
+
".vs",
|
|
15
|
+
".vscode",
|
|
16
|
+
"bin",
|
|
17
|
+
"build",
|
|
18
|
+
"coverage",
|
|
19
|
+
"dist",
|
|
20
|
+
"node_modules",
|
|
21
|
+
"obj",
|
|
22
|
+
"target",
|
|
23
|
+
"vendor",
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Moves only known, untracked test-output directories out of a worktree before
|
|
27
|
+
* a managed attempt starts. The quarantine lives in the attempt-owned output
|
|
28
|
+
* directory, so cleanup is recoverable and cannot cross the attempt boundary.
|
|
29
|
+
*/
|
|
30
|
+
export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
|
|
31
|
+
const worktreeRoot = await fs.realpath(path.resolve(worktreePath));
|
|
32
|
+
const outputRoot = await fs.realpath(path.resolve(testOutputDirectory));
|
|
33
|
+
if (isContainedPath(worktreeRoot, outputRoot)) {
|
|
34
|
+
throw new Error("generated_output_reset_invalid: attempt output must be outside the source worktree.");
|
|
35
|
+
}
|
|
36
|
+
const candidates = await findGeneratedDirectories(worktreeRoot);
|
|
37
|
+
const quarantineDirectory = path.join(outputRoot, "preflight-reset");
|
|
38
|
+
const quarantinedPaths = [];
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
const relativePath = path.relative(worktreeRoot, candidate);
|
|
41
|
+
if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
42
|
+
throw new Error("generated_output_reset_invalid: generated output escaped the source worktree.");
|
|
43
|
+
}
|
|
44
|
+
await assertUntrackedGeneratedDirectory(worktreeRoot, relativePath);
|
|
45
|
+
await fs.mkdir(quarantineDirectory, { recursive: true, mode: 0o700 });
|
|
46
|
+
const digest = createHash("sha256").update(relativePath).digest("hex").slice(0, 12);
|
|
47
|
+
const destination = path.join(quarantineDirectory, `${digest}-${randomUUID()}-${path.basename(candidate)}`);
|
|
48
|
+
try {
|
|
49
|
+
await fs.rename(candidate, destination);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const code = error.code ?? "unknown";
|
|
53
|
+
throw new Error(`generated_output_reset_failed: could not quarantine ${relativePath} (${code}).`, { cause: error });
|
|
54
|
+
}
|
|
55
|
+
quarantinedPaths.push(relativePath.split(path.sep).join("/"));
|
|
56
|
+
}
|
|
57
|
+
if (quarantinedPaths.length > 0) {
|
|
58
|
+
await fs.writeFile(path.join(quarantineDirectory, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, worktreePath: worktreeRoot, quarantinedPaths }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
59
|
+
}
|
|
60
|
+
return { quarantineDirectory, quarantinedPaths };
|
|
61
|
+
}
|
|
62
|
+
async function findGeneratedDirectories(root) {
|
|
63
|
+
const candidates = [];
|
|
64
|
+
const pending = [root];
|
|
65
|
+
while (pending.length > 0) {
|
|
66
|
+
const current = pending.pop();
|
|
67
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
70
|
+
continue;
|
|
71
|
+
if (skippedDirectoryNames.has(entry.name))
|
|
72
|
+
continue;
|
|
73
|
+
const candidate = path.join(current, entry.name);
|
|
74
|
+
if (generatedDirectoryNames.has(entry.name)) {
|
|
75
|
+
const realCandidate = await fs.realpath(candidate);
|
|
76
|
+
if (!isContainedPath(root, realCandidate)) {
|
|
77
|
+
throw new Error("generated_output_reset_invalid: generated output resolves outside the source worktree.");
|
|
78
|
+
}
|
|
79
|
+
candidates.push(realCandidate);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
pending.push(candidate);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return candidates.sort((left, right) => left.localeCompare(right));
|
|
86
|
+
}
|
|
87
|
+
async function assertUntrackedGeneratedDirectory(worktreeRoot, relativePath) {
|
|
88
|
+
const pathspec = relativePath.split(path.sep).join("/");
|
|
89
|
+
let stdout;
|
|
90
|
+
try {
|
|
91
|
+
({ stdout } = await execFileAsync("git", ["-C", worktreeRoot, "ls-files", "-z", "--", pathspec], { encoding: "utf8", windowsHide: true, maxBuffer: 4 * 1024 * 1024 }));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new Error(`generated_output_reset_unverified: git could not verify ${pathspec}.`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
if (stdout.length > 0) {
|
|
97
|
+
throw new Error(`generated_output_reset_unsafe: ${pathspec} contains tracked files and was not changed.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function isContainedPath(root, candidate) {
|
|
101
|
+
const relative = path.relative(root, candidate);
|
|
102
|
+
return relative.length > 0 && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
103
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AttemptProcessChildHandle } from "./attempt-process-supervisor.js";
|
|
2
|
+
import type { HostPortLeaseOwner, HostPortReservation } from "./host-port-broker.js";
|
|
3
|
+
import type { HostListenerOwnership } from "./node-host-runtime-adapter.js";
|
|
4
|
+
export interface HostInspectionCommandResult {
|
|
5
|
+
readonly exitCode: number;
|
|
6
|
+
readonly stdout: string;
|
|
7
|
+
readonly stderr: string;
|
|
8
|
+
readonly errorCode?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HostInspectionCommandRunner {
|
|
11
|
+
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
12
|
+
}
|
|
13
|
+
/** Proves that a loopback listener belongs to one exact supervised child. */
|
|
14
|
+
export declare class HostListenerInspector {
|
|
15
|
+
private readonly commands;
|
|
16
|
+
private readonly platform;
|
|
17
|
+
constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform);
|
|
18
|
+
inspect(child: AttemptProcessChildHandle, reservation: HostPortReservation, owner: HostPortLeaseOwner): Promise<HostListenerOwnership>;
|
|
19
|
+
/** Returns loopback TCP ports opened by one exact supervised child process tree. */
|
|
20
|
+
inspectOwnedLoopbackListeners(child: AttemptProcessChildHandle, owner: HostPortLeaseOwner): Promise<readonly number[]>;
|
|
21
|
+
/** Snapshots loopback TCP ports that predate a supervised child launch. */
|
|
22
|
+
inspectLoopbackListeners(): Promise<readonly number[]>;
|
|
23
|
+
private inspectWindows;
|
|
24
|
+
private inspectAllWindows;
|
|
25
|
+
private inspectUnix;
|
|
26
|
+
private inspectAllUnix;
|
|
27
|
+
}
|
|
28
|
+
export declare function parseWindowsLoopbackListeners(output: string): readonly {
|
|
29
|
+
pid: number;
|
|
30
|
+
port: number;
|
|
31
|
+
}[];
|
|
32
|
+
export declare function parseLsofLoopbackListeners(output: string): readonly {
|
|
33
|
+
pid: number;
|
|
34
|
+
port: number;
|
|
35
|
+
}[];
|
|
36
|
+
export declare function parseSsLoopbackListeners(output: string): readonly {
|
|
37
|
+
pid: number;
|
|
38
|
+
port: number;
|
|
39
|
+
}[];
|
|
40
|
+
export declare class ExecFileInspectionCommandRunner implements HostInspectionCommandRunner {
|
|
41
|
+
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
42
|
+
}
|
|
43
|
+
export declare function parseWindowsListenerPids(output: string, port: number): readonly number[];
|
|
44
|
+
export declare function parseLsofPids(output: string): readonly number[];
|
|
45
|
+
export declare function parseSsPids(output: string, port: number): readonly number[];
|
|
46
|
+
export declare function parseProcessGroups(output: string): ReadonlyMap<number, number>;
|