// Run/Stop lifecycle around executeProgram: one run at a time, abort on // stop, and a guaranteed cleanup path (stopAll → returnHome → unhighlight) // no matter how the run ended. import type * as Blockly from "blockly"; import { AbortError, type RobotAdapter } from "../robot/adapter"; import type { EmotionSource, SoundSource } from "./commands"; import { executeProgram, findStart } from "./interpreter"; export type RunResult = "finished" | "stopped" | "error"; export interface RunnerHooks { onHighlight?(blockId: string | null): void; onRunStateChange?(running: boolean): void; } export interface RunnerOptions { emotions: EmotionSource; sounds: SoundSource; random?: () => number; maxSteps?: number; hooks?: RunnerHooks; } export class Runner { private controller: AbortController | null = null; private current: Promise | null = null; constructor( private workspace: Blockly.Workspace, private robot: RobotAdapter, private opts: RunnerOptions, ) {} get running(): boolean { return this.current !== null; } run(): Promise { if (this.current) return this.current; const controller = new AbortController(); this.controller = controller; this.opts.hooks?.onRunStateChange?.(true); this.current = (async (): Promise => { try { if (!findStart(this.workspace)) { // No start hat: a friendly shrug so the tap still "does something". await this.robot.goto({ headRoll: 8 }, 0.2); await this.robot.goto({ headRoll: 0 }, 0.25); return "finished"; } await executeProgram(this.workspace, { robot: this.robot, signal: controller.signal, random: this.opts.random, emotions: this.opts.emotions, sounds: this.opts.sounds, maxSteps: this.opts.maxSteps, hooks: { onHighlight: (id) => this.opts.hooks?.onHighlight?.(id) }, }); return "finished"; } catch (err) { if (err instanceof AbortError) return "stopped"; console.error("[reachy-blocks] run failed", err); return "error"; } finally { try { await this.robot.stopAll(); } catch { /* cleanup is best-effort */ } try { await this.robot.returnHome(); } catch { /* cleanup is best-effort */ } this.opts.hooks?.onHighlight?.(null); this.controller = null; this.current = null; this.opts.hooks?.onRunStateChange?.(false); } })(); return this.current; } /** Abort the active run and wait for its cleanup to complete. */ async stop(): Promise { const current = this.current; if (!current) return; this.controller?.abort(); await current; } }