@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.04a1e5b9e484
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 +88 -2
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +4 -1
- package/dist/bridge/bridge.service.d.ts +72 -3
- package/dist/bridge/bridge.service.js +1563 -152
- package/dist/bridge/bridge.types.d.ts +46 -1
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- 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 +1 -0
- package/dist/cleanup/cleanup.service.js +70 -9
- package/dist/cli/cli.help.js +2 -2
- package/dist/cli/cli.service.d.ts +14 -6
- package/dist/cli/cli.service.js +75 -16
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +4 -0
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-database-broker.d.ts +89 -0
- package/dist/runtime/attempt-database-broker.js +390 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
- package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +16 -0
- package/dist/runtime/attempt-process-supervisor.js +94 -23
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +62 -8
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +192 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +8 -1
- package/dist/runtime/host-listener-inspector.js +121 -60
- package/dist/runtime/host-runtime-contract.d.ts +12 -1
- package/dist/runtime/host-runtime-contract.js +168 -55
- package/dist/runtime/host-runtime-lease.d.ts +92 -8
- package/dist/runtime/host-runtime-lease.js +1141 -114
- package/dist/runtime/host-runtime-promotion.d.ts +12 -3
- package/dist/runtime/host-runtime-promotion.js +59 -12
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
- package/dist/runtime/node-host-runtime-adapter.js +29 -11
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.js +4 -1
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +31 -1
- package/dist/sandbox/preflight.js +237 -51
- package/dist/sandbox/repository-lock.d.ts +2 -1
- package/dist/sandbox/repository-lock.js +19 -9
- package/dist/sandbox/runtime.d.ts +6 -1
- package/dist/sandbox/runtime.js +137 -107
- package/dist/sandbox/sandbox.types.d.ts +7 -0
- package/dist/sandbox/workspace.service.js +2 -0
- package/dist/session/managed-attempt-tools.d.ts +17 -1
- package/dist/session/managed-attempt-tools.js +489 -34
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +106 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +224 -22
- package/dist/session/phase-completion.d.ts +11 -1
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +30 -7
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.js +17 -18
- package/dist/session/phase-completion.runtime.d.ts +3 -1
- package/dist/session/phase-completion.runtime.js +126 -17
- package/dist/session/phase-completion.schemas.js +49 -2
- package/dist/session/phase-completion.validation.d.ts +1 -0
- package/dist/session/phase-completion.validation.js +111 -21
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +661 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
- package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
- package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
- package/dist/session/provider-cli/provider-cli.process.js +422 -22
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
- 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 +14 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +54 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
- package/dist/session/provider-cli/provider-cli.service.js +773 -69
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
- package/dist/session/provider-cli/provider-output-state.js +361 -17
- 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.js +434 -9
- package/dist/session/session.service.js +145 -23
- package/dist/session/session.types.d.ts +147 -2
- package/dist/session/session.types.js +587 -7
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +18 -0
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- package/dist/shared/process/host-process-recovery.js +3 -2
- package/dist/shared/process/host-process-supervisor.d.ts +2 -0
- package/dist/shared/process/host-process-supervisor.js +60 -11
- package/dist/shared/process/process.d.ts +2 -0
- package/dist/shared/process/process.js +12 -7
- package/dist/shared/provider-capabilities.d.ts +3 -0
- package/dist/shared/provider-capabilities.js +85 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +39 -3
- package/dist/shared/time/monotonic.d.ts +1 -0
- package/dist/shared/time/monotonic.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/dependency-warmer.d.ts +41 -9
- package/dist/worktree/dependency-warmer.js +497 -22
- package/dist/worktree/index.d.ts +1 -1
- package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -0
- package/dist/worktree/worktree.service.d.ts +7 -0
- package/dist/worktree/worktree.service.js +253 -21
- package/dist/worktree/worktree.types.d.ts +7 -1
- package/package.json +7 -5
- package/runtime/windows-job-supervisor.ps1 +380 -14
|
@@ -2,26 +2,45 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { formatBytes } from "../shared/format/format-bytes.js";
|
|
5
6
|
import { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
|
|
7
|
+
import { RecoveryStabilizedMemory, resolveAdaptiveMemoryReserveBytes } from "../runtime/memory-capacity.js";
|
|
6
8
|
import { BoundedCommandRunner } from "./command-runner.js";
|
|
7
9
|
const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
|
|
8
|
-
const defaultHostMemoryReserveBytes = 2 * 1024 ** 3;
|
|
9
10
|
export class DockerPreflight {
|
|
10
11
|
commands;
|
|
11
12
|
cacheMs;
|
|
12
13
|
generation = 0;
|
|
13
14
|
cached = null;
|
|
14
|
-
|
|
15
|
+
fullPreflightAt = 0;
|
|
16
|
+
staticPreflight = null;
|
|
17
|
+
inspectionInFlight = null;
|
|
18
|
+
lastCapacitySample = null;
|
|
15
19
|
options;
|
|
16
20
|
docker;
|
|
17
21
|
ownership = new DockerContextOwnershipLock();
|
|
18
22
|
diskPressureLatched = false;
|
|
23
|
+
memoryAvailability = new RecoveryStabilizedMemory();
|
|
19
24
|
constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 30_000) {
|
|
20
25
|
this.commands = commands;
|
|
21
26
|
this.cacheMs = cacheMs;
|
|
22
27
|
this.options = typeof options === "string" ? { dockerExecutable: options } : options;
|
|
23
28
|
this.docker = this.options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
|
|
24
29
|
}
|
|
30
|
+
unavailable(code, reason, features) {
|
|
31
|
+
const totalMemoryBytes = os.totalmem();
|
|
32
|
+
this.memoryAvailability.observe(0);
|
|
33
|
+
return unavailable(this.generation, code, reason, this.memoryReserveBytes(totalMemoryBytes), this.defaultAdmissionMemoryBytes(), totalMemoryBytes, features);
|
|
34
|
+
}
|
|
35
|
+
memoryReserveBytes(totalMemoryBytes) {
|
|
36
|
+
return resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, this.options.hostMemoryReserveBytes);
|
|
37
|
+
}
|
|
38
|
+
defaultAdmissionMemoryBytes() {
|
|
39
|
+
const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
|
|
40
|
+
const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
|
|
41
|
+
const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
|
|
42
|
+
return workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
|
|
43
|
+
}
|
|
25
44
|
current() {
|
|
26
45
|
return this.cached;
|
|
27
46
|
}
|
|
@@ -37,25 +56,97 @@ export class DockerPreflight {
|
|
|
37
56
|
await this.ownership.release();
|
|
38
57
|
}
|
|
39
58
|
async run(force = false) {
|
|
40
|
-
|
|
59
|
+
const inFlight = this.inspectionInFlight;
|
|
60
|
+
if (inFlight) {
|
|
61
|
+
const result = await inFlight.promise;
|
|
62
|
+
if (inFlight.kind === "full" || (!force && Date.now() - this.fullPreflightAt < this.cacheMs)) {
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
return await this.run(force);
|
|
66
|
+
}
|
|
67
|
+
if (!force && this.cached && Date.now() - this.fullPreflightAt < this.cacheMs)
|
|
41
68
|
return this.cached;
|
|
69
|
+
return await this.startInspection("full", () => this.inspect().catch((error) => this.unavailable("docker_unreachable", error instanceof Error ? error.message : String(error))), true);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Refreshes only the admission inputs that change while the daemon's static
|
|
73
|
+
* capability snapshot remains valid. Heartbeats use this path so periodic
|
|
74
|
+
* liveness reporting does not wake Docker/WSL to rerun feature probes.
|
|
75
|
+
*/
|
|
76
|
+
async refreshCapacity() {
|
|
77
|
+
if (this.inspectionInFlight) {
|
|
78
|
+
return await this.inspectionInFlight.promise;
|
|
79
|
+
}
|
|
80
|
+
const staticPreflight = this.staticPreflight;
|
|
81
|
+
if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
|
|
82
|
+
return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
|
|
83
|
+
}
|
|
84
|
+
return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* When the cached static preflight remains usable, produces a capacity sample
|
|
88
|
+
* that began after this admission request. An older inspection is awaited,
|
|
89
|
+
* not preempted or reused. Later admission callers may share the next sample
|
|
90
|
+
* only when it began after they entered this method. Without a usable static
|
|
91
|
+
* snapshot, the existing fail-closed result is returned without probing.
|
|
92
|
+
*/
|
|
93
|
+
async refreshCapacityForAdmission() {
|
|
94
|
+
const generationAtRequest = this.generation;
|
|
95
|
+
while (true) {
|
|
96
|
+
const inFlight = this.inspectionInFlight;
|
|
97
|
+
if (inFlight) {
|
|
98
|
+
if (inFlight.kind === "capacity" && inFlight.generation > generationAtRequest) {
|
|
99
|
+
return await inFlight.promise;
|
|
100
|
+
}
|
|
101
|
+
await inFlight.promise;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const lastCapacitySample = this.lastCapacitySample;
|
|
105
|
+
if (lastCapacitySample
|
|
106
|
+
&& lastCapacitySample.generation > generationAtRequest
|
|
107
|
+
&& lastCapacitySample.generation === this.generation
|
|
108
|
+
&& lastCapacitySample.result === this.cached) {
|
|
109
|
+
return lastCapacitySample.result;
|
|
110
|
+
}
|
|
111
|
+
const staticPreflight = this.staticPreflight;
|
|
112
|
+
if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
|
|
113
|
+
return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
|
|
114
|
+
}
|
|
115
|
+
return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
startInspection(kind, inspect, full) {
|
|
42
119
|
this.generation += 1;
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
120
|
+
const generation = this.generation;
|
|
121
|
+
const operation = Promise.resolve()
|
|
122
|
+
.then(inspect)
|
|
123
|
+
.then((result) => {
|
|
124
|
+
this.cached = result;
|
|
125
|
+
if (kind === "capacity")
|
|
126
|
+
this.lastCapacitySample = { generation, result };
|
|
127
|
+
if (full)
|
|
128
|
+
this.fullPreflightAt = Date.now();
|
|
129
|
+
return result;
|
|
130
|
+
})
|
|
131
|
+
.finally(() => {
|
|
132
|
+
if (this.inspectionInFlight?.promise === operation) {
|
|
133
|
+
this.inspectionInFlight = null;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
this.inspectionInFlight = { kind, generation, promise: operation };
|
|
137
|
+
return operation;
|
|
47
138
|
}
|
|
48
139
|
async inspect() {
|
|
49
140
|
if (!new Set(["linux", "darwin", "win32"]).has(process.platform)) {
|
|
50
|
-
return unavailable(
|
|
141
|
+
return this.unavailable("platform_unsupported", `Docker sandboxes are not supported on ${process.platform}.`);
|
|
51
142
|
}
|
|
52
143
|
const dockerArgs = this.options.dockerContext ? ["--context", this.options.dockerContext] : [];
|
|
53
144
|
const infoResult = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
|
|
54
145
|
if (infoResult.exitCode !== 0)
|
|
55
|
-
return unavailable(
|
|
146
|
+
return this.unavailable("docker_unreachable", singleLine(infoResult.stderr || infoResult.stdout || "Docker daemon is unreachable."));
|
|
56
147
|
const info = parseInfo(infoResult.stdout);
|
|
57
148
|
if (String(info.OSType ?? "").toLowerCase() !== "linux")
|
|
58
|
-
return unavailable(
|
|
149
|
+
return this.unavailable("docker_linux_required", "Forge sandbox images require a Linux Docker daemon.");
|
|
59
150
|
const securityOptions = strings(info.SecurityOptions).map((value) => value.toLowerCase());
|
|
60
151
|
const rootless = securityOptions.some((value) => value.includes("rootless"));
|
|
61
152
|
const userNamespaces = rootless || securityOptions.some((value) => value.includes("userns"));
|
|
@@ -67,7 +158,7 @@ export class DockerPreflight {
|
|
|
67
158
|
const desktopDeveloperMode = process.platform !== "linux" && process.env.FORGE_ALLOW_DOCKER_DESKTOP_SANDBOX === "true";
|
|
68
159
|
const requireRootless = this.options.requireRootless ?? process.env.FORGE_SANDBOX_REQUIRE_ROOTLESS !== "false";
|
|
69
160
|
if (!rootless && !desktopDeveloperMode && requireRootless) {
|
|
70
|
-
return unavailable(
|
|
161
|
+
return this.unavailable("docker_rootless_required", "Docker daemon is reachable but is not running rootless.", {
|
|
71
162
|
rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy: false, compose: false, internalNetwork: true, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
|
|
72
163
|
});
|
|
73
164
|
}
|
|
@@ -101,32 +192,82 @@ export class DockerPreflight {
|
|
|
101
192
|
const dockerRoot = process.platform === "linux" && typeof info.DockerRootDir === "string" && path.isAbsolute(info.DockerRootDir)
|
|
102
193
|
? info.DockerRootDir
|
|
103
194
|
: configuredStateRoot;
|
|
104
|
-
const
|
|
195
|
+
const dockerDesktop = process.platform !== "linux"
|
|
196
|
+
&& String(info.OperatingSystem ?? "").toLowerCase().includes("docker desktop");
|
|
197
|
+
const reportedDockerMemoryBytes = Number(info.MemTotal);
|
|
198
|
+
const features = { rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy, compose, internalNetwork: true, buildkit, imagePolicy, securityProfile, egressProxy };
|
|
199
|
+
const staticPreflight = {
|
|
200
|
+
dockerArgs,
|
|
201
|
+
dockerRoot,
|
|
202
|
+
dockerDesktop,
|
|
203
|
+
dockerMemoryBytes: Number.isFinite(reportedDockerMemoryBytes) && reportedDockerMemoryBytes > 0
|
|
204
|
+
? reportedDockerMemoryBytes
|
|
205
|
+
: null,
|
|
206
|
+
runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
|
|
207
|
+
features,
|
|
208
|
+
};
|
|
209
|
+
this.staticPreflight = staticPreflight;
|
|
210
|
+
return await this.inspectCapacity(staticPreflight);
|
|
211
|
+
}
|
|
212
|
+
async inspectCapacity(staticPreflight) {
|
|
213
|
+
const disk = await diskSpace(staticPreflight.dockerRoot);
|
|
105
214
|
const requiredWorkspaceBytes = this.options.minimumWorkspaceBytes ?? minimumWorkspaceBytes;
|
|
106
215
|
const highWatermark = Math.min(99, Math.max(1, this.options.diskHighWatermarkPercent ?? 90));
|
|
107
216
|
const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
|
|
108
217
|
const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
|
|
109
218
|
this.diskPressureLatched = diskLow;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
219
|
+
const requiredAvailableBytes = requiredAvailableBytesToClearDiskPressure(disk.total, requiredWorkspaceBytes, lowWatermark);
|
|
220
|
+
const rawHostAvailableMemoryBytesBefore = Math.max(0, os.freemem());
|
|
221
|
+
const hostTotalMemoryBytes = os.totalmem();
|
|
222
|
+
let totalMemoryBytes = hostTotalMemoryBytes;
|
|
223
|
+
// Native Docker shares the host capacity domain. Docker Desktop has a
|
|
224
|
+
// second VM/daemon domain, so neither domain may be treated as a substitute
|
|
225
|
+
// for the other when deciding whether new committed memory is safe.
|
|
226
|
+
let rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytesBefore;
|
|
227
|
+
let daemonMemorySampleFailure = null;
|
|
228
|
+
if (staticPreflight.dockerDesktop) {
|
|
229
|
+
rawDaemonAvailableMemoryBytes = null;
|
|
230
|
+
if (staticPreflight.dockerMemoryBytes) {
|
|
231
|
+
totalMemoryBytes = staticPreflight.dockerMemoryBytes;
|
|
232
|
+
const stats = await this.commands.run(this.docker, [...staticPreflight.dockerArgs, "stats", "--no-stream", "--format", "{{.MemUsage}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 }).catch(() => null);
|
|
233
|
+
if (stats?.exitCode === 0) {
|
|
234
|
+
let dockerUsedMemoryBytes = 0;
|
|
235
|
+
for (const line of stats.stdout.split(/\r?\n/gu).map(value => value.trim()).filter(Boolean)) {
|
|
236
|
+
const usage = tryParseDockerMemoryUsage(line);
|
|
237
|
+
if (usage === null) {
|
|
238
|
+
daemonMemorySampleFailure = "Docker returned an invalid memory-usage sample.";
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
dockerUsedMemoryBytes += usage;
|
|
242
|
+
}
|
|
243
|
+
if (daemonMemorySampleFailure === null) {
|
|
244
|
+
rawDaemonAvailableMemoryBytes = Math.max(0, staticPreflight.dockerMemoryBytes - dockerUsedMemoryBytes);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
daemonMemorySampleFailure = singleLine(stats?.stderr || stats?.stdout || "Docker Desktop memory capacity could not be sampled.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
daemonMemorySampleFailure = "Docker Desktop did not report a valid memory-capacity limit.";
|
|
125
253
|
}
|
|
126
254
|
}
|
|
255
|
+
const rawHostAvailableMemoryBytesAfter = Math.max(0, os.freemem());
|
|
256
|
+
const rawHostAvailableMemoryBytes = Math.min(rawHostAvailableMemoryBytesBefore, rawHostAvailableMemoryBytesAfter);
|
|
257
|
+
if (!staticPreflight.dockerDesktop) {
|
|
258
|
+
rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytes;
|
|
259
|
+
}
|
|
260
|
+
const sampledAt = new Date().toISOString();
|
|
261
|
+
const rawAvailableMemoryBytes = rawDaemonAvailableMemoryBytes === null
|
|
262
|
+
? 0
|
|
263
|
+
: Math.min(rawHostAvailableMemoryBytes, rawDaemonAvailableMemoryBytes);
|
|
264
|
+
const availableMemoryBytes = this.memoryAvailability.observe(rawAvailableMemoryBytes);
|
|
127
265
|
const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
|
|
128
266
|
const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
|
|
129
|
-
|
|
267
|
+
// The reserve protects the physical host and its control plane. Docker
|
|
268
|
+
// Desktop's VM total remains the independent daemon commitment ceiling,
|
|
269
|
+
// but must not shrink the reserve derived from the Windows host.
|
|
270
|
+
const memoryReserveBytes = this.memoryReserveBytes(hostTotalMemoryBytes);
|
|
130
271
|
const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
|
|
131
272
|
const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
|
|
132
273
|
const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
|
|
@@ -136,29 +277,38 @@ export class DockerPreflight {
|
|
|
136
277
|
const cpuSlots = Math.max(0, Math.floor(availableCpuMillis / sandboxCpuMillis));
|
|
137
278
|
const configuredMax = positiveInteger(this.options.maxConcurrentSandboxes, positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 3));
|
|
138
279
|
const availableForAdmissionBytes = Math.max(0, availableMemoryBytes - memoryReserveBytes);
|
|
139
|
-
const capacityLowCode = !diskLow &&
|
|
140
|
-
? "
|
|
141
|
-
: !diskLow &&
|
|
142
|
-
? "
|
|
143
|
-
:
|
|
280
|
+
const capacityLowCode = !diskLow && rawDaemonAvailableMemoryBytes === null
|
|
281
|
+
? "docker_memory_sample_unavailable"
|
|
282
|
+
: !diskLow && availableForAdmissionBytes < minimumAdmissionMemoryBytes
|
|
283
|
+
? "docker_memory_low"
|
|
284
|
+
: !diskLow && cpuSlots <= 0
|
|
285
|
+
? "docker_cpu_low"
|
|
286
|
+
: null;
|
|
144
287
|
const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots);
|
|
145
288
|
const unavailableCode = diskLow ? "docker_disk_low" : capacityLowCode;
|
|
146
289
|
const unavailableReason = diskLow
|
|
147
|
-
? `Docker workspace has
|
|
148
|
-
: unavailableCode === "
|
|
149
|
-
? `Docker
|
|
150
|
-
: unavailableCode === "
|
|
151
|
-
?
|
|
152
|
-
|
|
153
|
-
|
|
290
|
+
? `Docker workspace has ${formatBytes(disk.available)} available · ${formatBytes(requiredAvailableBytes)} required to clear disk pressure.`
|
|
291
|
+
: unavailableCode === "docker_memory_sample_unavailable"
|
|
292
|
+
? `Docker Desktop memory capacity is unavailable (${daemonMemorySampleFailure ?? "unknown sampler failure"}); the host reports ${formatBytes(rawHostAvailableMemoryBytes)} free, but Forge will not admit a sandbox until both memory domains are known.`
|
|
293
|
+
: unavailableCode === "docker_memory_low"
|
|
294
|
+
? `${staticPreflight.dockerDesktop
|
|
295
|
+
? `The host reports ${formatBytes(rawHostAvailableMemoryBytes)} free and the Docker domain reports ${formatBytes(rawDaemonAvailableMemoryBytes ?? 0)} free; Forge uses the conservative ${formatBytes(rawAvailableMemoryBytes)} bound`
|
|
296
|
+
: `Docker reports ${formatBytes(rawAvailableMemoryBytes)} free`} and reserves ${formatBytes(memoryReserveBytes)} for the host, leaving ${formatBytes(availableForAdmissionBytes)} for admission; the smallest supported sandbox requires ${formatBytes(minimumAdmissionMemoryBytes)}. Exact attempt memory is calculated dynamically from its worker profile, sidecars, and runtime helpers.`
|
|
297
|
+
: unavailableCode === "docker_cpu_low"
|
|
298
|
+
? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
|
|
299
|
+
: null;
|
|
154
300
|
const capacityAvailable = unavailableCode === null;
|
|
155
301
|
return {
|
|
156
302
|
available: capacityAvailable,
|
|
157
303
|
healthy: capacityAvailable,
|
|
158
304
|
generation: this.generation,
|
|
159
|
-
|
|
305
|
+
sampledAt,
|
|
306
|
+
runtimeVersion: staticPreflight.runtimeVersion,
|
|
160
307
|
maxConcurrentSandboxes,
|
|
161
308
|
totalMemoryBytes,
|
|
309
|
+
rawHostAvailableMemoryBytes,
|
|
310
|
+
rawDaemonAvailableMemoryBytes,
|
|
311
|
+
rawAvailableMemoryBytes,
|
|
162
312
|
availableMemoryBytes,
|
|
163
313
|
hostReserveBytes: memoryReserveBytes,
|
|
164
314
|
admissionMemoryBytes: sandboxMemoryBytes,
|
|
@@ -167,11 +317,29 @@ export class DockerPreflight {
|
|
|
167
317
|
diskLow,
|
|
168
318
|
unavailableCode,
|
|
169
319
|
unavailableReason,
|
|
170
|
-
features,
|
|
320
|
+
features: staticPreflight.features,
|
|
171
321
|
};
|
|
172
322
|
}
|
|
173
323
|
}
|
|
174
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Uses the same reviewed reserve vector as host-runtime admission. The input
|
|
326
|
+
* is always the physical host total; Docker Desktop's VM total is tracked as
|
|
327
|
+
* a separate daemon commitment domain.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, explicitBytes, environment = process.env) {
|
|
330
|
+
return resolveAdaptiveMemoryReserveBytes(totalMemoryBytes, {
|
|
331
|
+
explicitBytes,
|
|
332
|
+
floorBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_FLOOR_BYTES, 1024 ** 3),
|
|
333
|
+
ceilingBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_CEILING_BYTES, 4 * 1024 ** 3),
|
|
334
|
+
percent: positiveNumber(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_PERCENT, 12.5),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
const dockerMaintenanceUnavailableCodes = new Set([
|
|
338
|
+
"docker_disk_low",
|
|
339
|
+
"docker_memory_low",
|
|
340
|
+
"docker_memory_sample_unavailable",
|
|
341
|
+
"docker_cpu_low",
|
|
342
|
+
]);
|
|
175
343
|
export function canRunDockerMaintenance(preflight) {
|
|
176
344
|
return preflight.healthy || dockerMaintenanceUnavailableCodes.has(preflight.unavailableCode ?? "");
|
|
177
345
|
}
|
|
@@ -185,6 +353,12 @@ export function evaluateDiskPressure(availableBytes, totalBytes, minimumAvailabl
|
|
|
185
353
|
return true;
|
|
186
354
|
return pressureLatched && usedPercent > lowWatermarkPercent;
|
|
187
355
|
}
|
|
356
|
+
export function requiredAvailableBytesToClearDiskPressure(totalBytes, minimumAvailableBytes, lowWatermarkPercent) {
|
|
357
|
+
const watermarkBytes = totalBytes > 0
|
|
358
|
+
? Math.ceil(totalBytes * (1 - Math.min(100, Math.max(0, lowWatermarkPercent)) / 100))
|
|
359
|
+
: 0;
|
|
360
|
+
return Math.max(0, minimumAvailableBytes, watermarkBytes);
|
|
361
|
+
}
|
|
188
362
|
export function storagePolicyAvailable(storageQuota, environment = process.env) {
|
|
189
363
|
return storageQuota || environment.FORGE_SANDBOX_DEDICATED_DISK === "true";
|
|
190
364
|
}
|
|
@@ -245,19 +419,24 @@ function parseInfo(stdout) {
|
|
|
245
419
|
throw new Error("Docker returned an invalid info document.", { cause: error });
|
|
246
420
|
}
|
|
247
421
|
}
|
|
248
|
-
function unavailable(generation, code, reason, features = {
|
|
422
|
+
function unavailable(generation, code, reason, hostReserveBytes, admissionMemoryBytes, totalMemoryBytes, features = {
|
|
249
423
|
rootless: false, userNamespaces: false, cgroupV2: false, seccomp: false, lsm: false, storageQuota: false, storagePolicy: false, compose: false, internalNetwork: false, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
|
|
250
424
|
}) {
|
|
425
|
+
const rawHostAvailableMemoryBytes = Math.max(0, os.freemem());
|
|
251
426
|
return {
|
|
252
427
|
available: false,
|
|
253
428
|
healthy: false,
|
|
254
429
|
generation,
|
|
430
|
+
sampledAt: new Date().toISOString(),
|
|
255
431
|
runtimeVersion: null,
|
|
256
432
|
maxConcurrentSandboxes: 0,
|
|
257
|
-
totalMemoryBytes
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
433
|
+
totalMemoryBytes,
|
|
434
|
+
rawHostAvailableMemoryBytes,
|
|
435
|
+
rawDaemonAvailableMemoryBytes: null,
|
|
436
|
+
rawAvailableMemoryBytes: rawHostAvailableMemoryBytes,
|
|
437
|
+
availableMemoryBytes: rawHostAvailableMemoryBytes,
|
|
438
|
+
hostReserveBytes,
|
|
439
|
+
admissionMemoryBytes,
|
|
261
440
|
availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
|
|
262
441
|
availableWorkspaceBytes: 0,
|
|
263
442
|
diskLow: code === "docker_disk_low",
|
|
@@ -292,14 +471,21 @@ function nonNegativeInteger(value, fallback) {
|
|
|
292
471
|
const parsed = Number(value);
|
|
293
472
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
294
473
|
}
|
|
295
|
-
|
|
474
|
+
function positiveNumber(value, fallback) {
|
|
475
|
+
const parsed = Number(value);
|
|
476
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
477
|
+
}
|
|
478
|
+
function tryParseDockerMemoryUsage(value) {
|
|
296
479
|
const usage = value.split("/", 1)[0]?.trim().replaceAll(" ", "") ?? "";
|
|
297
480
|
const match = /^(\d+(?:\.\d+)?)(b|kib|mib|gib|tib)$/iu.exec(usage);
|
|
298
481
|
if (!match)
|
|
299
|
-
return
|
|
482
|
+
return null;
|
|
300
483
|
const amount = Number(match[1]);
|
|
301
484
|
const multiplier = { b: 1, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3, tib: 1024 ** 4 }[match[2].toLowerCase()];
|
|
302
|
-
return Number.isFinite(amount) ? Math.round(amount * multiplier) :
|
|
485
|
+
return Number.isFinite(amount) ? Math.round(amount * multiplier) : null;
|
|
486
|
+
}
|
|
487
|
+
export function parseDockerMemoryUsage(value) {
|
|
488
|
+
return tryParseDockerMemoryUsage(value) ?? 0;
|
|
303
489
|
}
|
|
304
490
|
async function readLock(lockPath) {
|
|
305
491
|
return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare class RepositoryCoordinatorLock {
|
|
2
2
|
private readonly stateRoot;
|
|
3
|
-
|
|
3
|
+
private readonly lockTimeoutMs;
|
|
4
|
+
constructor(stateRoot: string, lockTimeoutMs?: number);
|
|
4
5
|
withLock<T>(repoPath: string, action: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
5
6
|
}
|
|
@@ -2,24 +2,31 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { resolvePathIdentity } from "../shared/path/path.js";
|
|
6
|
+
import { ForgeFailureError } from "../session/session.types.js";
|
|
7
|
+
import { monotonicNowMs } from "../shared/time/monotonic.js";
|
|
5
8
|
const LOCK_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
6
9
|
const LOCK_STALE_AFTER_MS = 2 * 60_000;
|
|
7
10
|
export class RepositoryCoordinatorLock {
|
|
8
11
|
stateRoot;
|
|
9
|
-
|
|
12
|
+
lockTimeoutMs;
|
|
13
|
+
constructor(stateRoot, lockTimeoutMs = 2 * 60_000) {
|
|
10
14
|
this.stateRoot = stateRoot;
|
|
15
|
+
this.lockTimeoutMs = lockTimeoutMs;
|
|
11
16
|
}
|
|
12
17
|
async withLock(repoPath, action, signal) {
|
|
13
|
-
const resolved =
|
|
14
|
-
const
|
|
18
|
+
const resolved = await resolvePathIdentity(repoPath);
|
|
19
|
+
const keyInput = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
20
|
+
const key = createHash("sha256").update(keyInput).digest("hex");
|
|
15
21
|
const root = path.join(this.stateRoot, "repo-locks");
|
|
16
22
|
const target = path.join(root, `${key}.lock`);
|
|
17
23
|
const token = randomUUID();
|
|
18
24
|
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
19
|
-
const started =
|
|
25
|
+
const started = monotonicNowMs();
|
|
20
26
|
while (true) {
|
|
21
|
-
if (signal?.aborted)
|
|
22
|
-
throw new
|
|
27
|
+
if (signal?.aborted) {
|
|
28
|
+
throw new ForgeFailureError("sandbox_import_cancelled", "sandbox_import_cancelled: repository coordination was cancelled.", "sandbox");
|
|
29
|
+
}
|
|
23
30
|
try {
|
|
24
31
|
const handle = await fs.open(target, "wx", 0o600);
|
|
25
32
|
try {
|
|
@@ -38,8 +45,8 @@ export class RepositoryCoordinatorLock {
|
|
|
38
45
|
await fs.rm(target, { force: true });
|
|
39
46
|
continue;
|
|
40
47
|
}
|
|
41
|
-
if (
|
|
42
|
-
throw new
|
|
48
|
+
if (monotonicNowMs() - started >= this.lockTimeoutMs) {
|
|
49
|
+
throw new ForgeFailureError("sandbox_import_unavailable", "sandbox_import_unavailable: repository coordinator lock timed out.", "sandbox", { cause: error });
|
|
43
50
|
}
|
|
44
51
|
await abortableDelay(50, signal);
|
|
45
52
|
}
|
|
@@ -112,7 +119,10 @@ async function abortableDelay(delayMs, signal) {
|
|
|
112
119
|
return await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
113
120
|
await new Promise((resolve, reject) => {
|
|
114
121
|
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, delayMs);
|
|
115
|
-
const abort = () => {
|
|
122
|
+
const abort = () => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
reject(new ForgeFailureError("sandbox_import_cancelled", "sandbox_import_cancelled: repository coordination was cancelled.", "sandbox"));
|
|
125
|
+
};
|
|
116
126
|
signal.addEventListener("abort", abort, { once: true });
|
|
117
127
|
timer.unref?.();
|
|
118
128
|
});
|
|
@@ -37,6 +37,10 @@ export interface SandboxCleanupAction {
|
|
|
37
37
|
readonly message?: string | null;
|
|
38
38
|
readonly occurredAt: string;
|
|
39
39
|
}
|
|
40
|
+
export interface TrustedSandboxHostFailure {
|
|
41
|
+
readonly code: string;
|
|
42
|
+
readonly message: string;
|
|
43
|
+
}
|
|
40
44
|
export interface SandboxRuntimeSnapshot {
|
|
41
45
|
readonly starting: number;
|
|
42
46
|
readonly active: number;
|
|
@@ -285,11 +289,12 @@ export declare function egressProxyConfig(allowedHosts: readonly string[]): stri
|
|
|
285
289
|
export declare function serviceContainerArgs(service: SandboxService, image: string, resources: ResourceNames, labels: Record<string, string>, securityOptions: readonly string[], environment?: NodeJS.ProcessEnv): string[];
|
|
286
290
|
export declare function resolveSandboxSecurityOptions(executionPolicy: string, environment?: NodeJS.ProcessEnv): string[];
|
|
287
291
|
export declare function networkCreateArgs(dispatch: RunOnceDispatchPacket, environment?: NodeJS.ProcessEnv, allowOpenPublicSandbox?: boolean): string[];
|
|
288
|
-
export declare function classifySandboxRuntimeFailure(value:
|
|
292
|
+
export declare function classifySandboxRuntimeFailure(value: unknown): {
|
|
289
293
|
readonly code: string;
|
|
290
294
|
readonly phase: string;
|
|
291
295
|
readonly message: string;
|
|
292
296
|
};
|
|
297
|
+
export declare function applyTrustedHostFailure(result: RunOnceResultPacket, trustedFailure: TrustedSandboxHostFailure | null): RunOnceResultPacket;
|
|
293
298
|
export declare function classifySandboxResourceFailure(value: string): {
|
|
294
299
|
readonly code: string;
|
|
295
300
|
readonly message: string;
|