Skip to content

Commit 1870c02

Browse files
authored
Merge pull request #7738 from BloomBooks/BL-16014-MultipleDevExes
Better support for workflows that involve multiple source directories
2 parents e82cf63 + 6494610 commit 1870c02

32 files changed

Lines changed: 3691 additions & 111 deletions

.github/skills/bloom-automation/SKILL.md

Lines changed: 249 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
import { execFileSync } from "node:child_process";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
const standardBloomStartingHttpPort = 8089;
6+
const standardBloomReservedPortBlockLength = 3;
7+
const standardBloomPortCount = 20;
8+
9+
export const toLocalOrigin = (port) => `http://localhost:${port}`;
10+
export const toBloomApiBaseUrl = (port) => `${toLocalOrigin(port)}/bloom/api`;
11+
export const toWorkspaceTabsEndpoint = (httpPort) =>
12+
`${toBloomApiBaseUrl(httpPort)}/workspace/tabs`;
13+
const toPositiveInteger = (value) => {
14+
const parsed = Number(value);
15+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
16+
};
17+
18+
export const toTcpPort = (value) => {
19+
if (value === undefined || value === null) {
20+
return undefined;
21+
}
22+
23+
const normalized = String(value).trim();
24+
if (!/^\d+$/.test(normalized)) {
25+
return undefined;
26+
}
27+
28+
const parsed = Number(normalized);
29+
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
30+
? parsed
31+
: undefined;
32+
};
33+
34+
export const requireTcpPortOption = (optionName, value) => {
35+
const port = toTcpPort(value);
36+
if (!port) {
37+
throw new Error(
38+
`${optionName} must be an integer from 1 to 65535. Received: ${value}`,
39+
);
40+
}
41+
42+
return port;
43+
};
44+
45+
export const requireOptionValue = (args, index, optionName) => {
46+
const value = args[index + 1];
47+
if (!value || value.startsWith("--")) {
48+
throw new Error(`${optionName} requires a value.`);
49+
}
50+
51+
return value;
52+
};
53+
54+
export const getStandardBloomHttpPorts = () =>
55+
Array.from(
56+
{ length: standardBloomPortCount },
57+
(_, index) =>
58+
standardBloomStartingHttpPort +
59+
index * standardBloomReservedPortBlockLength,
60+
);
61+
62+
export const getDefaultRepoRoot = () =>
63+
path.resolve(
64+
path.dirname(fileURLToPath(import.meta.url)),
65+
"..",
66+
"..",
67+
"..",
68+
);
69+
70+
const normalizePath = (value) => {
71+
if (!value) {
72+
return undefined;
73+
}
74+
75+
const trimmed = value.trim().replace(/^"|"$/g, "");
76+
if (!trimmed) {
77+
return undefined;
78+
}
79+
80+
return path.resolve(trimmed).replace(/\//g, "\\");
81+
};
82+
83+
export const extractRepoRoot = (text) => {
84+
if (!text) {
85+
return undefined;
86+
}
87+
88+
const normalized = text.replace(/\//g, "\\");
89+
90+
const projectMatch = normalized.match(
91+
/([A-Za-z]:\\[^"\r\n]+?)\\src\\BloomExe\\BloomExe\.csproj/i,
92+
);
93+
if (projectMatch?.[1]) {
94+
return normalizePath(projectMatch[1]);
95+
}
96+
97+
const exeMatch = normalized.match(
98+
/([A-Za-z]:\\[^"\r\n]+?)\\output\\[^"\r\n]+?\\Bloom\.exe/i,
99+
);
100+
if (exeMatch?.[1]) {
101+
return normalizePath(exeMatch[1]);
102+
}
103+
104+
return undefined;
105+
};
106+
107+
export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => {
108+
const httpPort = toTcpPort(info?.httpPort) ?? discoveredViaPort;
109+
const cdpPort = toTcpPort(info?.cdpPort);
110+
111+
return {
112+
processId: toPositiveInteger(info?.processId),
113+
discoveredViaPort,
114+
httpPort,
115+
origin: toLocalOrigin(httpPort),
116+
cdpPort,
117+
};
118+
};
119+
120+
const parseWmicList = (text) => {
121+
const lines = text.replace(/\r/g, "").split("\n");
122+
const records = [];
123+
let current = {};
124+
125+
const flush = () => {
126+
if (Object.keys(current).length > 0) {
127+
records.push(current);
128+
current = {};
129+
}
130+
};
131+
132+
for (const line of lines) {
133+
const trimmed = line.trim();
134+
if (!trimmed) {
135+
flush();
136+
continue;
137+
}
138+
139+
const equalsIndex = trimmed.indexOf("=");
140+
if (equalsIndex < 0) {
141+
continue;
142+
}
143+
144+
const key = trimmed.slice(0, equalsIndex);
145+
const value = trimmed.slice(equalsIndex + 1);
146+
current[key] = value;
147+
}
148+
149+
flush();
150+
return records;
151+
};
152+
153+
const queryProcessesByName = (name) => {
154+
for (let attempt = 0; attempt < 3; attempt++) {
155+
try {
156+
const output = execFileSync(
157+
"wmic",
158+
[
159+
"process",
160+
"where",
161+
`name='${name}'`,
162+
"get",
163+
"ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine",
164+
"/format:list",
165+
],
166+
{
167+
encoding: "utf8",
168+
timeout: 5000,
169+
windowsHide: true,
170+
},
171+
);
172+
return parseWmicList(output);
173+
} catch (error) {
174+
if (attempt === 2) {
175+
return [];
176+
}
177+
}
178+
}
179+
180+
return [];
181+
};
182+
183+
export const getWindowsProcessSnapshot = () => {
184+
const rawProcesses = [
185+
...queryProcessesByName("Bloom.exe"),
186+
...queryProcessesByName("dotnet.exe"),
187+
]
188+
.map((record) => ({
189+
processId: Number(record.ProcessId || 0),
190+
parentProcessId: Number(record.ParentProcessId || 0),
191+
name: record.Name,
192+
executablePath: record.ExecutablePath || undefined,
193+
commandLine: record.CommandLine || undefined,
194+
}))
195+
.filter((record) => record.processId > 0 && record.name);
196+
197+
const byId = new Map(
198+
rawProcesses.map((record) => [record.processId, record]),
199+
);
200+
return { rawProcesses, byId };
201+
};
202+
203+
export const buildProcessChain = (processRecord, byId) => {
204+
const chain = [];
205+
let current = processRecord;
206+
207+
for (let i = 0; i < 8 && current; i++) {
208+
chain.push({
209+
processId: current.processId,
210+
parentProcessId: current.parentProcessId,
211+
name: current.name,
212+
executablePath: current.executablePath,
213+
commandLine: current.commandLine,
214+
repoRoot:
215+
extractRepoRoot(current.executablePath) ||
216+
extractRepoRoot(current.commandLine),
217+
});
218+
219+
current = byId.get(current.parentProcessId);
220+
}
221+
222+
return chain;
223+
};
224+
225+
export const classifyProcesses = (expectedRepoRoot) => {
226+
const normalizedExpectedRepoRoot = normalizePath(expectedRepoRoot);
227+
const { rawProcesses, byId } = getWindowsProcessSnapshot();
228+
229+
const toRecord = (processRecord) => {
230+
const processChain = buildProcessChain(processRecord, byId);
231+
const detectedRepoRoot = processChain.find(
232+
(entry) => entry.repoRoot,
233+
)?.repoRoot;
234+
235+
return {
236+
processId: processRecord.processId,
237+
name: processRecord.name,
238+
executablePath: processRecord.executablePath,
239+
commandLine: processRecord.commandLine,
240+
detectedRepoRoot,
241+
matchesExpectedRepoRoot:
242+
!!detectedRepoRoot &&
243+
!!normalizedExpectedRepoRoot &&
244+
detectedRepoRoot.toLowerCase() ===
245+
normalizedExpectedRepoRoot.toLowerCase(),
246+
processChain,
247+
};
248+
};
249+
250+
const bloomProcesses = rawProcesses
251+
.filter((processRecord) => processRecord.name === "Bloom.exe")
252+
.map(toRecord);
253+
254+
const rawWatchProcesses = rawProcesses
255+
.filter(
256+
(processRecord) =>
257+
processRecord.name === "dotnet.exe" &&
258+
processRecord.commandLine?.includes("BloomExe.csproj") &&
259+
(processRecord.commandLine.includes("dotnet-watch.dll") ||
260+
processRecord.commandLine.includes("DOTNET_WATCH=1") ||
261+
processRecord.commandLine.includes(" watch run ")),
262+
)
263+
.map(toRecord);
264+
265+
const watchProcesses = rawWatchProcesses.filter(
266+
(processRecord) => processRecord.detectedRepoRoot,
267+
);
268+
const ambiguousWatchProcesses = rawWatchProcesses.filter(
269+
(processRecord) => !processRecord.detectedRepoRoot,
270+
);
271+
272+
return {
273+
expectedRepoRoot: normalizedExpectedRepoRoot,
274+
bloomProcesses,
275+
watchProcesses,
276+
ambiguousWatchProcesses,
277+
};
278+
};
279+
280+
export const fetchJsonEndpoint = async (url) => {
281+
try {
282+
const response = await fetch(url);
283+
const body = await response.text();
284+
return {
285+
reachable: response.ok,
286+
statusCode: response.status,
287+
json: body ? JSON.parse(body) : undefined,
288+
error: response.ok
289+
? undefined
290+
: `${response.status} ${response.statusText}`,
291+
};
292+
} catch (error) {
293+
return {
294+
reachable: false,
295+
statusCode: undefined,
296+
json: undefined,
297+
error: error instanceof Error ? error.message : String(error),
298+
};
299+
}
300+
};
301+
302+
export const fetchBloomInstanceInfo = async (httpPort) =>
303+
fetchJsonEndpoint(`${toBloomApiBaseUrl(httpPort)}/common/instanceInfo`);
304+
305+
export const waitForBloomInstanceInfo = async (httpPort, timeoutMs = 30000) => {
306+
const deadline = Date.now() + timeoutMs;
307+
308+
while (Date.now() < deadline) {
309+
const response = await fetchBloomInstanceInfo(httpPort);
310+
if (response.reachable && response.json) {
311+
return normalizeBloomInstanceInfo(response.json, httpPort);
312+
}
313+
314+
await new Promise((resolve) => setTimeout(resolve, 250));
315+
}
316+
317+
throw new Error(
318+
`Bloom did not report common/instanceInfo on http://localhost:${httpPort} within ${timeoutMs} ms.`,
319+
);
320+
};
321+
322+
export const findRunningStandardBloomInstances = async () => {
323+
const responses = await Promise.all(
324+
getStandardBloomHttpPorts().map(async (port) => ({
325+
port,
326+
instanceInfo: await fetchBloomInstanceInfo(port),
327+
})),
328+
);
329+
330+
return responses
331+
.filter(
332+
({ instanceInfo }) => instanceInfo.reachable && !!instanceInfo.json,
333+
)
334+
.map(({ port, instanceInfo }) =>
335+
normalizeBloomInstanceInfo(instanceInfo.json, port),
336+
)
337+
.sort((left, right) => left.httpPort - right.httpPort);
338+
};
339+
340+
export const findRunningStandardBloomInstance = async () => {
341+
const instances = await findRunningStandardBloomInstances();
342+
return instances[0];
343+
};
344+
345+
export const killProcessIds = (processIds) => {
346+
const killed = [];
347+
348+
for (const processId of processIds) {
349+
try {
350+
execFileSync("taskkill", ["/PID", String(processId), "/F"], {
351+
encoding: "utf8",
352+
stdio: "pipe",
353+
});
354+
killed.push(processId);
355+
} catch {}
356+
}
357+
358+
return killed;
359+
};

0 commit comments

Comments
 (0)