diff --git a/changes/unreleased/engine-state-advance.added.md b/changes/unreleased/engine-state-advance.added.md new file mode 100644 index 0000000000..cc93a61858 --- /dev/null +++ b/changes/unreleased/engine-state-advance.added.md @@ -0,0 +1 @@ +- The in-browser engine (`sysml-engine`) honours `advance` on `ExecuteState` and `ExecuteAction` as `sysml-grpc` does, running a state machine or an action for a simulated duration, through one runtime implementation both share; the homepage hero gains a "Mission phases on the clock" scene that runs `MissionSequence::MissionPhases` on the engine for 400,000 s (or 800,000 s) and animates the trace it records, clock instants included. diff --git a/editors/vscode/src/landing/main.ts b/editors/vscode/src/landing/main.ts index 0c65bd56e9..1945c34b7b 100644 --- a/editors/vscode/src/landing/main.ts +++ b/editors/vscode/src/landing/main.ts @@ -23,7 +23,9 @@ import { connectModule, moduleUrls } from "./connect"; import { debugSteps, journey, + runClock, runJourney, + stateIdOf, type DebugStep, type JourneyRun, type LandingModel, @@ -32,8 +34,8 @@ import { import { carried, obstacles, resettle } from "./carry"; import { download, exportFrame, exportSvg, rasterSize, rasterize } from "./export"; import { quantityText } from "./present"; -import { SCENES, STACK_SCENE, chooseScene, type Scene, type SceneDiagram } from "./scene"; -import { message, mountShell, type Mounted, type MountOptions } from "./shell"; +import { SCENES, STACK_SCENE, chooseScene, type Scene, type SceneClock, type SceneDiagram } from "./scene"; +import { message, mountShell, shortName, type Mounted, type MountOptions } from "./shell"; import { mountTable } from "./table"; import { wireTabs } from "./tabs"; import { ZOOM_STEP, pannedView, pinchedView, refittedView, zoomOf, zoomedView, type View } from "./zoom"; @@ -135,6 +137,10 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou const queueEl = debugPanel.querySelector("[data-osml-debug-queue]")!; const traceEl = debugPanel.querySelector("[data-osml-debug-trace]")!; const journeyProgram = scene.program.kind === "journey" ? scene.program : undefined; + // A clocked run takes no events and makes no free choices: the controls for those are hidden. + for (const control of debugPanel.querySelectorAll("[data-osml-debug-events]")) { + control.hidden = journeyProgram === undefined; + } debugBtn.setAttribute("aria-expanded", "false"); debugBtn.textContent = "Debug the run"; debugPanel.hidden = true; @@ -172,6 +178,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou placed.clear(); settle(); }, + reprogram: () => forgetRun(), syncControls: () => syncDebugControls(), }); const { status, engine, runnable } = shell; @@ -196,7 +203,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou let token: SVGCircleElement | undefined; const partOf = (id: string): LandingPart | undefined => [...model.parts.values()].find((part) => part.id === id); - const idOf = (feature: string): string | undefined => model.parts.get(feature)?.id; + const idOf = (state: string): string | undefined => stateIdOf(model, state); // projects is the parts drawn at the top level, which are the ones a visitor can move. const projects = (): LandingPart[] => [...model.parts.values()].filter((part) => part.owner === undefined); @@ -375,8 +382,14 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou } function decorate(): void { + const stateIds = new Set([...(model.machine?.states.values() ?? [])].map((state) => state.id)); for (const group of content.querySelectorAll("g.opensysml-node")) { - const part = partOf(group.dataset.opensysmlId ?? ""); + const id = group.dataset.opensysmlId ?? ""; + if (stateIds.has(id)) { + group.classList.add("osml-state"); + continue; + } + const part = partOf(id); if (!part) { continue; } @@ -464,7 +477,11 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou if (cardFor) { fillCard(cardFor); } - // A debugger run belongs to the model it ran; a new model runs afresh. + forgetRun(); + } + + // forgetRun drops a debugger run that belongs to another model or program; an open panel runs afresh. + function forgetRun(): void { if (debugPanel.hidden) { run = undefined; } else if (rerunning === undefined) { @@ -1068,6 +1085,12 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou if (!current) { return; } + const program = shell.program; + if (program.kind === "clock") { + await runClocked(program, current); + ran = true; + return; + } const picked = seed === undefined || seedPicked; if (picked) { seed = randomSeed(); @@ -1109,9 +1132,53 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou })(); }); + const secondsText = (seconds: number): string => `${seconds.toLocaleString("en-US")} s`; + + // runClocked performs the machine on the clock and replays the trace the engine recorded: + // each state lit as its entry record comes, the token on each transition, the status + // line reading the clock from the records. + async function runClocked(program: SceneClock, current: LandingModel): Promise { + const started = performance.now(); + const result = await runClock(await engine(), program, current); + const ms = Math.max(1, Math.round(performance.now() - started)); + run = result; + runSeed = seed; + steps = debugSteps(result.trace); + fillTrace(); + if (result.error !== undefined) { + throw new Error(result.error); + } + const machine = shortName(program.symbol); + const path = result.visited.map(stateLabel).join(" → "); + const ran = `ExecuteState ran ${machine} on ${shortName(program.performer)} for ${secondsText(program.advance)} in ${ms} ms: ${path}`; + status(`${ran}.`); + runBtn.textContent = "▶ Run it again"; + for (let index = 0; index < steps.length && !signal.aborted; index += 1) { + const step = steps[index]; + const clock = step.record.time === undefined ? "" : `t = ${quantityText(step.record.time)} · `; + status(`${clock}${step.record.text}`); + await showStep(index, !reduceMotion.matches); + await delay(reduceMotion.matches ? 250 : 400); + } + if (signal.aborted) { + return; + } + const last = result.visited[result.visited.length - 1]; + const stopped = result.finalTime === undefined ? "" : ` The clock stopped at t = ${secondsText(result.finalTime)}`; + const final = last === undefined ? undefined : model.machine?.states.get(last); + const reached = steps.filter((step) => step.edge?.to === last && step.record.time !== undefined).pop(); + const ending = + final?.kind === "final" + ? `; the machine completed when a transition reached ${stateLabel(last)}${reached ? ` at t = ${quantityText(reached.record.time!)}` : ""}.` + : last === undefined + ? "." + : ` with the machine still in ${stateLabel(last)}.`; + status(`${ran}.${stopped}${ending}`); + } + // ---- debugging the model ---- - // Every change re-runs ModelJourney from the start on the engine with all the events - // sent so far; the panel then steps through that run's trace record by record. + // Every change re-runs the program from the start on the engine, a journey with all the + // events sent so far; the panel then steps through that run's trace record by record. const journeyEvents: readonly string[] = journeyProgram?.events ?? []; let sent: string[] = [...journeyEvents]; @@ -1134,10 +1201,11 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou function recordText(step: DebugStep): string { const { record } = step; + const at = record.time === undefined ? "" : `t = ${quantityText(record.time)} · `; if (step.ignored) { - return `${record.text}: ignored, no transition out of ${stateLabel(step.state)} accepts it`; + return `${at}${record.text}: ignored, no transition out of ${stateLabel(step.state)} accepts it`; } - return record.text; + return `${at}${record.text}`; } function fillTrace(): void { @@ -1198,7 +1266,9 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou const position = steps.length === 0 ? "no records" : `record ${cursor + 1} of ${steps.length}`; const where = step === undefined ? "Not started" : `In ${stateLabel(step.state)}`; const choice = step?.record.kind === "choice" ? `, chose ${step.record.taken ?? "?"}` : ""; - nowEl.textContent = `${where}${choice} · ${position} · ${accepted} of ${sent.length} events taken`; + const clock = step?.record.time === undefined ? "" : ` · t = ${quantityText(step.record.time)}`; + const taken = journeyProgram === undefined ? "" : ` · ${accepted} of ${sent.length} events taken`; + nowEl.textContent = `${where}${choice}${clock} · ${position}${taken}`; syncDebugControls(); if (!animated || liveEdge === undefined || reduceMotion.matches || !step?.edge) { dropToken(); @@ -1229,15 +1299,22 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou // This run uses the latest model, including one its own read adopted. rerunStale = false; const started = performance.now(); - run = await runJourney(client, scene, current, sent, seed); + const program = shell.program; + const machine = shortName(program.kind === "rollup" ? scene.rootSymbol : program.symbol); + run = program.kind === "clock" + ? await runClock(client, program, current) + : await runJourney(client, scene, current, sent, seed); runSeed = seed; steps = debugSteps(run.trace); const ms = Math.max(1, Math.round(performance.now() - started)); const schedule = seed === undefined ? "the default schedule" : `seed ${seed}`; + const over = program.kind === "clock" + ? `on ${shortName(program.performer)} for ${secondsText(program.advance)}` + : `on ${sent.length} events under ${schedule}`; status( run.error ? `ExecuteState failed after ${steps.length} trace records: ${run.error}` - : `ExecuteState ran ModelJourney on ${sent.length} events under ${schedule} in ${ms} ms: ${steps.length} trace records.`, + : `ExecuteState ran ${machine} ${over} in ${ms} ms: ${steps.length} trace records.`, run.error !== undefined, ); fillTrace(); diff --git a/editors/vscode/src/landing/model.test.ts b/editors/vscode/src/landing/model.test.ts index e62becccee..cf8627b31d 100644 --- a/editors/vscode/src/landing/model.test.ts +++ b/editors/vscode/src/landing/model.test.ts @@ -7,17 +7,20 @@ import { ExecutionError, renderedViewFromJson } from "@openmbee/opensysml/browse import type { RenderResult } from "../protocol"; import { EngineInstance, + clockOf, debugSteps, landingModel, journey, readModel, renderResult, + runClock, runJourney, + stateIdOf, TraceRecord, journeyOf, sceneTarget, } from "./model"; -import { STACK_SCENE } from "./scene"; +import { MISSION_PHASES_SCENE, STACK_SCENE } from "./scene"; interface LandingFixture { hash: string; @@ -29,6 +32,8 @@ interface LandingFixture { const fixture = JSON.parse(readFileSync("src/landing/stack.json", "utf8")) as LandingFixture; const fixtureView = renderedViewFromJson(fixture.render); const fixtureRender: RenderResult = renderResult(fixtureView); +const phasesFixture = JSON.parse(readFileSync("src/landing/phases.json", "utf8")) as LandingFixture; +const phasesRender: RenderResult = renderResult(renderedViewFromJson(phasesFixture.render)); const PROJECTS = ["flexo", "opensysml", "pilot"]; const COMPONENTS: Record = { @@ -109,8 +114,12 @@ function typedInstance(instance: EngineInstance): Instance { } function traceEvent(record: TraceRecord): DocumentEvent { + const time = record.time === undefined + ? undefined + : { kind: "quantity", magnitude: { kind: "real", value: Number(record.time.magnitude) }, unit: record.time.unit }; return { kind: record.kind, + time, state: record.state ?? "", fromState: record.from ?? "", toState: record.to ?? "", @@ -130,6 +139,7 @@ interface FakeAnswers { diagnostics?: unknown[]; statesVisited?: string[]; trace?: TraceRecord[]; + finalTime?: number; executeError?: Error; /** Features of the root the instantiation could not value, by the error it gives each. */ rootErrors?: Record; @@ -172,6 +182,7 @@ function fakeConnection(answers: FakeAnswers = {}): { connection: Connection; ca return Promise.resolve({ statesVisited: answers.statesVisited ?? [], trace: (answers.trace ?? []).map(traceEvent), + finalTime: answers.finalTime ?? 0, }); }, }; @@ -315,6 +326,91 @@ test("runJourney returns the partial trace of a failed run and rethrows other er await assert.rejects(runJourney(unavailable.connection, STACK_SCENE, model, ["Commit"]), /engine unavailable/); }); +test("landingModel lifts a state view's states out of the machine's box and keys them by name", () => { + const model = landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, phasesRender, phasesFixture.instances); + assert.ok(model.machine, "a state view gives a machine"); + assert.equal(model.machine.symbol, "MissionSequence::MissionPhases"); + assert.deepEqual( + [...model.machine.states.keys()], + ["prelaunch", "translunarCoast", "lunarOrbit", "onSurface", "transEarthCoast", "recovered", "done"], + ); + assert.equal(model.machine.states.get("prelaunch")?.initial, true); + assert.equal(model.machine.states.get("onSurface")?.path, "onSurface"); + assert.equal(model.machine.states.get("onSurface")?.initial, false); + assert.equal(model.machine.states.get("done")?.kind, "final"); + assert.equal(model.parts.size, 0, "a state is not a part"); + assert.equal(model.render.nodes.length, 8, "six states, the start and the final"); + assert.ok(model.render.nodes.every((node) => node.parent === undefined), "the states are drawn at the top level"); + assert.equal(model.render.edges.length, 7); + assert.deepEqual( + model.render.edges.map((edge) => edge.label), + ["", "after 9000 [s]", "after 270000 [s]", "after 100000 [s]", "after 78000 [s]", "after 245000 [s]", ""], + ); + assert.equal(model.root.symbol, "MissionSequence::mission", "the root is the object the machine runs on"); + assert.equal(model.root.feature, "mission"); + assert.equal(stateIdOf(model, "onSurface"), model.machine.states.get("onSurface")?.id); + assert.equal(stateIdOf(model, "nowhere"), undefined); + const stack = landingModel(sceneTarget(STACK_SCENE), fixture.hash, fixtureRender, fixture.instances); + assert.equal(stack.machine, undefined); + + // A state nested in a composite stays under it, keyed by its path and, being the only `landing`, by name. + const surface = model.machine.states.get("onSurface")!; + const nested: RenderResult = { + ...phasesRender, + nodes: [ + ...phasesRender.nodes, + { id: "n9", kind: "state", name: "landing", type: "", detail: "", parent: surface.id }, + { id: "n10", kind: "state", name: "prelaunch", type: "", detail: "", parent: surface.id }, + ], + edges: [...phasesRender.edges, { from: surface.id, to: "n9", kind: "transition", label: "" }], + }; + const composite = landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, nested, phasesFixture.instances); + assert.equal(composite.render.nodes.find((node) => node.id === "n9")?.parent, surface.id, "a nested state keeps its parent"); + assert.equal(composite.render.edges.filter((edge) => edge.to === "n9").length, 1, "the transition into it is kept"); + assert.equal(composite.machine?.states.get("onSurface.landing")?.id, "n9"); + assert.equal(stateIdOf(composite, "landing"), "n9"); + assert.equal(composite.machine?.states.get("onSurface.prelaunch")?.id, "n10"); + assert.equal(stateIdOf(composite, "prelaunch"), model.machine.states.get("prelaunch")?.id, "a repeated name stays with the machine's own state"); + assert.equal(stateIdOf(stack, "flexo"), stack.parts.get("flexo")?.id, "a journey's states name parts"); + assert.throws( + () => landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, { ...phasesRender, nodes: [] }, phasesFixture.instances), + /draws 0 machines/u, + ); +}); + +test("runClock performs the machine on its object for the duration and keeps each record's instant", async () => { + const model = landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, phasesRender, phasesFixture.instances); + const trace: TraceRecord[] = [ + { kind: "entry", state: "prelaunch", time: { magnitude: "0", unit: "s" }, text: "enter: prelaunch" }, + { + kind: "transition", + from: "prelaunch", + to: "translunarCoast", + event: "time", + time: { magnitude: "9000", unit: "s" }, + text: "transition: prelaunch -> translunarCoast (event: time)", + }, + ]; + const { connection, calls } = fakeConnection({ statesVisited: ["prelaunch", "translunarCoast"], finalTime: 400000, trace }); + const program = clockOf(MISSION_PHASES_SCENE.program); + const run = await runClock(connection, program, model); + assert.deepEqual(calls[0], { + method: "ExecuteState", + params: [phasesFixture.hash, "MissionSequence::MissionPhases", { performer: "MissionSequence::mission", advance: 400000, trace: true }], + }); + assert.deepEqual(run, { visited: ["prelaunch", "translunarCoast"], trace, finalTime: 400000 }); + assert.throws(() => clockOf(STACK_SCENE.program), /runs no clock/u); + + const failed = fakeConnection({ + executeError: new ExecutionError("state machine execution failed: budget", "unspecified", [], trace.map(traceEvent)), + }); + assert.deepEqual(await runClock(failed.connection, program, model), { + visited: [], + trace, + error: "state machine execution failed: budget", + }); +}); + test("debugSteps tracks active state, transitions, accepts, and ignored events", () => { const trace: TraceRecord[] = [ { kind: "entry", state: "flexo", text: "entry flexo" }, diff --git a/editors/vscode/src/landing/model.ts b/editors/vscode/src/landing/model.ts index 0a98f6a3c4..d28f5336ea 100644 --- a/editors/vscode/src/landing/model.ts +++ b/editors/vscode/src/landing/model.ts @@ -1,6 +1,7 @@ import type { Connection, DocumentEvent, + DocumentValue, Instance, Magnitude, ModelDiagnostic, @@ -10,7 +11,7 @@ import type { import { ExecutionError, Model, ParseError, instanceFromJson, renderedViewFromJson } from "@openmbee/opensysml/browser"; import { normalizeRender } from "../protocol"; import type { RenderEdge, RenderNode, RenderResult } from "../protocol"; -import type { Scene, SceneJourney } from "./scene"; +import type { Scene, SceneClock, SceneJourney, SceneProgram } from "./scene"; export interface Diagnostic { message: string; @@ -38,6 +39,27 @@ export interface LandingPart { owner?: string; } +/** A state of the machine a scene draws: its box, and the name the trace records it under. */ +export interface LandingState { + id: string; + /** The state's name as the trace records carry it: `prelaunch`; the start pseudostate has none. */ + name: string; + /** The names from the machine down to this state, dotted: `onSurface.landing`; the name itself at the top. */ + path: string; + /** What RenderView drew it as: `start`, `state` or `final`. */ + kind: string; + /** Whether the machine enters this state first, as RenderView details it. */ + initial: boolean; +} + +/** The state machine a scene draws: its states by path and, where that is unambiguous, by name. */ +export interface LandingMachine { + /** The state definition drawn: `MissionSequence::MissionPhases`. */ + symbol: string; + /** Keyed by `LandingState.path`; a nested state is also keyed by its bare name when no other state has it. */ + states: Map; +} + /** What the root is drawn as: its symbol and the view RenderView draws of it. */ export interface SceneTarget { rootSymbol: string; @@ -52,6 +74,8 @@ export interface LandingModel { parts: Map; /** The root itself, which the parts compose; it is not drawn as a box, so it is not among `parts`. */ root: LandingPart; + /** The state machine the view draws, when it draws one rather than the root's composition. */ + machine?: LandingMachine; } export type ModelRead = { diagnostics: Diagnostic[]; model?: LandingModel }; @@ -90,12 +114,16 @@ export interface TraceRecord { event?: string; alternatives?: string[]; taken?: string; + /** The simulated instant the record was written at, when the engine timed it. */ + time?: EngineQuantity; text: string; } export interface JourneyRun { visited: string[]; trace: TraceRecord[]; + /** The simulated instant the clock stopped at; absent when the run failed. */ + finalTime?: number; error?: string; } @@ -254,10 +282,23 @@ function diagnosticOf(diagnostic: ModelDiagnostic): Diagnostic { }; } +/** recordTime is the instant a trace record carries, as the page prints it; undefined when untimed. */ +function recordTime(time: DocumentValue): EngineQuantity | undefined { + if (typeof time === "number" || typeof time === "bigint") { + return { magnitude: time.toString(), unit: "" }; + } + if (typeof time === "object" && "magnitude" in time && "unit" in time) { + return { magnitude: magnitudeText(time.magnitude), unit: time.unit }; + } + return undefined; +} + /** Shapes a trace event as the debugger's record; absent fields are omitted. */ export function traceRecord(event: DocumentEvent): TraceRecord { + const time = recordTime(event.time); return { kind: event.kind, + ...(time === undefined ? {} : { time }), ...(event.state === "" ? {} : { state: event.state }), ...(event.fromState === "" ? {} : { from: event.fromState }), ...(event.toState === "" ? {} : { to: event.toState }), @@ -282,6 +323,9 @@ export function landingModel( render: RenderResult, instances: readonly EngineInstance[], ): LandingModel { + if (render.kind === "state") { + return machineModel(target, hash, render, instances); + } const { rootSymbol } = target; const rootName = rootSymbol.slice(rootSymbol.lastIndexOf("::") + 2); const root = render.nodes.find( @@ -303,32 +347,8 @@ export function landingModel( const nodes: RenderNode[] = []; const parts = new Map(); - const partOf = (node: RenderNode, instance: EngineInstance | undefined, path: string, owner: string | undefined): LandingPart => { - const attrs: Record = {}; - const quantities: Record = {}; - const unvalued: Record = {}; - for (const [name, featureValue] of Object.entries(instance?.featureValues ?? {})) { - if (featureValue.value?.stringValue !== undefined) { - attrs[name] = featureValue.value.stringValue; - } else if (featureValue.value?.quantity !== undefined) { - quantities[name] = featureValue.value.quantity; - } else if (featureValue.error !== undefined) { - unvalued[name] = featureValue.error; - } - } - attrs.label ??= node.name; - return { - id: node.id, - feature: node.name, - path, - symbol: instance?.typeSymbolId ?? node.type, - type: node.type, - attrs, - quantities, - unvalued, - ...(owner === undefined ? {} : { owner }), - }; - }; + const partOf = (node: RenderNode, instance: EngineInstance | undefined, path: string, owner: string | undefined): LandingPart => + partRecord(node.id, node.name, node.type, instance, path, owner); // place draws `drawn`, a copy of `source` standing for `instance`, then the boxes below it; // `suffix` is what the copies above it added to their ids, so each copy's subtree has its own. @@ -412,6 +432,107 @@ export function landingModel( return { hash, target, render: normalized, parts, root: { ...partOf(root, rootInstance, rootName, undefined), feature: rootName } }; } +/** partRecord is one object as the page keeps it: the instance's values under the box drawn for it. */ +function partRecord( + id: string, + feature: string, + type: string, + instance: EngineInstance | undefined, + path: string, + owner: string | undefined, +): LandingPart { + const attrs: Record = {}; + const quantities: Record = {}; + const unvalued: Record = {}; + for (const [name, featureValue] of Object.entries(instance?.featureValues ?? {})) { + if (featureValue.value?.stringValue !== undefined) { + attrs[name] = featureValue.value.stringValue; + } else if (featureValue.value?.quantity !== undefined) { + quantities[name] = featureValue.value.quantity; + } else if (featureValue.error !== undefined) { + unvalued[name] = featureValue.error; + } + } + attrs.label ??= feature; + return { + id, + feature, + path, + symbol: instance?.typeSymbolId ?? type, + type, + attrs, + quantities, + unvalued, + ...(owner === undefined ? {} : { owner }), + }; +} + +/** + * machineModel is a state view as the page keeps it: the machine's own states lifted out of + * its box, the states and regions nested in them kept where they are, the transitions kept, + * and the root the object the machine runs on, as Instantiate materialized it. + */ +function machineModel( + target: SceneTarget, + hash: string, + render: RenderResult, + instances: readonly EngineInstance[], +): LandingModel { + const { rootSymbol } = target; + const rootName = rootSymbol.slice(rootSymbol.lastIndexOf("::") + 2); + const machines = render.nodes.filter((node) => node.parent === undefined); + if (machines.length !== 1) { + throw new Error(`rendered state view draws ${machines.length} machines, want one`); + } + const [machine] = machines; + const states = new Map(); + const byName = new Map(); + const nodes: RenderNode[] = []; + // keep walks below `parent`: its children lifted when it is the machine, nested ones left in place. + const keep = (parent: RenderNode, above: string | undefined): void => { + for (const node of render.nodes) { + if (node.parent !== parent.id) { + continue; + } + const { parent: _parent, ...lifted } = node; + nodes.push(parent === machine ? lifted : node); + const path = node.name === "" ? above : above === undefined ? node.name : `${above}.${node.name}`; + if (node.name !== "" && path !== undefined && node.kind !== "region") { + const state: LandingState = { id: node.id, name: node.name, path, kind: node.kind, initial: node.detail === "initial" }; + states.set(path, state); + byName.set(node.name, [...(byName.get(node.name) ?? []), state]); + } + keep(node, path); + } + }; + keep(machine, undefined); + for (const [name, named] of byName) { + if (named.length === 1 && !states.has(name)) { + states.set(name, named[0]); + } + } + const retained = new Set(nodes.map((node) => node.id)); + const normalized = normalizeRender({ + ...render, + nodes, + edges: render.edges.filter((edge) => retained.has(edge.from) && retained.has(edge.to)), + }); + const rootInstance = instances.find((instance) => instance.typeSymbolId === rootSymbol); + return { + hash, + target, + render: normalized, + parts: new Map(), + root: partRecord(machine.id, rootName, "", rootInstance, rootName, undefined), + machine: { symbol: machine.name, states }, + }; +} + +/** stateIdOf is the box a state the engine named is drawn as: a machine's state (by path or unambiguous name), or the part a journey's state stands for. */ +export function stateIdOf(model: LandingModel, state: string): string | undefined { + return model.machine?.states.get(state)?.id ?? model.parts.get(state)?.id; +} + /** sceneTarget is the root a scene draws by default, before any variant is switched on. */ export function sceneTarget(scene: Scene): SceneTarget { return { rootSymbol: scene.rootSymbol, view: scene.view }; @@ -531,11 +652,36 @@ export async function journey( ...(seed === undefined ? {} : { schedule: `seed:${seed}` }), }); return result.statesVisited.flatMap((state) => { - const id = model.parts.get(state)?.id; + const id = stateIdOf(model, state); return id === undefined ? [] : [id]; }); } +/** clockOf is the clocked run a scene's program is; another program has none. */ +export function clockOf(program: SceneProgram): SceneClock { + if (program.kind !== "clock") { + throw new Error(`a ${program.kind} program runs no clock`); + } + return program; +} + +/** Runs the program's machine, performed by its object, for its simulated duration, with the trace. */ +export async function runClock(connection: Connection, program: SceneClock, model: LandingModel): Promise { + try { + const result = await connection.executeState(model.hash, program.symbol, { + performer: program.performer, + advance: program.advance, + trace: true, + }); + return { visited: result.statesVisited, trace: result.trace.map(traceRecord), finalTime: result.finalTime }; + } catch (error) { + if (error instanceof ExecutionError) { + return { visited: [], trace: error.trace.map(traceRecord), error: errorMessage(error) }; + } + throw error; + } +} + /** Runs the scene's state machine on `events` with its trace; a seed picks each free choice. */ export async function runJourney( connection: Connection, diff --git a/editors/vscode/src/landing/phases.json b/editors/vscode/src/landing/phases.json new file mode 100644 index 0000000000..2f319e7577 --- /dev/null +++ b/editors/vscode/src/landing/phases.json @@ -0,0 +1,642 @@ +{ + "hash": "b861900f257122580e56514dfdf1b5a42906cbbfc7245c41b4fb900b19ade2b3", + "render": { + "kind": "state", + "stated": "no view declared; rendering MissionSequence::MissionPhases directly", + "nodes": [ + { + "id": "n0", + "kind": "state def", + "name": "MissionSequence::MissionPhases", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 57, + "startCol": 5, + "endLine": 73, + "endCol": 6 + } + }, + { + "id": "n8", + "kind": "start", + "parent": "n0" + }, + { + "id": "n1", + "kind": "state", + "name": "prelaunch", + "detail": "initial", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 60, + "startCol": 9, + "endLine": 60, + "endCol": 25 + } + }, + { + "id": "n2", + "kind": "state", + "name": "translunarCoast", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 61, + "startCol": 9, + "endLine": 61, + "endCol": 31 + } + }, + { + "id": "n3", + "kind": "state", + "name": "lunarOrbit", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 62, + "startCol": 9, + "endLine": 62, + "endCol": 26 + } + }, + { + "id": "n4", + "kind": "state", + "name": "onSurface", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 63, + "startCol": 9, + "endLine": 63, + "endCol": 25 + } + }, + { + "id": "n5", + "kind": "state", + "name": "transEarthCoast", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 64, + "startCol": 9, + "endLine": 64, + "endCol": 31 + } + }, + { + "id": "n6", + "kind": "state", + "name": "recovered", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 65, + "startCol": 9, + "endLine": 65, + "endCol": 25 + } + }, + { + "id": "n7", + "kind": "final", + "name": "done", + "nameSynthesized": true, + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 72, + "startCol": 41, + "endLine": 72, + "endCol": 45 + } + } + ], + "edges": [ + { + "from": "n8", + "to": "n1", + "kind": "transition", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 58, + "startCol": 16, + "endLine": 58, + "endCol": 31 + } + }, + { + "from": "n1", + "to": "n2", + "label": "after 9000 [s]", + "kind": "transition", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 67, + "startCol": 9, + "endLine": 67, + "endCol": 79 + } + }, + { + "from": "n2", + "to": "n3", + "label": "after 270000 [s]", + "kind": "transition", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 68, + "startCol": 9, + "endLine": 68, + "endCol": 82 + } + }, + { + "from": "n3", + "to": "n4", + "label": "after 100000 [s]", + "kind": "transition", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 69, + "startCol": 9, + "endLine": 69, + "endCol": 76 + } + }, + { + "from": "n4", + "to": "n5", + "label": "after 78000 [s]", + "kind": "transition", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 70, + "startCol": 9, + "endLine": 70, + "endCol": 80 + } + }, + { + "from": "n5", + "to": "n6", + "label": "after 245000 [s]", + "kind": "transition", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 71, + "startCol": 9, + "endLine": 71, + "endCol": 81 + } + }, + { + "from": "n6", + "to": "n7", + "kind": "transition", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 72, + "startCol": 9, + "endLine": 72, + "endCol": 46 + } + } + ], + "shown": [ + "port-symbols", + "compartments", + "edge-labels", + "keywords" + ] + }, + "instances": [ + { + "id": "1", + "typeSymbolId": "MissionSequence::mission", + "featureValues": { + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates", + "values": [ + { + "instanceId": "2" + } + ], + "materialized": true + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "performedActions": { + "featureName": "performedActions", + "values": [ + { + "instanceId": "2" + } + ], + "materialized": true + }, + "phases": { + "featureName": "phases", + "value": { + "instanceId": "2" + }, + "materialized": true + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "2", + "typeSymbolId": "MissionSequence::MissionPhases", + "featureValues": { + "acceptSubactions": { + "featureName": "acceptSubactions", + "materialized": true + }, + "assignments": { + "featureName": "assignments", + "materialized": true + }, + "controls": { + "featureName": "controls", + "materialized": true + }, + "decisionTransitions": { + "featureName": "decisionTransitions", + "materialized": true + }, + "decisions": { + "featureName": "decisions", + "materialized": true + }, + "doAction": { + "featureName": "doAction", + "value": { + "instanceId": "3" + }, + "materialized": true + }, + "entryAction": { + "featureName": "entryAction", + "value": { + "instanceId": "4" + }, + "materialized": true + }, + "exclusiveStates": { + "featureName": "exclusiveStates", + "materialized": true + }, + "exitAction": { + "featureName": "exitAction", + "value": { + "instanceId": "5" + }, + "materialized": true + }, + "forLoops": { + "featureName": "forLoops", + "materialized": true + }, + "forks": { + "featureName": "forks", + "materialized": true + }, + "ifSubactions": { + "featureName": "ifSubactions", + "materialized": true + }, + "isTriggerDuring": { + "featureName": "isTriggerDuring", + "value": { + "boolValue": true + }, + "materialized": true + }, + "joins": { + "featureName": "joins", + "materialized": true + }, + "loops": { + "featureName": "loops", + "materialized": true + }, + "lunarOrbit": { + "featureName": "lunarOrbit" + }, + "merges": { + "featureName": "merges", + "materialized": true + }, + "onSurface": { + "featureName": "onSurface" + }, + "prelaunch": { + "featureName": "prelaunch" + }, + "recovered": { + "featureName": "recovered" + }, + "sendSubactions": { + "featureName": "sendSubactions", + "materialized": true + }, + "stateTransitions": { + "featureName": "stateTransitions", + "materialized": true + }, + "subactions": { + "featureName": "subactions", + "values": [ + { + "instanceId": "3" + } + ], + "materialized": true + }, + "substates": { + "featureName": "substates", + "materialized": true + }, + "terminateSubactions": { + "featureName": "terminateSubactions", + "materialized": true + }, + "transEarthCoast": { + "featureName": "transEarthCoast" + }, + "transitions": { + "featureName": "transitions", + "materialized": true + }, + "translunarCoast": { + "featureName": "translunarCoast" + }, + "whileLoops": { + "featureName": "whileLoops", + "materialized": true + } + } + }, + { + "id": "3", + "typeSymbolId": "Actions::Action", + "featureValues": { + "acceptSubactions": { + "featureName": "acceptSubactions", + "materialized": true + }, + "assignments": { + "featureName": "assignments", + "materialized": true + }, + "controls": { + "featureName": "controls", + "materialized": true + }, + "decisionTransitions": { + "featureName": "decisionTransitions", + "materialized": true + }, + "decisions": { + "featureName": "decisions", + "materialized": true + }, + "forLoops": { + "featureName": "forLoops", + "materialized": true + }, + "forks": { + "featureName": "forks", + "materialized": true + }, + "ifSubactions": { + "featureName": "ifSubactions", + "materialized": true + }, + "joins": { + "featureName": "joins", + "materialized": true + }, + "loops": { + "featureName": "loops", + "materialized": true + }, + "merges": { + "featureName": "merges", + "materialized": true + }, + "sendSubactions": { + "featureName": "sendSubactions", + "materialized": true + }, + "subactions": { + "featureName": "subactions", + "materialized": true + }, + "terminateSubactions": { + "featureName": "terminateSubactions", + "materialized": true + }, + "transitions": { + "featureName": "transitions", + "materialized": true + }, + "whileLoops": { + "featureName": "whileLoops", + "materialized": true + } + } + }, + { + "id": "4", + "typeSymbolId": "States::StateAction::entryAction", + "featureValues": { + "acceptSubactions": { + "featureName": "acceptSubactions", + "materialized": true + }, + "assignments": { + "featureName": "assignments", + "materialized": true + }, + "controls": { + "featureName": "controls", + "materialized": true + }, + "decisionTransitions": { + "featureName": "decisionTransitions", + "materialized": true + }, + "decisions": { + "featureName": "decisions", + "materialized": true + }, + "forLoops": { + "featureName": "forLoops", + "materialized": true + }, + "forks": { + "featureName": "forks", + "materialized": true + }, + "ifSubactions": { + "featureName": "ifSubactions", + "materialized": true + }, + "joins": { + "featureName": "joins", + "materialized": true + }, + "loops": { + "featureName": "loops", + "materialized": true + }, + "merges": { + "featureName": "merges", + "materialized": true + }, + "sendSubactions": { + "featureName": "sendSubactions", + "materialized": true + }, + "subactions": { + "featureName": "subactions", + "materialized": true + }, + "terminateSubactions": { + "featureName": "terminateSubactions", + "materialized": true + }, + "transitions": { + "featureName": "transitions", + "materialized": true + }, + "whileLoops": { + "featureName": "whileLoops", + "materialized": true + } + } + }, + { + "id": "5", + "typeSymbolId": "Actions::Action", + "featureValues": { + "acceptSubactions": { + "featureName": "acceptSubactions", + "materialized": true + }, + "assignments": { + "featureName": "assignments", + "materialized": true + }, + "controls": { + "featureName": "controls", + "materialized": true + }, + "decisionTransitions": { + "featureName": "decisionTransitions", + "materialized": true + }, + "decisions": { + "featureName": "decisions", + "materialized": true + }, + "forLoops": { + "featureName": "forLoops", + "materialized": true + }, + "forks": { + "featureName": "forks", + "materialized": true + }, + "ifSubactions": { + "featureName": "ifSubactions", + "materialized": true + }, + "joins": { + "featureName": "joins", + "materialized": true + }, + "loops": { + "featureName": "loops", + "materialized": true + }, + "merges": { + "featureName": "merges", + "materialized": true + }, + "sendSubactions": { + "featureName": "sendSubactions", + "materialized": true + }, + "subactions": { + "featureName": "subactions", + "materialized": true + }, + "terminateSubactions": { + "featureName": "terminateSubactions", + "materialized": true + }, + "transitions": { + "featureName": "transitions", + "materialized": true + }, + "whileLoops": { + "featureName": "whileLoops", + "materialized": true + } + } + } + ] +} diff --git a/editors/vscode/src/landing/present.ts b/editors/vscode/src/landing/present.ts index f3f06e6aef..15af18571d 100644 --- a/editors/vscode/src/landing/present.ts +++ b/editors/vscode/src/landing/present.ts @@ -30,6 +30,14 @@ export function presentedStack(model: LandingModel): RenderResult { }; } +/** + * presentedPhases is the landing model as the hero draws a state machine: the states as the + * view drew them, each transition labelled with the wait that fires it. + */ +export function presentedPhases(model: LandingModel): RenderResult { + return model.render; +} + const STACK_TONES: Record = { runtime: "primary", reference: "secondary", "model store": "accent" }; /** stackTone fills a project's box by the kind its model declares; a component stays plain. */ diff --git a/editors/vscode/src/landing/scene.test.ts b/editors/vscode/src/landing/scene.test.ts index f78881ec25..a6070c3c6a 100644 --- a/editors/vscode/src/landing/scene.test.ts +++ b/editors/vscode/src/landing/scene.test.ts @@ -1,10 +1,20 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { MASS_ROLLUP_SCENE, SCENES, STACK_SCENE, chooseScene, forcedSceneId, requiredVariant, variantChain } from "./scene"; +import { + MASS_ROLLUP_SCENE, + MISSION_PHASES_SCENE, + SCENES, + STACK_SCENE, + chooseScene, + forcedSceneId, + programOf, + requiredVariant, + variantChain, +} from "./scene"; test("the scenes declare distinct ids, controls, programs and snapshots", () => { - assert.deepEqual(SCENES.map(({ id }) => id), ["stack", "mass"]); + assert.deepEqual(SCENES.map(({ id }) => id), ["stack", "mass", "phases"]); assert.equal(STACK_SCENE.program.kind, "journey"); assert.deepEqual(STACK_SCENE.controls, ["run", "edit", "debug", "download"]); assert.equal(MASS_ROLLUP_SCENE.program.kind, "rollup"); @@ -12,6 +22,9 @@ test("the scenes declare distinct ids, controls, programs and snapshots", () => assert.equal(STACK_SCENE.form.kind, "diagram"); assert.equal(MASS_ROLLUP_SCENE.form.kind, "table", "the roll-up is listed, not drawn as nested boxes"); assert.equal(MASS_ROLLUP_SCENE.variants[0]?.rootSymbol, "MassRollup::saturnVWithIU"); + assert.equal(MISSION_PHASES_SCENE.program.kind, "clock"); + assert.equal(MISSION_PHASES_SCENE.view, "#state:MissionSequence::MissionPhases"); + assert.deepEqual(MISSION_PHASES_SCENE.controls, ["run", "edit", "debug", "download"]); for (const scene of SCENES) { assert.ok(scene.snapshot.hash.length > 0, `${scene.id} has a snapshot hash`); assert.ok(Array.isArray(scene.snapshot.instances) && scene.snapshot.instances.length > 0, `${scene.id} has instances`); @@ -36,6 +49,21 @@ test("the roll-up's second variant builds on the first, and the chain unwinds th assert.deepEqual(variantChain(scene, scene.variants[1]).map(({ id }) => id), ["iu-mass", "iu"]); }); +test("the clocked scene's variant keeps the root and the view but advances the clock further", () => { + const [recovery] = MISSION_PHASES_SCENE.variants; + assert.equal(recovery?.rootSymbol, MISSION_PHASES_SCENE.rootSymbol); + assert.equal(recovery?.view, MISSION_PHASES_SCENE.view); + assert.deepEqual(programOf(MISSION_PHASES_SCENE, undefined), { + kind: "clock", symbol: "MissionSequence::MissionPhases", performer: "MissionSequence::mission", advance: 400000, + }); + assert.deepEqual(programOf(MISSION_PHASES_SCENE, recovery), { + kind: "clock", symbol: "MissionSequence::MissionPhases", performer: "MissionSequence::mission", advance: 800000, + }); + const [iu, valued] = MASS_ROLLUP_SCENE.variants; + assert.equal(programOf(MASS_ROLLUP_SCENE, valued), MASS_ROLLUP_SCENE.program, "a variant without a program runs the scene's"); + assert.equal(programOf(MASS_ROLLUP_SCENE, iu), MASS_ROLLUP_SCENE.program); +}); + test("forcedSceneId reads the scene from the query or the fragment", () => { assert.equal(forcedSceneId(new URL("https://example.test/")), undefined); assert.equal(forcedSceneId(new URL("https://example.test/?scene=mass")), "mass"); @@ -54,17 +82,17 @@ test("chooseScene honours a forced scene and falls back to the random draw", () assert.equal(chooseScene(SCENES, new URL("https://example.test/?scene=nonesuch"), () => 0), STACK_SCENE); assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 0), STACK_SCENE); assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 0.5), MASS_ROLLUP_SCENE); - assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 0.999999), MASS_ROLLUP_SCENE); - assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 1), MASS_ROLLUP_SCENE); + assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 0.999999), MISSION_PHASES_SCENE); + assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 1), MISSION_PHASES_SCENE); assert.throws(() => chooseScene([], new URL("https://example.test/")), /no scene/u); }); test("chooseScene draws each scene in proportion to the draw", () => { const counts = new Map(); - const draws = 1000; + const draws = 999; for (let i = 0; i < draws; i++) { const scene = chooseScene(SCENES, new URL("https://example.test/"), () => i / draws); counts.set(scene.id, (counts.get(scene.id) ?? 0) + 1); } - assert.deepEqual([...counts.entries()], [["stack", draws / 2], ["mass", draws / 2]]); + assert.deepEqual([...counts.entries()], [["stack", draws / 3], ["mass", draws / 3], ["phases", draws / 3]]); }); diff --git a/editors/vscode/src/landing/scene.ts b/editors/vscode/src/landing/scene.ts index 8fc7f7435b..a3dabc8310 100644 --- a/editors/vscode/src/landing/scene.ts +++ b/editors/vscode/src/landing/scene.ts @@ -1,7 +1,8 @@ import type { RenderResult } from "../protocol"; import type { LandingModel, LandingPart } from "./model"; -import { presentedStack, stackTone } from "./present"; +import { presentedPhases, presentedStack, stackTone } from "./present"; import mass from "./mass.json"; +import phases from "./phases.json"; import stack from "./stack.json"; /** A scene whose Run executes a state machine and animates the states it visits. */ @@ -20,7 +21,18 @@ export interface SceneRollup { feature: string; } -export type SceneProgram = SceneJourney | SceneRollup; +/** A scene whose Run performs a state machine on the clock and animates the trace it records. */ +export interface SceneClock { + kind: "clock"; + /** The state machine ExecuteState runs: `MissionSequence::MissionPhases`. */ + symbol: string; + /** The object performing it, which exhibits the machine: `MissionSequence::mission`. */ + performer: string; + /** The simulated seconds the clock is advanced by after the machine starts. */ + advance: number; +} + +export type SceneProgram = SceneJourney | SceneRollup | SceneClock; /** The hero's fill for a box, one of the three the page defines; a box with none is drawn plain. */ export type SceneTone = "primary" | "secondary" | "accent"; @@ -61,6 +73,8 @@ export interface SceneVariant { hint: string; /** The variant this one builds on: its toggle is offered while that one is on, and switching it off returns there. */ requires?: string; + /** The program Run executes while the variant is on; absent, the scene's own. */ + program?: SceneProgram; } /** What the Go landing test pins for a scene: the first paint before the module loads. */ @@ -149,7 +163,32 @@ export const MASS_ROLLUP_SCENE: Scene = { ], }; -export const SCENES: readonly Scene[] = [STACK_SCENE, MASS_ROLLUP_SCENE]; +export const MISSION_PHASES_SCENE: Scene = { + id: "phases", + title: "Mission phases on the clock", + description: "The phases of a lunar mission as a state machine, run on the engine's simulated clock", + documentName: "mission-sequence.sysml", + rootSymbol: "MissionSequence::mission", + view: "#state:MissionSequence::MissionPhases", + show: ["edge-labels"], + snapshot: phases, + controls: ["run", "edit", "debug", "download"], + form: { kind: "diagram", present: presentedPhases, tone: () => undefined, pngName: "mission-phases.png" }, + program: { kind: "clock", symbol: "MissionSequence::MissionPhases", performer: "MissionSequence::mission", advance: 400000 }, + variants: [ + { + id: "recovery", + label: "Advance to 800,000 s", + activeLabel: "Advance to 400,000 s", + rootSymbol: "MissionSequence::mission", + view: "#state:MissionSequence::MissionPhases", + hint: "Run advances the clock by 800,000 s now: long enough for the last timed transition, at t = 702,000 s, to fire and the machine to reach done.", + program: { kind: "clock", symbol: "MissionSequence::MissionPhases", performer: "MissionSequence::mission", advance: 800000 }, + }, + ], +}; + +export const SCENES: readonly Scene[] = [STACK_SCENE, MASS_ROLLUP_SCENE, MISSION_PHASES_SCENE]; /** requiredVariant is the variant `variant` builds on; undefined when it stands alone. */ export function requiredVariant(scene: Scene, variant: SceneVariant): SceneVariant | undefined { @@ -165,6 +204,11 @@ export function variantChain(scene: Scene, variant: SceneVariant | undefined): S return chain; } +/** programOf is the program Run executes: the first the variant chain overrides, else the scene's own. */ +export function programOf(scene: Scene, variant: SceneVariant | undefined): SceneProgram { + return variantChain(scene, variant).find((candidate) => candidate.program !== undefined)?.program ?? scene.program; +} + /** forcedSceneId is the scene a URL names, by `?scene=` or `#scene=`; absent when it names none. */ export function forcedSceneId(url: URL): string | undefined { const query = url.searchParams.get("scene"); diff --git a/editors/vscode/src/landing/shell.ts b/editors/vscode/src/landing/shell.ts index 2587ed3abc..a303278827 100644 --- a/editors/vscode/src/landing/shell.ts +++ b/editors/vscode/src/landing/shell.ts @@ -2,7 +2,7 @@ // controls that change it (status line, editor, variant toggles); a form draws what it adopts. import type { Connection } from "@openmbee/opensysml/browser"; import { blocks, readModel, sceneTarget, snapshotModel, type Diagnostic, type LandingModel, type SceneTarget } from "./model"; -import { requiredVariant, variantChain, type Scene, type SceneControl, type SceneVariant } from "./scene"; +import { programOf, requiredVariant, variantChain, type Scene, type SceneControl, type SceneProgram, type SceneVariant } from "./scene"; const EDIT_DELAY = 300; @@ -26,6 +26,8 @@ export interface ShellHooks { busy(): boolean; /** The published text is back in the editor: the form forgets what the visitor did to the stage. */ reset(): void; + /** A variant changed what Run executes over the same model: the form forgets the previous program's run. */ + reprogram(): void; /** `running` changed: the form syncs the controls it owns. */ syncControls(): void; } @@ -35,6 +37,8 @@ export interface Shell { readonly model: LandingModel; /** The root shown: the scene's own, or a variant's while its toggle is on. */ readonly target: SceneTarget; + /** What Run executes: the scene's program, or a variant's while its toggle is on. */ + readonly program: SceneProgram; /** Whether `model` was read live rather than taken from the snapshot. */ readonly live: boolean; /** Whether a Run is in progress; the controls that would change the model wait. */ @@ -63,6 +67,14 @@ export function shortName(symbol: string): string { return symbol.slice(symbol.lastIndexOf("::") + 2); } +/** readSummary counts what a read model shows: a machine's states and transitions, else parts and interfaces. */ +export function readSummary(model: LandingModel): string { + if (model.machine) { + return `${model.machine.states.size} states, ${model.render.edges.length} transitions`; + } + return `${model.parts.size} parts, ${model.render.edges.length} interfaces`; +} + export const sameTarget = (a: SceneTarget, b: SceneTarget): boolean => a.rootSymbol === b.rootSymbol && a.view === b.view; export function mountShell(root: HTMLElement, scene: Scene, options: MountOptions, hooks: ShellHooks): Shell { @@ -170,7 +182,7 @@ export function mountShell(root: HTMLElement, scene: Scene, options: MountOption adopt(outcome.model, source); } const ms = Math.max(1, Math.round(performance.now() - started)); - status(`The engine read the model in ${ms} ms: ${outcome.model.parts.size} parts, ${outcome.model.render.edges.length} interfaces.`); + status(`The engine read the model in ${ms} ms: ${readSummary(outcome.model)}.`); return outcome.model; } @@ -240,8 +252,12 @@ export function mountShell(root: HTMLElement, scene: Scene, options: MountOption if (running) { return; } + const before = programOf(scene, variant); variant = next; target = next === undefined ? sceneTarget(scene) : { rootSymbol: next.rootSymbol, view: next.view }; + if (sameTarget(target, model.target) && programOf(scene, variant) !== before) { + hooks.reprogram(); + } // A read of the previous root still in flight is superseded, as an edit supersedes a read. const revision = ++editSequence; syncVariantControls(); @@ -343,6 +359,9 @@ export function mountShell(root: HTMLElement, scene: Scene, options: MountOption get target() { return target; }, + get program() { + return programOf(scene, variant); + }, get live() { return live; }, diff --git a/editors/vscode/src/landing/table.ts b/editors/vscode/src/landing/table.ts index 6eed0c3aec..6a35684929 100644 --- a/editors/vscode/src/landing/table.ts +++ b/editors/vscode/src/landing/table.ts @@ -144,6 +144,7 @@ export function mountTable(root: HTMLElement, scene: Scene, form: SceneTable, op }, busy: () => false, reset: () => undefined, + reprogram: () => undefined, syncControls: () => undefined, }); const { status, engine, runnable } = shell; diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 611011a090..0267daf8e7 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -1816,6 +1816,66 @@ func (ctx *Context) ExecuteActionReportingPerformer(action *symbols.Symbol, self return outputs, (&Invocation{Actions: []*ActionExecutor{exec}}).PerformerAttributes(), nil } +// ErrNotCompleted is the typed error an action run for a duration that has not +// completed by its end fails with; the message names where it stopped. +var ErrNotCompleted = errors.New("action did not complete") + +// ActionOutcomeAdvancedBy runs action as ActionOutcomePerformedBy does, but for duration of +// simulated time, as `sysml -action X -advance T` does; one still running then is ErrNotCompleted. +func (ctx *Context) ActionOutcomeAdvancedBy(action *symbols.Symbol, self *Instance, inputs map[string]Value, duration float64) (Outcome, error) { + exec, release, err := ctx.startActionAdvancedBy(action, self, inputs, duration) + if err != nil { + return Outcome{}, err + } + defer release() + if _, err := exec.ResultsWithError(); err != nil { + return Outcome{}, err + } + return (&Invocation{Actions: []*ActionExecutor{exec}}).Outcome(), nil +} + +// ExecuteActionAdvancedBy runs action as ExecuteActionReportingPerformer does, but for +// duration of simulated time as ActionOutcomeAdvancedBy does. +func (ctx *Context) ExecuteActionAdvancedBy(action *symbols.Symbol, self *Instance, inputs map[string]Value, duration float64) (outputs, performer map[string]Value, err error) { + exec, release, err := ctx.startActionAdvancedBy(action, self, inputs, duration) + if err != nil { + return nil, nil, err + } + defer release() + outputs, err = exec.ResultsWithError() + if err != nil { + return nil, nil, err + } + return outputs, (&Invocation{Actions: []*ActionExecutor{exec}}).PerformerAttributes(), nil +} + +// startActionAdvancedBy starts the performance a run to completion would run and moves the +// clock by duration; release lets go of one started for the call, not of a reused one. +func (ctx *Context) startActionAdvancedBy(action *symbols.Symbol, self *Instance, inputs map[string]Value, duration float64) (*ActionExecutor, func(), error) { + exec, reused, err := ctx.StartActionPerformedBy(action, self, inputs) + if err != nil { + return nil, nil, err + } + release := exec.Release + if reused { + release = func() {} + } + if _, err := ctx.Advance(duration); err != nil { + release() + return nil, nil, err + } + if !exec.State().Ended() { + err := fmt.Errorf("%w: action %s stopped at %s at simulation time %s without completing", + ErrNotCompleted, ctx.QualifiedName(action), exec.State(), semantics.FormatReal(ctx.Clock().Now())) + if next, ok := exec.NextWait(); ok { + err = fmt.Errorf("%w; the clock must reach t=%s for it to go on", err, semantics.FormatReal(next)) + } + release() + return nil, nil, err + } + return exec, release, nil +} + // performAction runs action to completion, performed by self, and returns the // executor that ran it, whose root performance holds what it produced. An object // performing the action runs the performance it already runs rather than a second. @@ -2020,6 +2080,28 @@ func (ctx *Context) StateOutcomePerformedBy(stateMachine *symbols.Symbol, self * return (&Invocation{States: []*StateExecutor{exec}}).Outcome(), nil } +// StateOutcomeAdvancedBy runs a state machine as StateOutcomePerformedBy does, but sends the +// events and then moves the clock by duration, as `sysml -state X -advance T` does. +func (ctx *Context) StateOutcomeAdvancedBy(stateMachine *symbols.Symbol, self *Instance, events []string, duration float64) (Outcome, error) { + exec, reused, err := ctx.StartStatePerformedBy(stateMachine, self) + if err != nil { + return Outcome{}, err + } + if !reused { + defer exec.Release() + } + for _, event := range events { + exec.SendSignal(event, nil) + } + if _, err := ctx.Advance(duration); err != nil { + return Outcome{}, err + } + if _, err := exec.StateDataWithError(); err != nil { + return Outcome{}, err + } + return (&Invocation{States: []*StateExecutor{exec}}).Outcome(), nil +} + // errAmbiguousMachine is the typed error a machine named on an object exhibiting // it under several usages wraps. var errAmbiguousMachine = errors.New("ambiguous state machine") diff --git a/internal/frontend/engine/engine.go b/internal/frontend/engine/engine.go index 84580f80ef..6c69ac642c 100644 --- a/internal/frontend/engine/engine.go +++ b/internal/frontend/engine/engine.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "os" "strings" @@ -632,6 +633,14 @@ func (e *Engine) schedulePolicy(spelling string) (runtime.SchedulePolicy, error) return policy, nil } +// checkAdvance refuses a run duration the service refuses: NaN, infinite or negative. +func checkAdvance(advance *float64) error { + if advance != nil && (math.IsNaN(*advance) || math.IsInf(*advance, 0) || *advance < 0) { + return statusErrorf(codeInvalidArgument, "advance takes a finite, non-negative duration to run for, not %s", semantics.FormatReal(*advance)) + } + return nil +} + // evaluate evaluates a SysML expression in the context of a parsed model, as // grpc's Evaluate does. func (e *Engine) evaluate(ctx context.Context, req *JEvaluateRequest) ([]byte, error) { @@ -853,6 +862,9 @@ func (e *Engine) executeAction(ctx context.Context, req *JExecuteActionRequest) if err != nil { return nil, err } + if err := checkAdvance(req.Advance); err != nil { + return nil, err + } cached, ok := e.get(req.ModelHash) if !ok { @@ -893,8 +905,13 @@ func (e *Engine) executeAction(ctx context.Context, req *JExecuteActionRequest) return marshal(&JExecuteActionResponse{Error: err.Error()}) } - // Execute action with the supplied inputs - outputs, performer, err := runtimeCtx.ExecuteActionReportingPerformer(action, self, inputs) + // Run to completion, or for the duration asked as the service does for `advance`. + var outputs, performer map[string]runtime.Value + if req.Advance == nil { + outputs, performer, err = runtimeCtx.ExecuteActionReportingPerformer(action, self, inputs) + } else { + outputs, performer, err = runtimeCtx.ExecuteActionAdvancedBy(action, self, inputs, *req.Advance) + } if err != nil { if gone := ctx.Err(); gone != nil { return nil, contextError(gone) @@ -926,6 +943,9 @@ func (e *Engine) executeState(ctx context.Context, req *JExecuteStateRequest) ([ if err != nil { return nil, err } + if err := checkAdvance(req.Advance); err != nil { + return nil, err + } cached, ok := e.get(req.ModelHash) if !ok { @@ -955,7 +975,12 @@ func (e *Engine) executeState(ctx context.Context, req *JExecuteStateRequest) ([ return marshal(&JExecuteStateResponse{Error: err.Error(), Trace: trace, TraceDropped: dropped}) } - outcome, err := runtimeCtx.StateOutcomePerformedBy(stateMachine, self, req.Events) + var outcome runtime.Outcome + if req.Advance == nil { + outcome, err = runtimeCtx.StateOutcomePerformedBy(stateMachine, self, req.Events) + } else { + outcome, err = runtimeCtx.StateOutcomeAdvancedBy(stateMachine, self, req.Events, *req.Advance) + } if err != nil { if gone := ctx.Err(); gone != nil { return nil, contextError(gone) diff --git a/internal/frontend/engine/json.go b/internal/frontend/engine/json.go index 4d1b3d9f3f..b981fe5850 100644 --- a/internal/frontend/engine/json.go +++ b/internal/frontend/engine/json.go @@ -475,6 +475,8 @@ type JExecuteActionRequest struct { Inputs map[string]*JValue `json:"inputs,omitempty"` Schedule string `json:"schedule,omitempty"` PerformerSymbolId string `json:"performerSymbolId,omitempty"` + // Advance is the simulated time, in seconds, to run for; nil runs to completion. + Advance *float64 `json:"advance,omitempty"` } type JExecuteActionResponse struct { @@ -492,6 +494,8 @@ type JExecuteStateRequest struct { Schedule string `json:"schedule,omitempty"` PerformerSymbolId string `json:"performerSymbolId,omitempty"` Trace bool `json:"trace,omitempty"` + // Advance is the simulated time, in seconds, to run for after the events; nil runs to quiescence. + Advance *float64 `json:"advance,omitempty"` } type JExecuteStateResponse struct { diff --git a/internal/frontend/grpc/run_options.go b/internal/frontend/grpc/run_options.go index a49d425a51..9a784784f0 100644 --- a/internal/frontend/grpc/run_options.go +++ b/internal/frontend/grpc/run_options.go @@ -1,8 +1,6 @@ package grpc import ( - "errors" - "fmt" "math" "connectrpc.com/connect" @@ -13,10 +11,6 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) -// ErrNotCompleted is the typed error an action run for a duration that has not -// completed by its end fails with; the message names where it stopped. -var ErrNotCompleted = errors.New("action did not complete") - // runOptions are the options of one behavior run a request carries beside the // schedule, as `sysml -advance`, `-seed`, `-draws` and `-clock-step` carry them. type runOptions struct { @@ -68,65 +62,27 @@ func (o runOptions) apply(rt *runtime.Context) { } // runAction runs action performed by self on rt: to completion, or for the -// duration asked, in which case one that has not completed is ErrNotCompleted. -func (o runOptions) runAction(rt *runtime.Context, name string, action *symbols.Symbol, self *runtime.Instance, inputs map[string]runtime.Value) (actionRun, error) { +// duration asked, in which case one that has not completed is runtime.ErrNotCompleted. +func (o runOptions) runAction(rt *runtime.Context, action *symbols.Symbol, self *runtime.Instance, inputs map[string]runtime.Value) (actionRun, error) { + var run actionRun + var err error if o.advance == nil { - outputs, performer, err := rt.ExecuteActionReportingPerformer(action, self, inputs) - return actionRun{outputs: outputs, performer: performer}, err - } - exec, release, err := o.startAction(rt, name, action, self, inputs) - if err != nil { - return actionRun{}, err + run.outputs, run.performer, err = rt.ExecuteActionReportingPerformer(action, self, inputs) + } else { + run.outputs, run.performer, err = rt.ExecuteActionAdvancedBy(action, self, inputs, *o.advance) } - defer release() - outputs, err := exec.ResultsWithError() if err != nil { return actionRun{}, err } - performer := (&runtime.Invocation{Actions: []*runtime.ActionExecutor{exec}}).PerformerAttributes() - return actionRun{outputs: outputs, performer: performer}, nil + return run, nil } // actionOutcome is runAction as an exploration's run observes it. -func (o runOptions) actionOutcome(rt *runtime.Context, name string, action *symbols.Symbol, self *runtime.Instance, inputs map[string]runtime.Value) (runtime.Outcome, error) { +func (o runOptions) actionOutcome(rt *runtime.Context, action *symbols.Symbol, self *runtime.Instance, inputs map[string]runtime.Value) (runtime.Outcome, error) { if o.advance == nil { return rt.ActionOutcomePerformedBy(action, self, inputs) } - exec, _, err := o.startAction(rt, name, action, self, inputs) - if err != nil { - return runtime.Outcome{}, err - } - if _, err := exec.ResultsWithError(); err != nil { - return runtime.Outcome{}, err - } - return (&runtime.Invocation{Actions: []*runtime.ActionExecutor{exec}}).Outcome(), nil -} - -// startAction starts the performance a run to completion would run and moves the clock by -// the duration asked, as `-action X -advance T` does; release lets go of one started for the call. -func (o runOptions) startAction(rt *runtime.Context, name string, action *symbols.Symbol, self *runtime.Instance, inputs map[string]runtime.Value) (*runtime.ActionExecutor, func(), error) { - exec, reused, err := rt.StartActionPerformedBy(action, self, inputs) - if err != nil { - return nil, nil, err - } - release := exec.Release - if reused { - release = func() { /* a reused performance is the run's own; nothing was started for the call */ } - } - if _, err := rt.Advance(*o.advance); err != nil { - release() - return nil, nil, err - } - if !exec.State().Ended() { - err := fmt.Errorf("%w: action %s stopped at %s at simulation time %s without completing", - ErrNotCompleted, name, exec.State(), semantics.FormatReal(rt.Clock().Now())) - if next, ok := exec.NextWait(); ok { - err = fmt.Errorf("%w; the clock must reach t=%s for it to go on", err, semantics.FormatReal(next)) - } - release() - return nil, nil, err - } - return exec, release, nil + return rt.ActionOutcomeAdvancedBy(action, self, inputs, *o.advance) } // stateOutcome runs the machine self performs (the one it exhibits, where it does) over @@ -135,21 +91,5 @@ func (o runOptions) stateOutcome(rt *runtime.Context, stateMachine *symbols.Symb if o.advance == nil { return rt.StateOutcomePerformedBy(stateMachine, self, events) } - exec, reused, err := rt.StartStatePerformedBy(stateMachine, self) - if err != nil { - return runtime.Outcome{}, err - } - if !reused { - defer exec.Release() - } - for _, event := range events { - exec.SendSignal(event, nil) - } - if _, err := rt.Advance(*o.advance); err != nil { - return runtime.Outcome{}, err - } - if _, err := exec.StateDataWithError(); err != nil { - return runtime.Outcome{}, err - } - return (&runtime.Invocation{States: []*runtime.StateExecutor{exec}}).Outcome(), nil + return rt.StateOutcomeAdvancedBy(stateMachine, self, events, *o.advance) } diff --git a/internal/frontend/grpc/service.go b/internal/frontend/grpc/service.go index 134f9f71bf..e14a9d554e 100644 --- a/internal/frontend/grpc/service.go +++ b/internal/frontend/grpc/service.go @@ -1395,7 +1395,7 @@ func (s *Service) ExecuteAction(ctx context.Context, req *pb.ExecuteActionReques if err != nil { return runtime.Outcome{}, err } - outcome, err := opts.actionOutcome(rt, req.ActionSymbolId, action, self, inputs) + outcome, err := opts.actionOutcome(rt, action, self, inputs) if err != nil { return runtime.Outcome{}, fmt.Errorf("action execution failed: %w", err) } @@ -1419,7 +1419,7 @@ func (s *Service) ExecuteAction(ctx context.Context, req *pb.ExecuteActionReques // Execute action with the supplied inputs ran, _, err := performOn(ctx, s, runtimeCtx, analysis.Auto(), req.ActionSymbolId, func(rt *runtime.Context) (actionRun, error) { - return opts.runAction(rt, req.ActionSymbolId, action, self, inputs) + return opts.runAction(rt, action, self, inputs) }, func(ran actionRun, err error) analysis.Answer { return heldAnswer(ran.outputs, err) }) if gone := callerGone(ctx, err); gone != nil { return nil, gone diff --git a/overrides/home.html b/overrides/home.html index ab83e0cd14..5040a095b5 100644 --- a/overrides/home.html +++ b/overrides/home.html @@ -377,11 +377,13 @@ } .osml-nodecard__close:hover { opacity: 1; } /* A run of the model: the token travels the wires the execution crossed. */ - .osml-diagram g.osml-part.is-live > .shape { + .osml-diagram g.osml-part.is-live > .shape, + .osml-diagram g.osml-state.is-live > .shape { stroke: #ffd54f; stroke-width: 3; } - .osml-diagram g.osml-part.is-live { + .osml-diagram g.osml-part.is-live, + .osml-diagram g.osml-state.is-live { filter: drop-shadow(0 0 .5rem #ffd54f); } .osml-diagram g.opensysml-edge.is-live .line { @@ -476,7 +478,9 @@ font-size: .64rem; text-align: left; } - .osml-debug[hidden] { display: none; } + .osml-debug[hidden], + .osml-debug__bar[hidden], + .osml-debug label[hidden] { display: none; } .osml-debug__bar { display: flex; flex-wrap: wrap; @@ -958,6 +962,7 @@

A SysML v2 toolchain that runs the model, not just reads it.

+

One open interchange, two implementations, a version-controlled store. Drag the boxes around — click opens the project, right-click shows its attributes. Zoom with the buttons, Ctrl+scroll or a pinch, and drag the background to pan. Edit the model and the engine redraws the diagram as you type.

+
@@ -978,7 +984,7 @@

A SysML v2 toolchain that runs the model, not just reads it.

This is a SysML model. Run it or edit it in your browser on the OpenSysML engine (about 9 MB, downloaded once).