Claude
feat: block interpreter with Run/Stop, logic branches, and step budget
b0f6cf1 unverified
Raw
History Blame Contribute Delete
2.84 kB
// 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<RunResult> | null = null;
constructor(
private workspace: Blockly.Workspace,
private robot: RobotAdapter,
private opts: RunnerOptions,
) {}
get running(): boolean {
return this.current !== null;
}
run(): Promise<RunResult> {
if (this.current) return this.current;
const controller = new AbortController();
this.controller = controller;
this.opts.hooks?.onRunStateChange?.(true);
this.current = (async (): Promise<RunResult> => {
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<void> {
const current = this.current;
if (!current) return;
this.controller?.abort();
await current;
}
}