feat(editor): unified Compile-All / Run-All across boards + programmable chips

Phase 3 of the run-system work. Generalises the boards-only Compile-All/Run-All
to RUN TARGETS = boards + programmable custom-chips, so a board+chip or several
chips compile and run together, the same way multiple Arduinos do.

- targetCount = boards + programmable chips; the Compile-All/Run-All buttons now
  appear when targetCount > 1 (was boards.length > 1). Cheap string predicate
  (no JSON.parse) since the selector runs on every sim tick.
- compileAllBoards builds chips (WASM+ROM) AND boards; works with zero boards;
  prepareCustomChips now returns a failure count folded into the Done summary so
  a failed chip no longer shows green / calls markCompiled.
- handleRunAll: compiles all targets, starts every board, then restartParts() so
  chips pick up fresh WASM/ROM, and resumes the electrical solver when NO board
  actually started (board-less, or a board that compiled to nothing) so chips
  aren't left frozen.

Review fixes (2-agent adversarial pass):
- Stop now stops EVERY running board (Run-All can start several); otherwise a
  non-active board kept the chip ticking after Stop.
- Run-All / Stop disabled gates use anyBoardRunning (+ digitalRunning) instead of
  the flat active-board  flag, which misreports multi-target runs.
- shared isQemuBoardKind() helper so handleRun and handleRunAll can't drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-04 07:21:39 +02:00
parent a68e7f8e94
commit 6ae1ed560d
1 changed files with 123 additions and 41 deletions

View File

@ -56,6 +56,28 @@ function clearAllChipDrives(): void {
if (any) requestElectricalResolve();
}
/**
* Boards whose firmware runs in a QEMU worker rather than a client-side AVR
* core. They can start without a pre-stored `compiledProgram`. Shared by
* handleRun and handleRunAll so the two paths can't drift.
*/
function isQemuBoardKind(kind: BoardKind | undefined): boolean {
if (!kind) return false;
return (
isPiBoardKind(kind) ||
kind === 'esp32' ||
kind === 'esp32-s3' ||
kind === 'esp32-cam' ||
kind === 'esp32-c3' ||
kind === 'esp32-devkit-c-v4' ||
kind === 'wemos-lolin32-lite' ||
kind === 'xiao-esp32-s3' ||
kind === 'arduino-nano-esp32' ||
kind === 'xiao-esp32-c3' ||
kind === 'aitewinrobot-esp32c3-supermini'
);
}
interface EditorToolbarProps {
consoleOpen: boolean;
setConsoleOpen: (open: boolean | ((v: boolean) => boolean)) => void;
@ -157,6 +179,29 @@ export const EditorToolbar = ({
const setElectricalPaused = useElectricalStore((s) => s.setPaused);
const isBoardless = boards.length === 0;
const digitalRunning = isBoardless && !electricalPaused;
// Any board actually running — the correct multi-target signal for the
// Run-All / Stop buttons (the flat `running` flag only tracks the ACTIVE
// board, so it misreports a multi-board or non-active-board run).
const anyBoardRunning = boards.some((b) => b.running);
// A "run target" is a board OR a programmable custom-chip (a CPU that runs a
// ROM). When there is more than one target — two boards, a board + a chip, or
// several chips — the unified Compile-All / Run-All buttons appear and act on
// every target, the same way multiple Arduinos behave. Resolved as a number
// so the toolbar only re-renders when the count changes. The predicate is a
// cheap string test (no JSON.parse) since this selector runs on every store
// change, including high-frequency simulation churn. (The compile/run paths
// deliberately act on ALL custom chips, not just programmable ones.)
const targetCount = useSimulatorStore((s) => {
let chips = 0;
for (const c of s.components) {
if (c.metadataId !== 'custom-chip') continue;
const p = c.properties as Record<string, unknown>;
if (String(p?.programFile ?? '').trim() || String(p?.chipJson ?? '').includes('"programTargets"'))
chips++;
}
return s.boards.length + chips;
});
// Circuit-verification modal state. When `pendingRun` is non-null we've
// already paid the cost of solving + analysing — the user can either
@ -231,6 +276,7 @@ export const EditorToolbar = ({
) => {
const codeChanged = useEditorStore.getState().codeChangedSinceLastCompile;
const updateComponent = useSimulatorStore.getState().updateComponent;
let failed = 0;
for (const chip of chips) {
// Re-read the freshest properties each iteration (an earlier chip's
@ -265,6 +311,7 @@ export const EditorToolbar = ({
type: 'error',
message: `Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`,
});
failed++;
}
} catch (e) {
addLog({
@ -272,6 +319,7 @@ export const EditorToolbar = ({
type: 'error',
message: `Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`,
});
failed++;
}
}
@ -295,6 +343,7 @@ export const EditorToolbar = ({
type: 'error',
message: `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`,
});
failed++;
} else {
const target = targetForChip(chipJson);
const fmt = formatForFile(programFile);
@ -320,6 +369,7 @@ export const EditorToolbar = ({
type: 'error',
message: `ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`,
});
failed++;
}
} catch (e) {
addLog({
@ -327,6 +377,7 @@ export const EditorToolbar = ({
type: 'error',
message: `ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`,
});
failed++;
}
}
}
@ -335,6 +386,7 @@ export const EditorToolbar = ({
updateComponent(chip.id, { properties: props } as any);
}
}
return { failed };
},
[addLog],
);
@ -722,18 +774,7 @@ export const EditorToolbar = ({
return;
}
const isQemuBoard =
board?.boardKind && isPiBoardKind(board.boardKind) ||
board?.boardKind === 'esp32' ||
board?.boardKind === 'esp32-s3' ||
board?.boardKind === 'esp32-cam' ||
board?.boardKind === 'esp32-c3' ||
board?.boardKind === 'esp32-devkit-c-v4' ||
board?.boardKind === 'wemos-lolin32-lite' ||
board?.boardKind === 'xiao-esp32-s3' ||
board?.boardKind === 'arduino-nano-esp32' ||
board?.boardKind === 'xiao-esp32-c3' ||
board?.boardKind === 'aitewinrobot-esp32c3-supermini';
const isQemuBoard = isQemuBoardKind(board?.boardKind);
// QEMU boards: auto-compile if no firmware available yet
if (isQemuBoard) {
@ -844,7 +885,11 @@ export const EditorToolbar = ({
setMessage(null);
return;
}
if (activeBoardId) stopBoard(activeBoardId);
// Stop EVERY running board — Run-All can start several, and leaving any
// running keeps chips ticking (their gate is boards.some(running)).
const runningBoards = useSimulatorStore.getState().boards.filter((b) => b.running);
if (runningBoards.length > 0) runningBoards.forEach((b) => stopBoard(b.id));
else if (activeBoardId) stopBoard(activeBoardId);
else stopSimulation();
// A chip wired to a board drives its LEDs via its own SPICE sources, which
// stopBoard doesn't touch — clear them so those LEDs also go dark.
@ -867,36 +912,46 @@ export const EditorToolbar = ({
*/
const compileAllBoards = async (): Promise<{ ok: number; failed: number }> => {
const boardsList = useSimulatorStore.getState().boards;
if (boardsList.length === 0) return { ok: 0, failed: 0 };
// Every custom-chip is a target too — Compile-All / Run-All build chips
// (WASM + ROM) alongside boards, so the flow works for a board + chip, for
// several chips with no board, etc.
const allCustomChips = useSimulatorStore
.getState()
.components.filter((c) => c.metadataId === 'custom-chip');
if (boardsList.length === 0 && allCustomChips.length === 0) return { ok: 0, failed: 0 };
setCompileAllRunning(true);
setConsoleOpen(true);
const targetSummary = [
boardsList.length ? `${boardsList.length} board${boardsList.length === 1 ? '' : 's'}` : '',
allCustomChips.length ? `${allCustomChips.length} chip${allCustomChips.length === 1 ? '' : 's'}` : '',
]
.filter(Boolean)
.join(' + ');
addLog({
timestamp: new Date(),
type: 'info',
message: `Compiling all ${boardsList.length} board${boardsList.length === 1 ? '' : 's'}...`,
message: `Compiling all targets (${targetSummary})...`,
});
// Make every custom-chip live (WASM + ROM) before compiling the boards,
// mirroring the single-board Compile path, and collect their program file
// names so they stay out of the arduino-cli builds below.
const allCustomChips = useSimulatorStore
.getState()
.components.filter((c) => c.metadataId === 'custom-chip');
const chipProgramFiles = new Set<string>();
for (const chip of allCustomChips) {
const pf = String((chip.properties as any)?.programFile ?? '').trim();
if (pf) chipProgramFiles.add(pf);
}
let chipFailed = 0;
if (allCustomChips.length > 0) {
const everyFile = boardsList.flatMap((b) =>
useEditorStore.getState().getGroupFiles(b.activeFileGroupId),
);
await prepareCustomChips(allCustomChips, everyFile);
chipFailed = (await prepareCustomChips(allCustomChips, everyFile)).failed;
}
let ok = 0;
let failed = 0;
let boardFailed = 0;
for (const board of boardsList) {
const label = boardDisplayName(board);
@ -918,7 +973,7 @@ export const EditorToolbar = ({
type: 'error',
message: `${label}: no FQBN configured`,
});
failed++;
boardFailed++;
continue;
}
@ -968,7 +1023,7 @@ export const EditorToolbar = ({
}
ok++;
} else {
failed++;
boardFailed++;
}
} catch (err) {
addLog({
@ -976,16 +1031,23 @@ export const EditorToolbar = ({
type: 'error',
message: `${label}: ${err instanceof Error ? err.message : String(err)}`,
});
failed++;
boardFailed++;
}
}
const failed = boardFailed + chipFailed;
const chipOk = allCustomChips.length - chipFailed;
const doneParts = [];
if (boardsList.length)
doneParts.push(`${ok} board${ok === 1 ? '' : 's'} ok${boardFailed > 0 ? `, ${boardFailed} failed` : ''}`);
if (allCustomChips.length)
doneParts.push(`${chipOk} chip${chipOk === 1 ? '' : 's'} ok${chipFailed > 0 ? `, ${chipFailed} failed` : ''}`);
addLog({
timestamp: new Date(),
type: ok > 0 && failed === 0 ? 'success' : failed > 0 ? 'error' : 'info',
message: `Done — ${ok} succeeded, ${failed} failed`,
type: failed > 0 ? 'error' : 'success',
message: `Done — ${doneParts.join('; ')}`,
});
if (ok > 0 && failed === 0) markCompiled();
if (failed === 0) markCompiled();
setCompileAllRunning(false);
return { ok, failed };
};
@ -995,14 +1057,28 @@ export const EditorToolbar = ({
void compileAllBoards();
};
/** Run All = compile all (if needed) + start every board, mirroring single Run. */
/**
* Run All = compile every target (boards + chips) if needed, then start every
* one: boards via startBoard, chips via restartParts (re-attach with the
* fresh WASM/ROM) + resuming the electrical solver when there's no board.
* Mirrors single Run, generalised across all targets.
*/
const handleRunAll = async () => {
const boardsList = useSimulatorStore.getState().boards;
if (boardsList.length === 0) return;
const sim = useSimulatorStore.getState();
const boardsList = sim.boards;
const chips = sim.components.filter((c) => c.metadataId === 'custom-chip');
if (boardsList.length === 0 && chips.length === 0) return;
// Compile if anything is missing a program or code changed since last compile
// A chip needs compiling when it has no WASM yet, or it references a program
// file but hasn't been assembled to ROM.
const chipNeedsCompile = chips.some((c) => {
const p = c.properties as Record<string, unknown>;
const programFile = String(p?.programFile ?? '').trim();
return !String(p?.wasmBase64 ?? '') || (programFile && !String(p?.romBytes ?? ''));
});
const needsCompile =
codeChangedSinceLastCompile ||
chipNeedsCompile ||
boardsList.some(
(b) =>
!isPiBoardKind(b.boardKind) &&
@ -1012,23 +1088,29 @@ export const EditorToolbar = ({
if (needsCompile) {
const { failed } = await compileAllBoards();
if (failed > 0) return; // Don't start anything if any board failed
if (failed > 0) return; // a board failed — don't start anything
}
// Refresh list after compile (compiledProgram may have changed)
// Start every board (compiledProgram may have changed during compile).
const refreshed = useSimulatorStore.getState().boards;
for (const board of refreshed) {
if (board.running) continue;
const isQemu =
isPiBoardKind(board.boardKind) ||
board.boardKind === 'esp32' ||
board.boardKind === 'esp32-s3';
if (isQemu || board.compiledProgram || board.languageMode === 'micropython') {
if (isQemuBoardKind(board.boardKind) || board.compiledProgram || board.languageMode === 'micropython') {
trackRunSimulation(board.boardKind);
reportRun(board.boardKind);
startBoard(board.id);
}
}
// Run the chips: re-attach so they pick up the freshly compiled WASM/ROM.
// The chip tick gates on a running board, so when NO board actually started
// (board-less, or a board that compiled to nothing) resume the electrical
// solver instead, otherwise the chips would stay frozen.
if (chips.length > 0) {
useSimulatorStore.getState().restartParts();
const anyBoardRunning = useSimulatorStore.getState().boards.some((b) => b.running);
if (!anyBoardRunning) setElectricalPaused(false);
}
};
const handleExport = async () => {
@ -1332,7 +1414,7 @@ export const EditorToolbar = ({
{/* Stop */}
<button
onClick={handleStop}
disabled={isBoardless ? !digitalRunning : !running}
disabled={isBoardless ? !digitalRunning : !anyBoardRunning}
className="tb-btn tb-btn-stop"
title={isBoardless ? 'Freeze digital simulation' : t('editor.toolbar.stop')}
>
@ -1363,11 +1445,11 @@ export const EditorToolbar = ({
</svg>
</button>
{boards.length > 1 && (
{targetCount > 1 && (
<>
<div className="tb-divider" />
{/* Compile All */}
{/* Compile All — boards + programmable chips */}
<button
onClick={handleCompileAll}
disabled={compileAllRunning}
@ -1392,7 +1474,7 @@ export const EditorToolbar = ({
{/* Run All */}
<button
onClick={handleRunAll}
disabled={running}
disabled={compileAllRunning || anyBoardRunning || digitalRunning}
className="tb-btn tb-btn-run-all"
title={t('editor.toolbar.runAll')}
>