Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
27429c5
More agent and test control of Bloom.exe
hatton Mar 12, 2026
617a900
allow isolated bloom.exe launches
hatton Mar 13, 2026
a170199
add --label, --vite-port, show info in window title bar
hatton Mar 13, 2026
f211333
yarn go to launch front and back end together with watching
hatton Mar 13, 2026
ca63b5d
Address multiple-instance PR feedback
hatton Mar 14, 2026
f094681
Apply formatter after PR feedback fixes
hatton Mar 14, 2026
7bb5c87
Revert VS Code workspace config changes
hatton Mar 14, 2026
59171d7
add top level `go.sh`, have it turn off feedback
hatton Mar 14, 2026
ef02624
Fix Bloom automation port scanning and CRLF output
hatton Mar 23, 2026
a710bc5
BL-15642 Intro Page Settings
hatton Dec 30, 2025
476a196
simplify
hatton Mar 27, 2026
2945318
Simplify by making the browser automation port be the 3rd port in line
hatton Mar 26, 2026
f52fe7a
UseEffect reduction from https://github.com/softaworks/agent-toolkit
hatton Mar 27, 2026
cb894db
Merge remote-tracking branch 'origin/master' into BL-15642PageColor
hatton Mar 27, 2026
92f7fe5
Merge remote-tracking branch 'origin/master' into BL-16014-MultipleDe…
hatton Mar 27, 2026
c98b222
fix
hatton Mar 28, 2026
2ba1bc2
Remove unused BloomServer port helpers
hatton Mar 28, 2026
c62e7a2
Add required useEffect justification comment per AGENTS.md
hatton Mar 28, 2026
31f7ab6
Ensure isOpenAlready flag is always reset even if page element restor…
hatton Mar 28, 2026
9a8409b
Ensure page settings dialog save always clears open flag
hatton Mar 28, 2026
e25d518
Merge remote-tracking branch 'origin/BL-15642PageColor' into BL-15642…
hatton Mar 28, 2026
5fe9c37
show branch name if helpful
hatton Mar 28, 2026
6494610
Clean up multi-instance startup state
hatton Mar 28, 2026
1870c02
Merge pull request #7738 from BloomBooks/BL-16014-MultipleDevExes
hatton Mar 28, 2026
47214c6
BL-15642 Intro Page Settings
hatton Dec 30, 2025
5c50810
simplify
hatton Mar 27, 2026
b8b4daf
UseEffect reduction from https://github.com/softaworks/agent-toolkit
hatton Mar 27, 2026
4e63659
fix
hatton Mar 28, 2026
6f1f773
Add required useEffect justification comment per AGENTS.md
hatton Mar 28, 2026
f9645df
Ensure page settings dialog save always clears open flag
hatton Mar 28, 2026
b1fc170
Ensure isOpenAlready flag is always reset even if page element restor…
hatton Mar 28, 2026
b672c02
Clarify labels for "Collection Settings" and "Book and Page" settings
hatton Mar 28, 2026
8c48170
don't allow page setting on cover for now
hatton Mar 28, 2026
f9a7053
normalize text display of all the controls that show above the page i…
hatton Mar 28, 2026
822af2b
fix background color when in rounded theme
hatton Mar 29, 2026
ff0b8cf
Merge remote-tracking branch 'origin/BL-15642PageColor' into BL-15642…
hatton Mar 29, 2026
3d5e700
Fix localization of origami choices
hatton Mar 29, 2026
456a868
Remove backend involvement in opening book and page settings
hatton Mar 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/prompts/bloom-test-CURRENTPAGE.prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
description: use browser tools to test and debug
---
The backend should already be running and serving a page at http://localhost:<port>/bloom/CURRENTPAGE. <port> is usually 8089. If i include a port number, use that, otherwise use 8089. You may use chrome-devtools-mcp, playwright-mcp, or other browser management tools. If you can't find any, use askQuestions tool to ask me to enable something for you to use.
249 changes: 249 additions & 0 deletions .github/skills/bloom-automation/SKILL.md

Large diffs are not rendered by default.

359 changes: 359 additions & 0 deletions .github/skills/bloom-automation/bloomProcessCommon.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const standardBloomStartingHttpPort = 8089;
const standardBloomReservedPortBlockLength = 3;
const standardBloomPortCount = 20;

export const toLocalOrigin = (port) => `http://localhost:${port}`;
export const toBloomApiBaseUrl = (port) => `${toLocalOrigin(port)}/bloom/api`;
export const toWorkspaceTabsEndpoint = (httpPort) =>
`${toBloomApiBaseUrl(httpPort)}/workspace/tabs`;
const toPositiveInteger = (value) => {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
};

export const toTcpPort = (value) => {
if (value === undefined || value === null) {
return undefined;
}

const normalized = String(value).trim();
if (!/^\d+$/.test(normalized)) {
return undefined;
}

const parsed = Number(normalized);
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
? parsed
: undefined;
};

export const requireTcpPortOption = (optionName, value) => {
const port = toTcpPort(value);
if (!port) {
throw new Error(
`${optionName} must be an integer from 1 to 65535. Received: ${value}`,
);
}

return port;
};

export const requireOptionValue = (args, index, optionName) => {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${optionName} requires a value.`);
}

return value;
};

export const getStandardBloomHttpPorts = () =>
Array.from(
{ length: standardBloomPortCount },
(_, index) =>
standardBloomStartingHttpPort +
index * standardBloomReservedPortBlockLength,
);

export const getDefaultRepoRoot = () =>
path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
);

const normalizePath = (value) => {
if (!value) {
return undefined;
}

const trimmed = value.trim().replace(/^"|"$/g, "");
if (!trimmed) {
return undefined;
}

return path.resolve(trimmed).replace(/\//g, "\\");
};

export const extractRepoRoot = (text) => {
if (!text) {
return undefined;
}

const normalized = text.replace(/\//g, "\\");

const projectMatch = normalized.match(
/([A-Za-z]:\\[^"\r\n]+?)\\src\\BloomExe\\BloomExe\.csproj/i,
);
if (projectMatch?.[1]) {
return normalizePath(projectMatch[1]);
}

const exeMatch = normalized.match(
/([A-Za-z]:\\[^"\r\n]+?)\\output\\[^"\r\n]+?\\Bloom\.exe/i,
);
if (exeMatch?.[1]) {
return normalizePath(exeMatch[1]);
}

return undefined;
};

export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => {
const httpPort = toTcpPort(info?.httpPort) ?? discoveredViaPort;
const cdpPort = toTcpPort(info?.cdpPort);

return {
processId: toPositiveInteger(info?.processId),
discoveredViaPort,
httpPort,
origin: toLocalOrigin(httpPort),
cdpPort,
};
};

const parseWmicList = (text) => {
const lines = text.replace(/\r/g, "").split("\n");
const records = [];
let current = {};

const flush = () => {
if (Object.keys(current).length > 0) {
records.push(current);
current = {};
}
};

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
flush();
continue;
}

const equalsIndex = trimmed.indexOf("=");
if (equalsIndex < 0) {
continue;
}

const key = trimmed.slice(0, equalsIndex);
const value = trimmed.slice(equalsIndex + 1);
current[key] = value;
}

flush();
return records;
};

const queryProcessesByName = (name) => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const output = execFileSync(
"wmic",
[
"process",
"where",
`name='${name}'`,
"get",
"ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine",
"/format:list",
],
{
encoding: "utf8",
timeout: 5000,
windowsHide: true,
},
);
return parseWmicList(output);
} catch (error) {
if (attempt === 2) {
return [];
}
}
}

return [];
};

export const getWindowsProcessSnapshot = () => {
const rawProcesses = [
...queryProcessesByName("Bloom.exe"),
...queryProcessesByName("dotnet.exe"),
]
.map((record) => ({
processId: Number(record.ProcessId || 0),
parentProcessId: Number(record.ParentProcessId || 0),
name: record.Name,
executablePath: record.ExecutablePath || undefined,
commandLine: record.CommandLine || undefined,
}))
.filter((record) => record.processId > 0 && record.name);

const byId = new Map(
rawProcesses.map((record) => [record.processId, record]),
);
return { rawProcesses, byId };
};

export const buildProcessChain = (processRecord, byId) => {
const chain = [];
let current = processRecord;

for (let i = 0; i < 8 && current; i++) {
chain.push({
processId: current.processId,
parentProcessId: current.parentProcessId,
name: current.name,
executablePath: current.executablePath,
commandLine: current.commandLine,
repoRoot:
extractRepoRoot(current.executablePath) ||
extractRepoRoot(current.commandLine),
});

current = byId.get(current.parentProcessId);
}

return chain;
};

export const classifyProcesses = (expectedRepoRoot) => {
const normalizedExpectedRepoRoot = normalizePath(expectedRepoRoot);
const { rawProcesses, byId } = getWindowsProcessSnapshot();

const toRecord = (processRecord) => {
const processChain = buildProcessChain(processRecord, byId);
const detectedRepoRoot = processChain.find(
(entry) => entry.repoRoot,
)?.repoRoot;

return {
processId: processRecord.processId,
name: processRecord.name,
executablePath: processRecord.executablePath,
commandLine: processRecord.commandLine,
detectedRepoRoot,
matchesExpectedRepoRoot:
!!detectedRepoRoot &&
!!normalizedExpectedRepoRoot &&
detectedRepoRoot.toLowerCase() ===
normalizedExpectedRepoRoot.toLowerCase(),
processChain,
};
};

const bloomProcesses = rawProcesses
.filter((processRecord) => processRecord.name === "Bloom.exe")
.map(toRecord);

const rawWatchProcesses = rawProcesses
.filter(
(processRecord) =>
processRecord.name === "dotnet.exe" &&
processRecord.commandLine?.includes("BloomExe.csproj") &&
(processRecord.commandLine.includes("dotnet-watch.dll") ||
processRecord.commandLine.includes("DOTNET_WATCH=1") ||
processRecord.commandLine.includes(" watch run ")),
)
.map(toRecord);

const watchProcesses = rawWatchProcesses.filter(
(processRecord) => processRecord.detectedRepoRoot,
);
const ambiguousWatchProcesses = rawWatchProcesses.filter(
(processRecord) => !processRecord.detectedRepoRoot,
);

return {
expectedRepoRoot: normalizedExpectedRepoRoot,
bloomProcesses,
watchProcesses,
ambiguousWatchProcesses,
};
};

export const fetchJsonEndpoint = async (url) => {
try {
const response = await fetch(url);
const body = await response.text();
return {
reachable: response.ok,
statusCode: response.status,
json: body ? JSON.parse(body) : undefined,
error: response.ok
? undefined
: `${response.status} ${response.statusText}`,
};
} catch (error) {
return {
reachable: false,
statusCode: undefined,
json: undefined,
error: error instanceof Error ? error.message : String(error),
};
}
};

export const fetchBloomInstanceInfo = async (httpPort) =>
fetchJsonEndpoint(`${toBloomApiBaseUrl(httpPort)}/common/instanceInfo`);

export const waitForBloomInstanceInfo = async (httpPort, timeoutMs = 30000) => {
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
const response = await fetchBloomInstanceInfo(httpPort);
if (response.reachable && response.json) {
return normalizeBloomInstanceInfo(response.json, httpPort);
}

await new Promise((resolve) => setTimeout(resolve, 250));
}

throw new Error(
`Bloom did not report common/instanceInfo on http://localhost:${httpPort} within ${timeoutMs} ms.`,
);
};

export const findRunningStandardBloomInstances = async () => {
const responses = await Promise.all(
getStandardBloomHttpPorts().map(async (port) => ({
port,
instanceInfo: await fetchBloomInstanceInfo(port),
})),
);

return responses
.filter(
({ instanceInfo }) => instanceInfo.reachable && !!instanceInfo.json,
)
.map(({ port, instanceInfo }) =>
normalizeBloomInstanceInfo(instanceInfo.json, port),
)
.sort((left, right) => left.httpPort - right.httpPort);
};

export const findRunningStandardBloomInstance = async () => {
const instances = await findRunningStandardBloomInstances();
return instances[0];
};

export const killProcessIds = (processIds) => {
const killed = [];

for (const processId of processIds) {
try {
execFileSync("taskkill", ["/PID", String(processId), "/F"], {
encoding: "utf8",
stdio: "pipe",
});
killed.push(processId);
} catch {}
}

return killed;
};
Loading
Loading