@loucompanion/forge-bridge 0.1.3-dev.ba7d36726eda → 0.1.3-dev.bca7d0ccee53
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/dist/bridge/bridge.base.d.ts +2 -1
- package/dist/bridge/bridge.service.d.ts +8 -0
- package/dist/bridge/bridge.service.js +184 -12
- package/dist/bridge/bridge.types.d.ts +37 -0
- package/dist/cli/cli.help.js +1 -1
- package/dist/cli/cli.service.js +1 -1
- package/dist/cli/local-config.js +2 -1
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +3 -1
- package/dist/runtime/attempt-node-runtime-launcher.js +62 -11
- package/dist/runtime/attempt-process-supervisor-registry.js +1 -1
- package/dist/runtime/attempt-process-supervisor.d.ts +12 -0
- package/dist/runtime/attempt-process-supervisor.js +75 -15
- 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 +16 -0
- package/dist/runtime/host-listener-inspector.js +143 -4
- package/dist/runtime/host-runtime-contract.d.ts +9 -1
- package/dist/runtime/host-runtime-contract.js +411 -6
- package/dist/runtime/host-runtime-lease.d.ts +12 -1
- package/dist/runtime/host-runtime-lease.js +271 -50
- package/dist/runtime/host-runtime-promotion.d.ts +2 -0
- package/dist/runtime/host-runtime-promotion.js +35 -4
- package/dist/runtime/node-host-runtime-adapter.d.ts +7 -0
- package/dist/runtime/node-host-runtime-adapter.js +71 -8
- package/dist/runtime/playwright-host-runtime-adapter.js +84 -0
- package/dist/sandbox/preflight.d.ts +9 -0
- package/dist/sandbox/preflight.js +39 -4
- package/dist/sandbox/workspace.service.js +7 -2
- package/dist/session/managed-attempt-tools.d.ts +17 -0
- package/dist/session/managed-attempt-tools.js +712 -0
- package/dist/session/phase-completion.artifacts.js +9 -2
- package/dist/session/phase-completion.d.ts +11 -2
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +45 -9
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +21 -6
- package/dist/session/phase-completion.runtime.d.ts +13 -1
- package/dist/session/phase-completion.runtime.js +183 -8
- package/dist/session/phase-completion.schemas.js +108 -3
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +144 -18
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
- package/dist/session/provider-cli/provider-cli.process.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.process.js +176 -18
- package/dist/session/provider-cli/provider-cli.prompts.js +30 -8
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +19 -2
- package/dist/session/provider-cli/provider-cli.service.js +390 -83
- package/dist/session/provider-cli/provider-cli.types.d.ts +3 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session.base.d.ts +2 -1
- package/dist/session/session.service.d.ts +2 -1
- package/dist/session/session.service.js +303 -30
- package/dist/session/session.types.d.ts +38 -0
- package/dist/session/session.types.js +17 -0
- package/dist/shared/process/host-process-supervisor.d.ts +18 -1
- package/dist/shared/process/host-process-supervisor.js +94 -7
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- 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 +10 -1
- package/dist/worktree/dependency-warmer.js +94 -5
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +12 -1
- package/package.json +1 -1
- package/runtime/windows-job-supervisor.ps1 +89 -20
|
@@ -19,6 +19,32 @@ export class HostParallelPromotionStore {
|
|
|
19
19
|
throw error;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
async list() {
|
|
23
|
+
const root = path.join(this.stateRoot, "runtime-promotion", "host");
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = await fs.readdir(root);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === "ENOENT")
|
|
30
|
+
return [];
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const records = [];
|
|
34
|
+
for (const entry of entries.filter(value => /^[0-9a-f]{64}\.json$/u.test(value)).slice(0, 4096)) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(await fs.readFile(path.join(root, entry), "utf8"));
|
|
37
|
+
const repoId = typeof parsed.repoId === "string" ? normalizeRepoId(parsed.repoId) : "";
|
|
38
|
+
if (repoId && validRecord(parsed, repoId))
|
|
39
|
+
records.push(parsed);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (!(error instanceof SyntaxError))
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return records.sort((left, right) => left.repoId.localeCompare(right.repoId));
|
|
47
|
+
}
|
|
22
48
|
async observe(repoId, runtimeContract, dispatchAttemptId, verifiedPortNames) {
|
|
23
49
|
if (!hostRuntimeParallelEligible(runtimeContract)) {
|
|
24
50
|
throw new Error("host_parallel_promotion_ineligible: the runtime contract is not eligible for parallel host execution.");
|
|
@@ -34,6 +60,7 @@ export class HostParallelPromotionStore {
|
|
|
34
60
|
schemaVersion: 1,
|
|
35
61
|
repoId: normalizedRepoId,
|
|
36
62
|
runtimeContractFingerprint: hostRuntimeContractFingerprint(runtimeContract),
|
|
63
|
+
runtimeContractSource: runtimeContract.source === "project" ? "project" : "derived",
|
|
37
64
|
observedAt: new Date().toISOString(),
|
|
38
65
|
observedDispatchAttemptId: normalizedUuid(dispatchAttemptId, "dispatch attempt"),
|
|
39
66
|
verifiedPortNames: observedPorts,
|
|
@@ -71,18 +98,22 @@ export function hostRuntimeContractFingerprint(runtimeContract) {
|
|
|
71
98
|
const canonical = JSON.stringify({
|
|
72
99
|
schemaVersion: runtimeContract.contract.schemaVersion,
|
|
73
100
|
runtime: runtimeContract.contract.runtime,
|
|
101
|
+
derivedCandidateFingerprint: runtimeContract.source === "derived"
|
|
102
|
+
? runtimeContract.discovery?.candidateFingerprint ?? null
|
|
103
|
+
: null,
|
|
74
104
|
});
|
|
75
105
|
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
|
|
76
106
|
}
|
|
77
107
|
export function hostRuntimeParallelEligible(runtimeContract) {
|
|
78
108
|
const runtime = runtimeContract.contract.runtime;
|
|
79
|
-
|
|
109
|
+
const launchedPorts = runtime.ports.filter(port => port.launch !== null);
|
|
110
|
+
return (runtimeContract.source === "project" || runtimeContract.source === "derived")
|
|
80
111
|
&& runtime.hostConcurrency === "parallel"
|
|
81
112
|
&& runtime.browserProfile === "isolated"
|
|
82
113
|
&& runtime.singletonResources.length === 0
|
|
83
|
-
&&
|
|
84
|
-
&& runtime.ports
|
|
85
|
-
&&
|
|
114
|
+
&& launchedPorts.length > 0
|
|
115
|
+
&& launchedPorts.length === runtime.ports.length
|
|
116
|
+
&& launchedPorts.every(port => port.adapter !== null);
|
|
86
117
|
}
|
|
87
118
|
export function configuredLaunchPortNames(runtimeContract) {
|
|
88
119
|
return runtimeContract.contract.runtime.ports
|
|
@@ -15,8 +15,15 @@ export interface NodeRuntimeLaunchRequest extends NodeRuntimeCommand {
|
|
|
15
15
|
export interface NodeRuntimeCommandLauncher<T> {
|
|
16
16
|
readonly launch: (request: NodeRuntimeLaunchRequest, signal?: AbortSignal) => Promise<T>;
|
|
17
17
|
readonly inspectListener: (value: T, request: NodeRuntimeLaunchRequest, signal?: AbortSignal) => Promise<HostListenerOwnership>;
|
|
18
|
+
readonly readinessDiagnostics?: (value: T) => Promise<NodeRuntimeReadinessDiagnostics>;
|
|
18
19
|
readonly stop: (value: T) => Promise<void>;
|
|
19
20
|
}
|
|
21
|
+
export interface NodeRuntimeReadinessDiagnostics {
|
|
22
|
+
readonly exited: boolean;
|
|
23
|
+
readonly exitCode?: number;
|
|
24
|
+
readonly stdoutTail?: string;
|
|
25
|
+
readonly stderrTail?: string;
|
|
26
|
+
}
|
|
20
27
|
export interface NodeHostRuntimeAdapterOptions {
|
|
21
28
|
readonly readinessTimeoutMs?: number;
|
|
22
29
|
readonly readinessPollIntervalMs?: number;
|
|
@@ -13,7 +13,7 @@ export class NodeHostRuntimeAdapter {
|
|
|
13
13
|
probeHttp;
|
|
14
14
|
constructor(broker, options = {}) {
|
|
15
15
|
this.broker = broker;
|
|
16
|
-
this.readinessTimeoutMs = boundedInteger(options.readinessTimeoutMs ??
|
|
16
|
+
this.readinessTimeoutMs = boundedInteger(options.readinessTimeoutMs ?? 120_000, 100, 120_000, "readinessTimeoutMs");
|
|
17
17
|
this.readinessPollIntervalMs = boundedInteger(options.readinessPollIntervalMs ?? 100, 10, 5_000, "readinessPollIntervalMs");
|
|
18
18
|
this.httpProbeTimeoutMs = boundedInteger(options.httpProbeTimeoutMs ?? 1_000, 50, 10_000, "httpProbeTimeoutMs");
|
|
19
19
|
this.probeHttp = options.probeHttp ?? probeHttpReadiness;
|
|
@@ -50,10 +50,16 @@ export class NodeHostRuntimeAdapter {
|
|
|
50
50
|
}
|
|
51
51
|
async waitUntilReady(value, request, launcher, signal) {
|
|
52
52
|
const deadline = Date.now() + this.readinessTimeoutMs;
|
|
53
|
+
let observedOwnership = "unbound";
|
|
53
54
|
while (Date.now() <= deadline) {
|
|
54
55
|
if (signal?.aborted)
|
|
55
56
|
throw cancellationError(signal);
|
|
57
|
+
const diagnostics = await launcher.readinessDiagnostics?.(value);
|
|
58
|
+
if (diagnostics?.exited) {
|
|
59
|
+
throw new Error(readinessFailure("host_runtime_process_exited", `the supervised process exited with code ${diagnostics.exitCode ?? "unknown"} before its assigned runtime became ready`, request, diagnostics));
|
|
60
|
+
}
|
|
56
61
|
const ownership = await launcher.inspectListener(value, request, signal);
|
|
62
|
+
observedOwnership = ownership;
|
|
57
63
|
if (ownership === "foreign")
|
|
58
64
|
return false;
|
|
59
65
|
if (ownership === "owned" && await this.probeHttp(request.baseUrl, this.httpProbeTimeoutMs, signal)) {
|
|
@@ -61,9 +67,32 @@ export class NodeHostRuntimeAdapter {
|
|
|
61
67
|
}
|
|
62
68
|
await wait(this.readinessPollIntervalMs, signal);
|
|
63
69
|
}
|
|
64
|
-
|
|
70
|
+
const diagnostics = await launcher.readinessDiagnostics?.(value);
|
|
71
|
+
if (diagnostics?.exited) {
|
|
72
|
+
throw new Error(readinessFailure("host_runtime_process_exited", `the supervised process exited with code ${diagnostics.exitCode ?? "unknown"} before its assigned runtime became ready`, request, diagnostics));
|
|
73
|
+
}
|
|
74
|
+
const code = observedOwnership === "owned"
|
|
75
|
+
? "host_runtime_http_readiness_timeout"
|
|
76
|
+
: "host_runtime_listener_timeout";
|
|
77
|
+
const detail = observedOwnership === "owned"
|
|
78
|
+
? `the supervised process owned its assigned loopback port but HTTP readiness did not succeed within ${this.readinessTimeoutMs}ms`
|
|
79
|
+
: `the supervised process did not bind its assigned loopback port within ${this.readinessTimeoutMs}ms`;
|
|
80
|
+
throw new Error(readinessFailure(code, detail, request, diagnostics));
|
|
65
81
|
}
|
|
66
82
|
}
|
|
83
|
+
function readinessFailure(code, detail, request, diagnostics) {
|
|
84
|
+
const evidence = [
|
|
85
|
+
boundedDiagnostic("stderr", diagnostics?.stderrTail),
|
|
86
|
+
boundedDiagnostic("stdout", diagnostics?.stdoutTail),
|
|
87
|
+
].filter((value) => value !== null);
|
|
88
|
+
return `${code}: ${detail} (${request.baseUrl}).${evidence.length > 0 ? ` ${evidence.join(" ")}` : ""}`;
|
|
89
|
+
}
|
|
90
|
+
function boundedDiagnostic(label, value) {
|
|
91
|
+
const normalized = value?.replaceAll(/\s+/gu, " ").trim();
|
|
92
|
+
if (!normalized)
|
|
93
|
+
return null;
|
|
94
|
+
return `${label} tail: ${normalized.slice(-2_000)}`;
|
|
95
|
+
}
|
|
67
96
|
export function buildNodeRuntimeLaunchRequest(framework, portConfig, command, port) {
|
|
68
97
|
validateCommand(command);
|
|
69
98
|
if (port.hostIp !== "127.0.0.1" || port.protocol !== "tcp") {
|
|
@@ -81,25 +110,56 @@ export function buildNodeRuntimeLaunchRequest(framework, portConfig, command, po
|
|
|
81
110
|
? aspNetCoreArguments(command.command, command.args)
|
|
82
111
|
: [...command.args, ...frameworkArguments(framework, port.hostIp, port.hostPort)];
|
|
83
112
|
const adapterEnvironment = framework === "aspnetcore"
|
|
84
|
-
? {
|
|
113
|
+
? {
|
|
114
|
+
ASPNETCORE_URLS: baseUrl,
|
|
115
|
+
Hosting__Port: String(port.hostPort),
|
|
116
|
+
Harness__Reconciler__Hosted: "false",
|
|
117
|
+
Projects__RuntimeReadiness__Hosted: "false",
|
|
118
|
+
Runners__ServerLocalHeartbeat__Hosted: "false",
|
|
119
|
+
FORGE_RUNTIME_MODE: "host",
|
|
120
|
+
FORGE_DISPATCH_ATTEMPT_ID: port.dispatchAttemptId,
|
|
121
|
+
DOTNET_CLI_USE_MSBUILD_SERVER: "0",
|
|
122
|
+
DOTNET_NOLOGO: "1",
|
|
123
|
+
}
|
|
85
124
|
: { HOST: port.hostIp };
|
|
125
|
+
const forgeEnvironment = {
|
|
126
|
+
FORGE_RUNTIME_PORT_NAME: portConfig.name,
|
|
127
|
+
FORGE_RUNTIME_PORT: String(port.hostPort),
|
|
128
|
+
FORGE_RUNTIME_BASE_URL: baseUrl,
|
|
129
|
+
};
|
|
130
|
+
const expansionScope = {
|
|
131
|
+
...command.environment,
|
|
132
|
+
...portEnvironment,
|
|
133
|
+
...adapterEnvironment,
|
|
134
|
+
...forgeEnvironment,
|
|
135
|
+
};
|
|
86
136
|
return {
|
|
87
137
|
framework,
|
|
88
138
|
command: command.command,
|
|
89
139
|
args,
|
|
90
140
|
cwd: path.resolve(command.cwd),
|
|
91
141
|
environment: {
|
|
92
|
-
...command.environment,
|
|
142
|
+
...expandCommandEnvironment(command.environment, expansionScope),
|
|
93
143
|
...portEnvironment,
|
|
94
144
|
...adapterEnvironment,
|
|
95
|
-
|
|
96
|
-
FORGE_RUNTIME_PORT: String(port.hostPort),
|
|
97
|
-
FORGE_RUNTIME_BASE_URL: baseUrl,
|
|
145
|
+
...forgeEnvironment,
|
|
98
146
|
},
|
|
99
147
|
port,
|
|
100
148
|
baseUrl,
|
|
101
149
|
};
|
|
102
150
|
}
|
|
151
|
+
function expandCommandEnvironment(environment, scope) {
|
|
152
|
+
return Object.fromEntries(Object.entries(environment ?? {}).map(([name, value]) => [
|
|
153
|
+
name,
|
|
154
|
+
value.replaceAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_match, variable) => {
|
|
155
|
+
const resolved = scope[variable];
|
|
156
|
+
if (!resolved) {
|
|
157
|
+
throw new Error(`node_runtime_adapter_invalid: launch environment references unavailable variable '${variable}'.`);
|
|
158
|
+
}
|
|
159
|
+
return resolved;
|
|
160
|
+
}),
|
|
161
|
+
]));
|
|
162
|
+
}
|
|
103
163
|
function frameworkArguments(framework, hostIp, hostPort) {
|
|
104
164
|
if (framework === "vite")
|
|
105
165
|
return ["--host", hostIp, "--port", String(hostPort), "--strictPort"];
|
|
@@ -139,6 +199,9 @@ function aspNetCoreArguments(command, args) {
|
|
|
139
199
|
const insertionIndex = applicationArguments < 0 ? args.length : applicationArguments;
|
|
140
200
|
return [
|
|
141
201
|
...args.slice(0, insertionIndex),
|
|
202
|
+
"--disable-build-servers",
|
|
203
|
+
"-p:UseSharedCompilation=false",
|
|
204
|
+
"-p:DefaultItemExcludesInProjectFolder=TestResults/**",
|
|
142
205
|
"--no-launch-profile",
|
|
143
206
|
...args.slice(insertionIndex),
|
|
144
207
|
];
|
|
@@ -151,7 +214,7 @@ function validateCommand(command) {
|
|
|
151
214
|
throw new Error("node_runtime_adapter_invalid: command, arguments, or absolute working directory are invalid.");
|
|
152
215
|
}
|
|
153
216
|
for (const [name, value] of Object.entries(command.environment ?? {})) {
|
|
154
|
-
if (!/^[A-
|
|
217
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(name) || !boundedText(value, 8192)) {
|
|
155
218
|
throw new Error("node_runtime_adapter_invalid: command environment is invalid.");
|
|
156
219
|
}
|
|
157
220
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { pathToFileURL } from "node:url";
|
|
4
5
|
const maximumConfigBytes = 1024 * 1024;
|
|
@@ -29,6 +30,8 @@ export class PlaywrightHostRuntimeAdapter {
|
|
|
29
30
|
}
|
|
30
31
|
const baseUrl = loopbackHttpUrl(context.baseUrl);
|
|
31
32
|
const overlayPath = path.join(temporaryDirectory, "forge.playwright.config.mjs");
|
|
33
|
+
const browserCache = await resolvePlaywrightBrowserCache(attemptRoot);
|
|
34
|
+
const browserInstallCommand = await resolvePlaywrightBrowserInstallCommand(context.worktreePath, path.dirname(realSource));
|
|
32
35
|
await fs.writeFile(overlayPath, renderOverlay({
|
|
33
36
|
projectConfigUrl: pathToFileURL(realSource).href,
|
|
34
37
|
projectConfigDirectory: path.dirname(realSource),
|
|
@@ -41,6 +44,10 @@ export class PlaywrightHostRuntimeAdapter {
|
|
|
41
44
|
environment: {
|
|
42
45
|
FORGE_PLAYWRIGHT_CONFIG: overlayPath,
|
|
43
46
|
FORGE_PLAYWRIGHT_BASE_URL: baseUrl,
|
|
47
|
+
FORGE_E2E_WEB_URL: baseUrl,
|
|
48
|
+
FORGE_PLAYWRIGHT_BROWSER_READY: browserCache.ready ? "true" : "false",
|
|
49
|
+
FORGE_PLAYWRIGHT_BROWSER_INSTALL_COMMAND: browserInstallCommand,
|
|
50
|
+
PLAYWRIGHT_BROWSERS_PATH: browserCache.path,
|
|
44
51
|
PLAYWRIGHT_HTML_OUTPUT_DIR: path.join(testOutputDirectory, "html-report"),
|
|
45
52
|
PLAYWRIGHT_BLOB_OUTPUT_DIR: path.join(testOutputDirectory, "blob-report"),
|
|
46
53
|
PLAYWRIGHT_JUNIT_OUTPUT_NAME: path.join(testOutputDirectory, "results.xml"),
|
|
@@ -51,6 +58,71 @@ export class PlaywrightHostRuntimeAdapter {
|
|
|
51
58
|
};
|
|
52
59
|
}
|
|
53
60
|
}
|
|
61
|
+
async function resolvePlaywrightBrowserInstallCommand(worktreePath, configDirectory) {
|
|
62
|
+
const relativeDirectory = path.relative(worktreePath, configDirectory).replaceAll(path.sep, "/") || ".";
|
|
63
|
+
if (await isFile(path.join(configDirectory, "pnpm-lock.yaml"))) {
|
|
64
|
+
return `pnpm --dir ${quoteCommandArgument(relativeDirectory)} exec playwright install chromium`;
|
|
65
|
+
}
|
|
66
|
+
if (await isFile(path.join(configDirectory, "yarn.lock"))) {
|
|
67
|
+
return `yarn --cwd ${quoteCommandArgument(relativeDirectory)} playwright install chromium`;
|
|
68
|
+
}
|
|
69
|
+
return `npm --prefix ${quoteCommandArgument(relativeDirectory)} exec playwright install chromium`;
|
|
70
|
+
}
|
|
71
|
+
async function isFile(candidate) {
|
|
72
|
+
try {
|
|
73
|
+
return (await fs.stat(candidate)).isFile();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function quoteCommandArgument(value) {
|
|
80
|
+
return /[\s"']/u.test(value) ? JSON.stringify(value) : value;
|
|
81
|
+
}
|
|
82
|
+
async function resolvePlaywrightBrowserCache(attemptRoot) {
|
|
83
|
+
const candidates = [
|
|
84
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH,
|
|
85
|
+
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "ms-playwright") : null,
|
|
86
|
+
path.join(os.homedir(), ".cache", "ms-playwright"),
|
|
87
|
+
].filter((candidate) => Boolean(candidate?.trim()));
|
|
88
|
+
for (const candidate of [...new Set(candidates.map(candidate => path.resolve(candidate)))]) {
|
|
89
|
+
if (await hasChromiumExecutable(candidate)) {
|
|
90
|
+
return { path: candidate, ready: true };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const attemptCache = path.join(attemptRoot, "cache", "ms-playwright");
|
|
94
|
+
await fs.mkdir(attemptCache, { recursive: true });
|
|
95
|
+
return { path: attemptCache, ready: false };
|
|
96
|
+
}
|
|
97
|
+
async function hasChromiumExecutable(cachePath) {
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = await fs.readdir(cachePath, { withFileTypes: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const executableSuffixes = process.platform === "win32"
|
|
106
|
+
? [path.join("chrome-win64", "chrome.exe"), path.join("chrome-win", "chrome.exe")]
|
|
107
|
+
: process.platform === "darwin"
|
|
108
|
+
? [path.join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")]
|
|
109
|
+
: [path.join("chrome-linux", "chrome"), path.join("chrome-linux64", "chrome")];
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (!entry.isDirectory() || !entry.name.startsWith("chromium-"))
|
|
112
|
+
continue;
|
|
113
|
+
for (const suffix of executableSuffixes) {
|
|
114
|
+
try {
|
|
115
|
+
const stat = await fs.stat(path.join(cachePath, entry.name, suffix));
|
|
116
|
+
if (stat.isFile())
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Continue through known Playwright layouts.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
54
126
|
function renderOverlay(values) {
|
|
55
127
|
return [
|
|
56
128
|
"// Generated by forge-bridge for one fenced host attempt.",
|
|
@@ -82,6 +154,17 @@ function renderOverlay(values) {
|
|
|
82
154
|
" ? [resolveModulePath(reporter[0]), ...reporter.slice(1)]",
|
|
83
155
|
" : resolveModulePath(reporter))",
|
|
84
156
|
" : resolveModulePath(projectConfig.reporter);",
|
|
157
|
+
"const forgeProviderOnlySpecs = (() => {",
|
|
158
|
+
" try {",
|
|
159
|
+
" const parsed = JSON.parse(process.env.FORGE_CI_REPAIR_PROVIDER_ONLY_SPECS ?? '[]');",
|
|
160
|
+
" return Array.isArray(parsed) ? parsed.filter(value => typeof value === 'string' && value.length > 0) : [];",
|
|
161
|
+
" } catch {",
|
|
162
|
+
" return [];",
|
|
163
|
+
" }",
|
|
164
|
+
"})();",
|
|
165
|
+
"const forgeTestIgnore = forgeProviderOnlySpecs.length === 0",
|
|
166
|
+
" ? projectConfig.testIgnore",
|
|
167
|
+
" : [...(Array.isArray(projectConfig.testIgnore) ? projectConfig.testIgnore : projectConfig.testIgnore ? [projectConfig.testIgnore] : []), ...forgeProviderOnlySpecs];",
|
|
85
168
|
"export default {",
|
|
86
169
|
" ...projectConfig,",
|
|
87
170
|
" testDir: resolveConfigPath(projectConfig.testDir),",
|
|
@@ -89,6 +172,7 @@ function renderOverlay(values) {
|
|
|
89
172
|
" globalSetup: resolveModulePath(projectConfig.globalSetup),",
|
|
90
173
|
" globalTeardown: resolveModulePath(projectConfig.globalTeardown),",
|
|
91
174
|
" reporter: forgeReporter,",
|
|
175
|
+
" testIgnore: forgeTestIgnore,",
|
|
92
176
|
` outputDir: ${JSON.stringify(values.testOutputDirectory)},`,
|
|
93
177
|
` workers: ${values.workers},`,
|
|
94
178
|
" webServer: undefined,",
|
|
@@ -39,7 +39,16 @@ export declare function evaluateDiskPressure(availableBytes: number, totalBytes:
|
|
|
39
39
|
export declare function storagePolicyAvailable(storageQuota: boolean, environment?: NodeJS.ProcessEnv): boolean;
|
|
40
40
|
export declare class DockerContextOwnershipLock {
|
|
41
41
|
private lockPath;
|
|
42
|
+
private lockToken;
|
|
42
43
|
acquire(contextName?: string): Promise<void>;
|
|
43
44
|
release(): Promise<void>;
|
|
44
45
|
}
|
|
45
46
|
export declare function parseDockerMemoryUsage(value: string): number;
|
|
47
|
+
interface DockerContextLockOwner {
|
|
48
|
+
readonly token?: string;
|
|
49
|
+
readonly pid?: number;
|
|
50
|
+
readonly processIdentity?: string | null;
|
|
51
|
+
readonly acquiredAt?: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function dockerContextOwnerIsActive(owner: DockerContextLockOwner | null): boolean;
|
|
54
|
+
export {};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
|
|
5
6
|
import { BoundedCommandRunner } from "./command-runner.js";
|
|
6
7
|
const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
|
|
7
8
|
const defaultHostMemoryReserveBytes = 2 * 1024 ** 3;
|
|
@@ -189,34 +190,48 @@ export function storagePolicyAvailable(storageQuota, environment = process.env)
|
|
|
189
190
|
}
|
|
190
191
|
export class DockerContextOwnershipLock {
|
|
191
192
|
lockPath = null;
|
|
193
|
+
lockToken = null;
|
|
192
194
|
async acquire(contextName) {
|
|
193
195
|
if (this.lockPath)
|
|
194
196
|
return;
|
|
195
197
|
contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
|
|
196
198
|
const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
|
|
197
199
|
const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
|
|
200
|
+
const token = randomUUID();
|
|
198
201
|
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
199
202
|
try {
|
|
200
203
|
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
201
|
-
await handle.writeFile(JSON.stringify({
|
|
204
|
+
await handle.writeFile(JSON.stringify({
|
|
205
|
+
schemaVersion: 2,
|
|
206
|
+
token,
|
|
207
|
+
pid: process.pid,
|
|
208
|
+
processIdentity: captureHostProcessIdentity(process.pid),
|
|
209
|
+
contextName,
|
|
210
|
+
acquiredAt: new Date().toISOString(),
|
|
211
|
+
}));
|
|
202
212
|
await handle.sync();
|
|
203
213
|
await handle.close();
|
|
204
214
|
}
|
|
205
215
|
catch (error) {
|
|
206
216
|
const owner = await readLock(lockPath);
|
|
207
|
-
if (
|
|
217
|
+
if (!dockerContextOwnerIsActive(owner)) {
|
|
208
218
|
await fs.rm(lockPath, { force: true });
|
|
209
219
|
return await this.acquire(contextName);
|
|
210
220
|
}
|
|
211
221
|
throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
|
|
212
222
|
}
|
|
213
223
|
this.lockPath = lockPath;
|
|
224
|
+
this.lockToken = token;
|
|
214
225
|
}
|
|
215
226
|
async release() {
|
|
216
227
|
if (!this.lockPath)
|
|
217
228
|
return;
|
|
218
|
-
await
|
|
229
|
+
const owner = await readLock(this.lockPath);
|
|
230
|
+
if (owner?.token === this.lockToken) {
|
|
231
|
+
await fs.rm(this.lockPath, { force: true });
|
|
232
|
+
}
|
|
219
233
|
this.lockPath = null;
|
|
234
|
+
this.lockToken = null;
|
|
220
235
|
}
|
|
221
236
|
}
|
|
222
237
|
function parseInfo(stdout) {
|
|
@@ -289,6 +304,26 @@ export function parseDockerMemoryUsage(value) {
|
|
|
289
304
|
async function readLock(lockPath) {
|
|
290
305
|
return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
|
|
291
306
|
}
|
|
307
|
+
export function dockerContextOwnerIsActive(owner) {
|
|
308
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0 || !processExists(owner.pid)) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
const liveIdentity = captureHostProcessIdentity(owner.pid);
|
|
312
|
+
if (owner.processIdentity) {
|
|
313
|
+
// A live but unverifiable process must remain protected. Windows and Linux
|
|
314
|
+
// normally provide a birth marker, so a mismatch is positive PID-reuse proof.
|
|
315
|
+
return !liveIdentity || liveIdentity === owner.processIdentity;
|
|
316
|
+
}
|
|
317
|
+
if (process.platform === "win32" && liveIdentity?.startsWith("win32:") && owner.acquiredAt) {
|
|
318
|
+
const ticks = Number(liveIdentity.slice("win32:".length));
|
|
319
|
+
const acquiredAt = Date.parse(owner.acquiredAt);
|
|
320
|
+
const processStartedAt = ticks / 10_000 - 62_135_596_800_000;
|
|
321
|
+
if (Number.isFinite(processStartedAt) && Number.isFinite(acquiredAt) && processStartedAt > acquiredAt) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
292
327
|
function processExists(pid) {
|
|
293
328
|
try {
|
|
294
329
|
process.kill(pid, 0);
|
|
@@ -38,8 +38,13 @@ export class SandboxWorkspaceService {
|
|
|
38
38
|
await this.git.run(this.workspace, ["config", "core.hooksPath", "/dev/null"]);
|
|
39
39
|
await clearRunArtifactRoots(this.workspace, request);
|
|
40
40
|
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
41
|
-
if (artifacts.length > 0)
|
|
42
|
-
await
|
|
41
|
+
if (artifacts.length > 0) {
|
|
42
|
+
const trackedForgePaths = new Set((await this.git.run(this.workspace, ["ls-files", "-z", "--", ".forge"]))
|
|
43
|
+
.split("\0")
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map(value => value.replaceAll("\\", "/")));
|
|
46
|
+
await injectWorktreeArtifacts(this.workspace, artifacts, trackedForgePaths);
|
|
47
|
+
}
|
|
43
48
|
const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(this.workspace, request.priorPhaseArtifacts);
|
|
44
49
|
return {
|
|
45
50
|
repoPath: this.workspace,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const managedAttemptToolNames: readonly ["forge.run_managed_command", "run_managed_command", "forge.start_managed_command", "start_managed_command", "forge.get_managed_command", "get_managed_command", "forge.wait_managed_command", "wait_managed_command", "forge.start_managed_verification", "start_managed_verification", "forge.reset_generated_outputs", "reset_generated_outputs"];
|
|
2
|
+
export declare function isManagedAttemptTool(name: string): boolean;
|
|
3
|
+
export declare function managedAttemptToolDefinition(name: string): unknown;
|
|
4
|
+
export interface ManagedAttemptToolResult {
|
|
5
|
+
readonly content: readonly {
|
|
6
|
+
readonly type: "text";
|
|
7
|
+
readonly text: string;
|
|
8
|
+
}[];
|
|
9
|
+
readonly structuredContent: Readonly<Record<string, unknown>>;
|
|
10
|
+
readonly isError?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function callManagedAttemptTool(name: string, input: unknown, worktreePath: string): Promise<ManagedAttemptToolResult>;
|
|
13
|
+
export declare function buildManagedVerificationCommand(profile: string, outputDirectory: string, shardCount: number, shardIndex: number, noBuild: boolean): {
|
|
14
|
+
command: string;
|
|
15
|
+
args: readonly string[];
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
};
|