diff --git a/changes/unreleased/landing-apollo-action-scene.added.md b/changes/unreleased/landing-apollo-action-scene.added.md new file mode 100644 index 0000000000..84b1b6f46b --- /dev/null +++ b/changes/unreleased/landing-apollo-action-scene.added.md @@ -0,0 +1 @@ +- The homepage hero gains an "Apollo 11 mission" scene: `MissionSequence::LunarMission` drawn as an action diagram whose Run performs it on `apollo11` with `ExecuteAction`'s trace and replays the engine's records (the token step by step, each assigned `remaining` under the burn that wrote it, the decide's branch, the wait at `awaitGo`), with toggles that post `GoForDescent` to the waiting accept, fly `apollo8`, or perform `LunarMissionUnordered`. diff --git a/editors/vscode/src/landing/main.ts b/editors/vscode/src/landing/main.ts index 1945c34b7b..46bfcfce20 100644 --- a/editors/vscode/src/landing/main.ts +++ b/editors/vscode/src/landing/main.ts @@ -23,9 +23,11 @@ import { connectModule, moduleUrls } from "./connect"; import { debugSteps, journey, + runAction, runClock, runJourney, stateIdOf, + type Assignment, type DebugStep, type JourneyRun, type LandingModel, @@ -34,7 +36,7 @@ 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 SceneClock, type SceneDiagram } from "./scene"; +import { SCENES, STACK_SCENE, chooseScene, type Scene, type SceneAction, type SceneClock, type SceneDiagram } from "./scene"; import { message, mountShell, shortName, type Mounted, type MountOptions } from "./shell"; import { mountTable } from "./table"; import { wireTabs } from "./tabs"; @@ -381,14 +383,48 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou } } + // The values an action run has assigned so far, each written under the node that assigned it. + let shownValues: readonly Assignment[] = []; + + function syncValues(): void { + for (const label of content.querySelectorAll("text.osml-value")) { + label.remove(); + } + for (const value of shownValues) { + const id = idOf(value.at); + const shape = id === undefined ? null : groupOf(id)?.querySelector(".shape"); + if (!shape) { + continue; + } + const box = shape.getBBox(); + const label = document.createElementNS(SVG_NS, "text"); + label.setAttribute("class", "osml-value"); + label.setAttribute("x", String(box.x + box.width / 2)); + label.setAttribute("y", String(box.y + box.height + 14)); + label.setAttribute("text-anchor", "middle"); + label.textContent = `${value.feature} = ${value.value}`; + shape.parentElement?.append(label); + } + } + + function showValues(values: readonly Assignment[]): void { + shownValues = values; + syncValues(); + } + function decorate(): void { const stateIds = new Set([...(model.machine?.states.values() ?? [])].map((state) => state.id)); + const nodeIds = new Set([...(model.action?.nodes.values() ?? [])].map((node) => node.id)); for (const group of content.querySelectorAll("g.opensysml-node")) { const id = group.dataset.opensysmlId ?? ""; if (stateIds.has(id)) { group.classList.add("osml-state"); continue; } + if (nodeIds.has(id)) { + group.classList.add("osml-step"); + continue; + } const part = partOf(id); if (!part) { continue; @@ -424,6 +460,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou content.append(token); } syncClasses(); + syncValues(); if (focusedId) { groupOf(focusedId)?.focus({ preventScroll: true }); } @@ -464,6 +501,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou // adopt redraws a model the shell read; a moved box stays put while its part remains. function adopt(next: LandingModel): void { model = next; + shownValues = []; for (const feature of placed.keys()) { if (!model.parts.has(feature)) { placed.delete(feature); @@ -484,6 +522,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou function forgetRun(): void { if (debugPanel.hidden) { run = undefined; + showValues([]); } else if (rerunning === undefined) { void rerun(-1); } else { @@ -1091,6 +1130,11 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou ran = true; return; } + if (program.kind === "action") { + await runActed(program, current); + ran = true; + return; + } const picked = seed === undefined || seedPicked; if (picked) { seed = randomSeed(); @@ -1176,6 +1220,43 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou status(`${ran}.${stopped}${ending}`); } + // runActed performs the action on its object and replays the trace the engine recorded: the + // token at each node a step reached, each assigned value written under its node, and the + // status line reading the records; a run that stops waiting ends on the wait it stopped at. + async function runActed(program: SceneAction, current: LandingModel): Promise { + const started = performance.now(); + const result = await runAction(await engine(), program, current); + const ms = Math.max(1, Math.round(performance.now() - started)); + run = result; + runSeed = seed; + steps = debugSteps(result.trace); + fillTrace(); + const posted = program.events.length === 0 ? "" : ` with ${program.events.join(", ")}`; + const ran = `ExecuteAction performed ${shortName(program.symbol)} on ${shortName(program.performer)}${posted} in ${ms} ms`; + if (steps.length === 0 && result.error !== undefined) { + throw new Error(result.error); + } + status(`${ran}: ${steps.length} trace records.`); + runBtn.textContent = "▶ Run it again"; + for (let index = 0; index < steps.length && !signal.aborted; index += 1) { + status(steps[index].record.text); + await showStep(index, !reduceMotion.matches); + await delay(reduceMotion.matches ? 250 : 400); + } + if (signal.aborted) { + return; + } + if (result.error !== undefined) { + status(`${ran} and stopped after ${steps.length} trace records: ${result.error}`, true); + return; + } + const tokens = new Set(steps.flatMap(({ record }) => (record.token === undefined ? [] : [record.token]))).size; + const last = steps[steps.length - 1]?.record; + const ended = last === undefined ? "" : last.kind === "complete" ? "; the action completed" : `; the run ends on ${last.text}`; + const outputs = [...(result.outputs ?? [])].map(([name, value]) => `${name} = ${value}`); + status(`${ran}: ${steps.length} trace records over ${tokens} token${tokens === 1 ? "" : "s"}${ended}${outputs.length === 0 ? "" : ` with ${outputs.join(", ")}`}.`); + } + // ---- debugging the model ---- // 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. @@ -1248,6 +1329,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou const edge = step?.edge ? edgeBetween(idOf(step.edge.from), idOf(step.edge.to)) : -1; liveEdge = edge < 0 ? undefined : edge; syncClasses(); + showValues(step?.assigned ?? []); for (const item of traceEl.querySelectorAll("li[data-index]")) { const current = Number(item.dataset.index) === cursor; item.classList.toggle("is-current", current); @@ -1265,7 +1347,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou queueEl.textContent = queue.length === 0 ? "empty" : queue.join(", "); 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 ?? "?"}` : ""; + const choice = step?.record.kind === "choice" || step?.record.kind === "decide" ? `, chose ${step.record.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}`; @@ -1303,18 +1385,23 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou 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); + : program.kind === "action" + ? await runAction(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 rpc = program.kind === "action" ? "ExecuteAction" : "ExecuteState"; const over = program.kind === "clock" ? `on ${shortName(program.performer)} for ${secondsText(program.advance)}` - : `on ${sent.length} events under ${schedule}`; + : program.kind === "action" + ? `on ${shortName(program.performer)}${program.events.length === 0 ? "" : ` with ${program.events.join(", ")}`}` + : `on ${sent.length} events under ${schedule}`; status( run.error - ? `ExecuteState failed after ${steps.length} trace records: ${run.error}` - : `ExecuteState ran ${machine} ${over} in ${ms} ms: ${steps.length} trace records.`, + ? `${rpc} failed after ${steps.length} trace records: ${run.error}` + : `${rpc} ran ${machine} ${over} in ${ms} ms: ${steps.length} trace records.`, run.error !== undefined, ); fillTrace(); diff --git a/editors/vscode/src/landing/mission.json b/editors/vscode/src/landing/mission.json new file mode 100644 index 0000000000..f68152f6bf --- /dev/null +++ b/editors/vscode/src/landing/mission.json @@ -0,0 +1,1578 @@ +{ + "hash": "b861900f257122580e56514dfdf1b5a42906cbbfc7245c41b4fb900b19ade2b3", + "render": { + "kind": "action", + "stated": "no view declared; rendering MissionSequence::LunarMission directly", + "nodes": [ + { + "id": "n0", + "kind": "action def", + "name": "MissionSequence::LunarMission", + "ports": [ + { + "id": "n0.0", + "name": "budget", + "direction": "in" + }, + { + "id": "n0.1", + "name": "landed", + "direction": "out" + }, + { + "id": "n0.2", + "name": "returned", + "direction": "out" + } + ], + "origin": { + "file": "mission-sequence.sysml", + "startLine": 22, + "startCol": 5, + "endLine": 44, + "endCol": 6 + } + }, + { + "id": "n1", + "kind": "initial", + "name": "start", + "nameSynthesized": true, + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 28, + "startCol": 9, + "endLine": 28, + "endCol": 21 + } + }, + { + "id": "n2", + "kind": "action", + "name": "translunarInjection", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 29, + "startCol": 14, + "endLine": 29, + "endCol": 86 + } + }, + { + "id": "n3", + "kind": "action", + "name": "lunarOrbitInsertion", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 30, + "startCol": 14, + "endLine": 30, + "endCol": 85 + } + }, + { + "id": "n4", + "kind": "decision", + "name": "landingGo", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 31, + "startCol": 14, + "endLine": 31, + "endCol": 31 + } + }, + { + "id": "n5", + "kind": "action", + "name": "awaitGo", + "parent": "n0", + "ports": [ + { + "id": "n5.0", + "name": "go", + "direction": "out" + }, + { + "id": "n5.1", + "name": "payload", + "direction": "inout", + "inherited": true + }, + { + "id": "n5.2", + "name": "receiver", + "direction": "in", + "inherited": true + } + ], + "origin": { + "file": "mission-sequence.sysml", + "startLine": 35, + "startCol": 9, + "endLine": 35, + "endCol": 49 + } + }, + { + "id": "n6", + "kind": "action", + "name": "descent", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 36, + "startCol": 14, + "endLine": 36, + "endCol": 74 + } + }, + { + "id": "n7", + "kind": "action", + "name": "surfaceOperations", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 37, + "startCol": 14, + "endLine": 37, + "endCol": 61 + } + }, + { + "id": "n8", + "kind": "action", + "name": "ascent", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 38, + "startCol": 14, + "endLine": 38, + "endCol": 73 + } + }, + { + "id": "n9", + "kind": "action", + "name": "transEarthInjection", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 41, + "startCol": 9, + "endLine": 41, + "endCol": 81 + } + }, + { + "id": "n10", + "kind": "action", + "name": "splashdown", + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 42, + "startCol": 14, + "endLine": 42, + "endCol": 56 + } + }, + { + "id": "n11", + "kind": "final", + "name": "done", + "nameSynthesized": true, + "parent": "n0", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 43, + "startCol": 14, + "endLine": 43, + "endCol": 19 + } + } + ], + "edges": [ + { + "from": "n1", + "to": "n2", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 29, + "startCol": 9, + "endLine": 29, + "endCol": 13 + } + }, + { + "from": "n2", + "to": "n3", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 30, + "startCol": 9, + "endLine": 30, + "endCol": 13 + } + }, + { + "from": "n3", + "to": "n4", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 31, + "startCol": 9, + "endLine": 31, + "endCol": 13 + } + }, + { + "from": "n4", + "to": "n5", + "label": "[remaining \u003e= 3970 ['m/s']]", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 32, + "startCol": 13, + "endLine": 32, + "endCol": 55 + } + }, + { + "from": "n4", + "to": "n9", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 33, + "startCol": 13, + "endLine": 33, + "endCol": 38 + } + }, + { + "from": "n5", + "to": "n6", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 36, + "startCol": 9, + "endLine": 36, + "endCol": 13 + } + }, + { + "from": "n6", + "to": "n7", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 37, + "startCol": 9, + "endLine": 37, + "endCol": 13 + } + }, + { + "from": "n7", + "to": "n8", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 38, + "startCol": 9, + "endLine": 38, + "endCol": 13 + } + }, + { + "from": "n8", + "to": "n9", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 39, + "startCol": 9, + "endLine": 39, + "endCol": 34 + } + }, + { + "from": "n9", + "to": "n10", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 42, + "startCol": 9, + "endLine": 42, + "endCol": 13 + } + }, + { + "from": "n10", + "to": "n11", + "kind": "succession", + "origin": { + "file": "mission-sequence.sysml", + "startLine": 43, + "startCol": 9, + "endLine": 43, + "endCol": 13 + } + } + ], + "shown": [ + "port-symbols", + "compartments", + "edge-labels", + "keywords" + ] + }, + "instances": [ + { + "id": "1", + "typeSymbolId": "MissionSequence::apollo11", + "featureValues": { + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "budget": { + "featureName": "budget", + "value": { + "quantity": { + "intMagnitude": "9500", + "unit": "'m/s'", + "unitTerm": { + "scaleNum": 1, + "scaleDen": 1, + "factors": [ + { + "unitId": "SI::metre", + "exponent": 1 + }, + { + "unitId": "SI::second", + "exponent": -1 + } + ] + } + } + }, + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates", + "materialized": true + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "mission": { + "featureName": "mission", + "value": { + "instanceId": "2" + }, + "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 + }, + "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::Flight::mission", + "featureValues": { + "acceptSubactions": { + "featureName": "acceptSubactions", + "materialized": true + }, + "ascent": { + "featureName": "ascent", + "value": { + "instanceId": "3" + }, + "materialized": true + }, + "assignments": { + "featureName": "assignments", + "materialized": true + }, + "awaitGo": { + "featureName": "awaitGo", + "value": { + "instanceId": "4" + }, + "materialized": true + }, + "budget": { + "featureName": "budget", + "values": [ + { + "quantity": { + "intMagnitude": "9500", + "unit": "'m/s'", + "unitTerm": { + "scaleNum": 1, + "scaleDen": 1, + "factors": [ + { + "unitId": "SI::metre", + "exponent": 1 + }, + { + "unitId": "SI::second", + "exponent": -1 + } + ] + } + } + } + ], + "materialized": true + }, + "controls": { + "featureName": "controls", + "materialized": true + }, + "decisionTransitions": { + "featureName": "decisionTransitions", + "materialized": true + }, + "decisions": { + "featureName": "decisions", + "materialized": true + }, + "descent": { + "featureName": "descent", + "value": { + "instanceId": "5" + }, + "materialized": true + }, + "forLoops": { + "featureName": "forLoops", + "materialized": true + }, + "forks": { + "featureName": "forks", + "materialized": true + }, + "ifSubactions": { + "featureName": "ifSubactions", + "materialized": true + }, + "joins": { + "featureName": "joins", + "materialized": true + }, + "landed": { + "featureName": "landed", + "values": [ + { + "boolValue": false + } + ], + "materialized": true + }, + "landingGo": { + "featureName": "landingGo" + }, + "loops": { + "featureName": "loops", + "materialized": true + }, + "lunarOrbitInsertion": { + "featureName": "lunarOrbitInsertion", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "merges": { + "featureName": "merges", + "materialized": true + }, + "remaining": { + "featureName": "remaining", + "value": { + "quantity": { + "intMagnitude": "5450", + "unit": "'m/s'", + "unitTerm": { + "scaleNum": 1, + "scaleDen": 1, + "factors": [ + { + "unitId": "SI::metre", + "exponent": 1 + }, + { + "unitId": "SI::second", + "exponent": -1 + } + ] + } + } + }, + "materialized": true + }, + "returned": { + "featureName": "returned", + "values": [ + { + "boolValue": false + } + ], + "materialized": true + }, + "sendSubactions": { + "featureName": "sendSubactions", + "materialized": true + }, + "splashdown": { + "featureName": "splashdown", + "value": { + "instanceId": "7" + }, + "materialized": true + }, + "start": { + "featureName": "start", + "value": { + "instanceId": "8" + }, + "materialized": true + }, + "subactions": { + "featureName": "subactions", + "values": [ + { + "instanceId": "9" + }, + { + "instanceId": "6" + }, + { + "instanceId": "4" + }, + { + "instanceId": "5" + }, + { + "instanceId": "10" + }, + { + "instanceId": "3" + }, + { + "instanceId": "11" + }, + { + "instanceId": "7" + } + ], + "materialized": true + }, + "surfaceOperations": { + "featureName": "surfaceOperations", + "value": { + "instanceId": "10" + }, + "materialized": true + }, + "terminateSubactions": { + "featureName": "terminateSubactions", + "materialized": true + }, + "transEarthInjection": { + "featureName": "transEarthInjection", + "value": { + "instanceId": "11" + }, + "materialized": true + }, + "transitions": { + "featureName": "transitions", + "materialized": true + }, + "translunarInjection": { + "featureName": "translunarInjection", + "value": { + "instanceId": "9" + }, + "materialized": true + }, + "whileLoops": { + "featureName": "whileLoops", + "materialized": true + } + } + }, + { + "id": "3", + "typeSymbolId": "MissionSequence::LunarMission::ascent", + "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": "MissionSequence::LunarMission::awaitGo", + "featureValues": { + "aState": { + "featureName": "aState", + "value": { + "instanceId": "12" + }, + "materialized": true + }, + "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 + }, + "go": { + "featureName": "go" + }, + "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", + "values": [ + { + "instanceId": "12" + } + ], + "materialized": true + }, + "terminateSubactions": { + "featureName": "terminateSubactions", + "materialized": true + }, + "transitions": { + "featureName": "transitions", + "materialized": true + }, + "whileLoops": { + "featureName": "whileLoops", + "materialized": true + } + } + }, + { + "id": "12", + "typeSymbolId": "Actions::AcceptAction::aState", + "featureValues": { + "aTransition": { + "featureName": "aTransition" + }, + "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": "13" + }, + "materialized": true + }, + "entryAction": { + "featureName": "entryAction", + "value": { + "instanceId": "14" + }, + "materialized": true + }, + "exclusiveStates": { + "featureName": "exclusiveStates", + "materialized": true + }, + "exitAction": { + "featureName": "exitAction", + "value": { + "instanceId": "15" + }, + "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 + }, + "merges": { + "featureName": "merges", + "materialized": true + }, + "sendSubactions": { + "featureName": "sendSubactions", + "materialized": true + }, + "stateTransitions": { + "featureName": "stateTransitions", + "materialized": true + }, + "subactions": { + "featureName": "subactions", + "values": [ + { + "instanceId": "13" + } + ], + "materialized": true + }, + "substates": { + "featureName": "substates", + "materialized": true + }, + "terminateSubactions": { + "featureName": "terminateSubactions", + "materialized": true + }, + "transitions": { + "featureName": "transitions", + "materialized": true + }, + "whileLoops": { + "featureName": "whileLoops", + "materialized": true + } + } + }, + { + "id": "13", + "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": "14", + "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": "15", + "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": "5", + "typeSymbolId": "MissionSequence::LunarMission::descent", + "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": "6", + "typeSymbolId": "MissionSequence::LunarMission::lunarOrbitInsertion", + "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": "7", + "typeSymbolId": "MissionSequence::LunarMission::splashdown", + "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": "9", + "typeSymbolId": "MissionSequence::LunarMission::translunarInjection", + "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": "10", + "typeSymbolId": "MissionSequence::LunarMission::surfaceOperations", + "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": "11", + "typeSymbolId": "MissionSequence::LunarMission::transEarthInjection", + "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/model.test.ts b/editors/vscode/src/landing/model.test.ts index cf8627b31d..b19c751d2d 100644 --- a/editors/vscode/src/landing/model.test.ts +++ b/editors/vscode/src/landing/model.test.ts @@ -13,14 +13,17 @@ import { journey, readModel, renderResult, + runAction, runClock, runJourney, stateIdOf, TraceRecord, journeyOf, sceneTarget, + traceRecord, } from "./model"; -import { MISSION_PHASES_SCENE, STACK_SCENE } from "./scene"; +import { LUNAR_MISSION_SCENE, MISSION_PHASES_SCENE, STACK_SCENE, programOf } from "./scene"; +import { readSummary } from "./shell"; interface LandingFixture { hash: string; @@ -34,6 +37,8 @@ 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 missionFixture = JSON.parse(readFileSync("src/landing/mission.json", "utf8")) as LandingFixture; +const missionRender: RenderResult = renderResult(renderedViewFromJson(missionFixture.render)); const PROJECTS = ["flexo", "opensysml", "pilot"]; const COMPONENTS: Record = { @@ -126,6 +131,10 @@ function traceEvent(record: TraceRecord): DocumentEvent { event: record.event ?? "", alternatives: record.alternatives ?? [], taken: record.taken ?? "", + token: record.token === undefined ? 0n : BigInt(record.token), + step: record.step ?? 0, + feature: record.feature ?? "", + value: record.value === undefined ? undefined : { kind: "string", value: record.value }, text: record.text, } as unknown as DocumentEvent; } @@ -140,6 +149,8 @@ interface FakeAnswers { statesVisited?: string[]; trace?: TraceRecord[]; finalTime?: number; + /** An action's outputs, by name. */ + outputs?: Map; executeError?: Error; /** Features of the root the instantiation could not value, by the error it gives each. */ rootErrors?: Record; @@ -185,6 +196,20 @@ function fakeConnection(answers: FakeAnswers = {}): { connection: Connection; ca finalTime: answers.finalTime ?? 0, }); }, + executeAction(...params: unknown[]) { + calls.push({ method: "ExecuteAction", params }); + if (answers.executeError) { + return Promise.reject(answers.executeError); + } + return Promise.resolve({ + outputs: answers.outputs ?? new Map(), + performer: new Map(), + finalTime: answers.finalTime ?? 0, + trace: (answers.trace ?? []).map(traceEvent), + traceDropped: 0, + orderDependent: new Map(), + }); + }, }; return { connection: connection as unknown as Connection, calls }; } @@ -518,3 +543,109 @@ test("renderResult states no x and y for a geometry that only folds the node", ( assert.equal(folded.y, undefined); assert.deepEqual([pinned.x, pinned.y], [10, 20]); }); + +test("landingModel lifts an action view's nodes out of the action's box and keys them by name", () => { + const model = landingModel(sceneTarget(LUNAR_MISSION_SCENE), missionFixture.hash, missionRender, missionFixture.instances); + assert.ok(model.action, "an action view gives an action"); + assert.equal(model.action.symbol, "MissionSequence::LunarMission"); + assert.deepEqual( + [...model.action.nodes.keys()], + ["start", "translunarInjection", "lunarOrbitInsertion", "landingGo", "awaitGo", "descent", "surfaceOperations", "ascent", "transEarthInjection", "splashdown", "done"], + ); + assert.equal(model.action.nodes.get("start")?.kind, "initial"); + assert.equal(model.action.nodes.get("landingGo")?.kind, "decision"); + assert.equal(model.action.nodes.get("done")?.kind, "final"); + assert.equal(model.machine, undefined); + assert.equal(model.parts.size, 0, "an action node is not a part"); + assert.equal(model.render.nodes.length, 11, "nine steps, the start and the final"); + assert.ok(model.render.nodes.every((node) => node.parent === undefined), "the nodes are drawn at the top level"); + assert.equal(model.render.edges.length, 11); + assert.deepEqual(model.render.edges.filter((edge) => edge.label !== "").map((edge) => edge.label), ["[remaining >= 3970 ['m/s']]"]); + assert.equal(model.root.symbol, "MissionSequence::apollo11", "the root is the object performing the action"); + assert.equal(stateIdOf(model, "awaitGo"), model.action.nodes.get("awaitGo")?.id); + assert.equal(stateIdOf(model, "nowhere"), undefined); + assert.equal(readSummary(model), "11 steps, 11 successions"); + assert.throws( + () => landingModel(sceneTarget(LUNAR_MISSION_SCENE), missionFixture.hash, { ...missionRender, nodes: [] }, missionFixture.instances), + /draws 0 actions/u, + ); +}); + +test("traceRecord keeps an action record's token, step, feature and printed value", () => { + const record: TraceRecord = { + kind: "assign", state: "translunarInjection", token: "1", step: 2, feature: "remaining", + value: "6350 'm/s'", text: "assign remaining = 6350 ['m/s']", + }; + assert.deepEqual(traceRecord(traceEvent(record)), record); + const bare: TraceRecord = { kind: "complete", step: 13, text: "complete" }; + assert.deepEqual(traceRecord(traceEvent(bare)), bare); +}); + +test("runAction performs the action on its object with its events and keeps the outputs and the partial trace", async () => { + const model = landingModel(sceneTarget(LUNAR_MISSION_SCENE), missionFixture.hash, missionRender, missionFixture.instances); + const trace: TraceRecord[] = [ + { kind: "step", to: "start", token: "1", step: 1, text: "step start" }, + { kind: "step", from: "start", to: "translunarInjection", token: "1", step: 1, text: "step start -> translunarInjection" }, + { kind: "assign", state: "translunarInjection", token: "1", step: 2, feature: "remaining", value: "6350 'm/s'", text: "assign remaining = 6350 ['m/s']" }, + { kind: "complete", step: 3, text: "complete" }, + ]; + const outputs = new Map([ + ["landed", { kind: "boolean", value: true }], + ["remaining", { kind: "quantity", magnitude: { kind: "int", value: 480n }, unit: "'m/s'" } as unknown as SysMLValue], + ]); + const { connection, calls } = fakeConnection({ trace, outputs }); + const [go] = LUNAR_MISSION_SCENE.variants; + const program = programOf(LUNAR_MISSION_SCENE, go); + assert.equal(program.kind, "action"); + const run = await runAction(connection, program, model); + assert.deepEqual(calls[0], { + method: "ExecuteAction", + params: [missionFixture.hash, "MissionSequence::LunarMission", { performer: "MissionSequence::apollo11", events: ["GoForDescent"], trace: true }], + }); + assert.deepEqual(run, { + visited: ["start", "translunarInjection"], + trace, + outputs: new Map([["landed", "true"], ["remaining", "480 'm/s'"]]), + }); + + const failed = fakeConnection({ + executeError: new ExecutionError("action execution failed: accept deadlock in action LunarMission", "unspecified", [], trace.slice(0, 3).map(traceEvent)), + }); + assert.deepEqual(await runAction(failed.connection, program, model), { + visited: [], + trace: trace.slice(0, 3), + error: "action execution failed: accept deadlock in action LunarMission", + }); + await assert.rejects(runAction(fakeConnection({ executeError: new Error("offline") }).connection, program, model), /offline/u); +}); + +test("debugSteps follows an action's token through its steps and accumulates the values it assigns", () => { + const trace: TraceRecord[] = [ + { kind: "step", to: "start", token: "1", step: 1, text: "step start" }, + { kind: "step", from: "start", to: "translunarInjection", token: "1", step: 1, text: "step start -> translunarInjection" }, + { kind: "assign", state: "translunarInjection", token: "1", step: 2, feature: "remaining", value: "6350 'm/s'", text: "assign remaining = 6350 ['m/s']" }, + { kind: "step", from: "translunarInjection", to: "landingGo", token: "1", step: 2, text: "step translunarInjection -> landingGo" }, + { kind: "decide", state: "landingGo", alternatives: ["1->awaitGo", "2->transEarthInjection"], taken: "1->awaitGo", token: "1", step: 3, text: "decide landingGo -> 1->awaitGo" }, + { kind: "step", from: "landingGo", to: "awaitGo", token: "1", step: 3, text: "step landingGo -> awaitGo" }, + { kind: "wait", state: "awaitGo", event: "GoForDescent", token: "1", step: 4, text: "wait GoForDescent" }, + { kind: "accept", state: "awaitGo", event: "GoForDescent", token: "1", step: 5, text: "accept GoForDescent" }, + { kind: "step", from: "awaitGo", to: "descent", token: "1", step: 5, text: "step awaitGo -> descent" }, + { kind: "assign", state: "descent", token: "1", step: 6, feature: "remaining", value: "3350 'm/s'", text: "assign remaining = 3350 ['m/s']" }, + { kind: "complete", step: 7, text: "complete" }, + ]; + const steps = debugSteps(trace); + assert.deepEqual( + steps.map(({ state }) => state), + ["start", "translunarInjection", "translunarInjection", "landingGo", "landingGo", "awaitGo", "awaitGo", "awaitGo", "descent", "descent", "descent"], + ); + assert.deepEqual(steps[1].edge, { from: "start", to: "translunarInjection" }); + assert.equal(Object.hasOwn(steps[0], "edge"), false, "the first step arrives from nowhere"); + assert.equal(Object.hasOwn(steps[2], "edge"), false); + assert.deepEqual(steps.map(({ assigned }) => assigned.length), [0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2]); + assert.deepEqual(steps[9].assigned, [ + { at: "translunarInjection", feature: "remaining", value: "6350 'm/s'" }, + { at: "descent", feature: "remaining", value: "3350 'm/s'" }, + ]); + assert.deepEqual(steps.map(({ accepted }) => accepted), [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]); + assert.equal(Object.hasOwn(steps[7], "ignored"), false, "an action's accept is followed by a step, not a transition"); +}); diff --git a/editors/vscode/src/landing/model.ts b/editors/vscode/src/landing/model.ts index d28f5336ea..e3f7832963 100644 --- a/editors/vscode/src/landing/model.ts +++ b/editors/vscode/src/landing/model.ts @@ -11,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, SceneClock, SceneJourney, SceneProgram } from "./scene"; +import type { Scene, SceneAction, SceneClock, SceneJourney, SceneProgram } from "./scene"; export interface Diagnostic { message: string; @@ -60,6 +60,22 @@ export interface LandingMachine { states: Map; } +/** A node of the action a scene draws: its box, and the name the trace records it under. */ +export interface LandingActionNode { + id: string; + /** The node's name as the trace records carry it: `awaitGo`. */ + name: string; + /** What RenderView drew it as: `initial`, `action`, `decision`, `final`. */ + kind: string; +} + +/** The action a scene draws: its nodes by name. */ +export interface LandingAction { + /** The action definition drawn: `MissionSequence::LunarMission`. */ + symbol: string; + nodes: Map; +} + /** What the root is drawn as: its symbol and the view RenderView draws of it. */ export interface SceneTarget { rootSymbol: string; @@ -76,6 +92,8 @@ export interface LandingModel { root: LandingPart; /** The state machine the view draws, when it draws one rather than the root's composition. */ machine?: LandingMachine; + /** The action the view draws, when it draws one. */ + action?: LandingAction; } export type ModelRead = { diagnostics: Diagnostic[]; model?: LandingModel }; @@ -107,7 +125,7 @@ export interface EngineInstance { } export interface TraceRecord { - kind: string; // entry | exit | accept | transition | choice | send | do | guard + kind: string; // entry | exit | accept | transition | choice | send | do | guard | step | wait | decide | assign | complete state?: string; from?: string; to?: string; @@ -116,14 +134,30 @@ export interface TraceRecord { taken?: string; /** The simulated instant the record was written at, when the engine timed it. */ time?: EngineQuantity; + /** The action token the record is about, as the executor numbers them. */ + token?: string; + /** The executor step the record was written at. */ + step?: number; + /** The feature an `assign` record wrote, and the value the engine wrote to it, printed. */ + feature?: string; + value?: string; text: string; } +/** A value an action run assigned, as its `assign` record reports it: at which node, to which feature. */ +export interface Assignment { + at: string; + feature: string; + value: string; +} + export interface JourneyRun { visited: string[]; trace: TraceRecord[]; /** The simulated instant the clock stopped at; absent when the run failed. */ finalTime?: number; + /** An action's outputs as the engine returned them, printed; absent when the run failed. */ + outputs?: Map; error?: string; } @@ -133,6 +167,8 @@ export interface DebugStep { state?: string; /** This record's transition, when it is one. */ edge?: { from: string; to: string }; + /** The values an action run has assigned up to this record, in order. */ + assigned: Assignment[]; /** How many events have been accepted up to and including this record. */ accepted: number; /** An accept record that fired no transition before the next accept or the end of the trace. */ @@ -305,6 +341,10 @@ export function traceRecord(event: DocumentEvent): TraceRecord { ...(event.event === "" ? {} : { event: event.event }), ...(event.alternatives.length === 0 ? {} : { alternatives: [...event.alternatives] }), ...(event.taken === "" ? {} : { taken: event.taken }), + ...(event.token === 0n ? {} : { token: String(event.token) }), + ...(event.step === 0 ? {} : { step: event.step }), + ...(event.feature === "" ? {} : { feature: event.feature }), + ...(event.value === undefined ? {} : { value: valueText(event.value) }), text: event.text, }; } @@ -326,6 +366,9 @@ export function landingModel( if (render.kind === "state") { return machineModel(target, hash, render, instances); } + if (render.kind === "action") { + return actionModel(target, hash, render, instances); + } const { rootSymbol } = target; const rootName = rootSymbol.slice(rootSymbol.lastIndexOf("::") + 2); const root = render.nodes.find( @@ -528,9 +571,56 @@ function machineModel( }; } -/** 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. */ +/** + * actionModel is an action view as the page keeps it: the action's own nodes lifted out of its + * box and keyed by the name the trace records carry, the successions kept, and the root the + * object performing the action, as Instantiate materialized it. + */ +function actionModel( + target: SceneTarget, + hash: string, + render: RenderResult, + instances: readonly EngineInstance[], +): LandingModel { + const { rootSymbol } = target; + const rootName = rootSymbol.slice(rootSymbol.lastIndexOf("::") + 2); + const actions = render.nodes.filter((node) => node.parent === undefined); + if (actions.length !== 1) { + throw new Error(`rendered action view draws ${actions.length} actions, want one`); + } + const [action] = actions; + const nodes = new Map(); + const kept: RenderNode[] = []; + for (const node of render.nodes) { + if (node.parent !== action.id) { + continue; + } + const { parent: _parent, ...lifted } = node; + kept.push(lifted); + if (node.name !== "") { + nodes.set(node.name, { id: node.id, name: node.name, kind: node.kind }); + } + } + const retained = new Set(kept.map((node) => node.id)); + const normalized = normalizeRender({ + ...render, + nodes: kept, + 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(action.id, rootName, "", rootInstance, rootName, undefined), + action: { symbol: action.name, nodes }, + }; +} + +/** stateIdOf is the box a state the engine named is drawn as: a machine's state (by path or unambiguous name), an action's node, 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; + return model.machine?.states.get(state)?.id ?? model.action?.nodes.get(state)?.id ?? model.parts.get(state)?.id; } /** sceneTarget is the root a scene draws by default, before any variant is switched on. */ @@ -605,6 +695,14 @@ export function valueText(value: SysMLValue): string { return String(value.value); case "rational": return `${value.numerator}/${value.denominator}`; + case "boolean": + return String(value.value); + case "string": + return value.value; + case "instance": + return `instance #${value.id}`; + case "sequence": + return value.elements.map(valueText).join(", "); case "undetermined": return value.reason === "" ? "undetermined" : `undetermined (${value.reason})`; case "unset": @@ -682,6 +780,28 @@ export async function runClock(connection: Connection, program: SceneClock, mode } } +/** Performs the program's action on its object, posting its events as the run waits, with the trace. */ +export async function runAction(connection: Connection, program: SceneAction, model: LandingModel): Promise { + try { + const result = await connection.executeAction(model.hash, program.symbol, { + performer: program.performer, + events: program.events, + trace: true, + }); + const outputs = new Map(); + for (const [name, value] of result.outputs) { + outputs.set(name, value instanceof Error ? value.message : valueText(value)); + } + const trace = result.trace.map(traceRecord); + return { visited: trace.flatMap((record) => (record.kind === "step" && record.to !== undefined ? [record.to] : [])), trace, outputs }; + } 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, @@ -708,25 +828,36 @@ export async function runJourney( export function debugSteps(trace: readonly TraceRecord[]): DebugStep[] { let state: string | undefined; let accepted = 0; + let assigned: Assignment[] = []; return trace.map((record, index) => { if (record.kind === "entry" && record.state !== undefined) { state = record.state; } + // An action's token is at the node a `step` arrived at; a wait, decide or assign names the node it is at. + if (record.kind === "step" && record.to !== undefined) { + state = record.to; + } else if ((record.kind === "wait" || record.kind === "decide" || record.kind === "assign") && record.state !== undefined) { + state = record.state; + } + if (record.kind === "assign" && record.state !== undefined && record.feature !== undefined && record.value !== undefined) { + assigned = [...assigned, { at: record.state, feature: record.feature, value: record.value }]; + } if (record.kind === "accept") { accepted += 1; } + const moved = (record.kind === "transition" || record.kind === "step") && record.from !== undefined && record.to !== undefined; const step: DebugStep = { record, ...(state === undefined ? {} : { state }), - ...(record.kind === "transition" && record.from !== undefined && record.to !== undefined - ? { edge: { from: record.from, to: record.to } } - : {}), + ...(moved ? { edge: { from: record.from!, to: record.to! } } : {}), + assigned, accepted, }; if (record.kind === "accept") { let fired = false; + // An accept fired when a transition or an action step moved on before the next accept. for (let next = index + 1; next < trace.length && trace[next].kind !== "accept"; next += 1) { - if (trace[next].kind === "transition") { + if (trace[next].kind === "transition" || trace[next].kind === "step") { fired = true; break; } diff --git a/editors/vscode/src/landing/present.ts b/editors/vscode/src/landing/present.ts index 15af18571d..b770420acb 100644 --- a/editors/vscode/src/landing/present.ts +++ b/editors/vscode/src/landing/present.ts @@ -38,6 +38,11 @@ export function presentedPhases(model: LandingModel): RenderResult { return model.render; } +/** presentedMission is the landing model as the hero draws an action: its steps as the view drew them, each succession a wire. */ +export function presentedMission(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 a6070c3c6a..3908a72fda 100644 --- a/editors/vscode/src/landing/scene.test.ts +++ b/editors/vscode/src/landing/scene.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { MASS_ROLLUP_SCENE, + LUNAR_MISSION_SCENE, MISSION_PHASES_SCENE, SCENES, STACK_SCENE, @@ -14,7 +15,7 @@ import { } from "./scene"; test("the scenes declare distinct ids, controls, programs and snapshots", () => { - assert.deepEqual(SCENES.map(({ id }) => id), ["stack", "mass", "phases"]); + assert.deepEqual(SCENES.map(({ id }) => id), ["stack", "mass", "phases", "apollo"]); assert.equal(STACK_SCENE.program.kind, "journey"); assert.deepEqual(STACK_SCENE.controls, ["run", "edit", "debug", "download"]); assert.equal(MASS_ROLLUP_SCENE.program.kind, "rollup"); @@ -25,6 +26,9 @@ test("the scenes declare distinct ids, controls, programs and snapshots", () => 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"]); + assert.equal(LUNAR_MISSION_SCENE.program.kind, "action"); + assert.equal(LUNAR_MISSION_SCENE.view, "#action:MissionSequence::LunarMission"); + assert.equal(LUNAR_MISSION_SCENE.rootSymbol, "MissionSequence::apollo11", "the root is the object performing the action"); 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`); @@ -64,6 +68,28 @@ test("the clocked scene's variant keeps the root and the view but advances the c assert.equal(programOf(MASS_ROLLUP_SCENE, iu), MASS_ROLLUP_SCENE.program); }); +test("the mission scene's toggles post the event, change the performer, or perform the unordered action", () => { + const [go, apollo8, unordered] = LUNAR_MISSION_SCENE.variants; + assert.deepEqual(programOf(LUNAR_MISSION_SCENE, undefined), { + kind: "action", symbol: "MissionSequence::LunarMission", performer: "MissionSequence::apollo11", events: [], + }); + assert.equal(go?.rootSymbol, LUNAR_MISSION_SCENE.rootSymbol); + assert.equal(go?.view, LUNAR_MISSION_SCENE.view); + assert.deepEqual(programOf(LUNAR_MISSION_SCENE, go), { + kind: "action", symbol: "MissionSequence::LunarMission", performer: "MissionSequence::apollo11", events: ["GoForDescent"], + }); + assert.equal(apollo8?.rootSymbol, "MissionSequence::apollo8"); + assert.equal(apollo8?.view, LUNAR_MISSION_SCENE.view); + assert.deepEqual(programOf(LUNAR_MISSION_SCENE, apollo8), { + kind: "action", symbol: "MissionSequence::LunarMission", performer: "MissionSequence::apollo8", events: [], + }); + assert.equal(unordered?.view, "#action:MissionSequence::LunarMissionUnordered"); + assert.deepEqual(programOf(LUNAR_MISSION_SCENE, unordered), { + kind: "action", symbol: "MissionSequence::LunarMissionUnordered", performer: "MissionSequence::apollo11", events: [], + }); + assert.ok(LUNAR_MISSION_SCENE.variants.every((variant) => variant.requires === undefined), "the toggles stand alone"); +}); + 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"); @@ -81,18 +107,19 @@ test("chooseScene honours a forced scene and falls back to the random draw", () assert.equal(chooseScene(SCENES, new URL("https://example.test/#scene=stack"), never), STACK_SCENE); 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), MISSION_PHASES_SCENE); - assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 1), MISSION_PHASES_SCENE); + assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 0.25), MASS_ROLLUP_SCENE); + assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 0.5), MISSION_PHASES_SCENE); + assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 0.999999), LUNAR_MISSION_SCENE); + assert.equal(chooseScene(SCENES, new URL("https://example.test/"), () => 1), LUNAR_MISSION_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 = 999; + const draws = 1000; 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 / 3], ["mass", draws / 3], ["phases", draws / 3]]); + assert.deepEqual([...counts.entries()], [["stack", draws / 4], ["mass", draws / 4], ["phases", draws / 4], ["apollo", draws / 4]]); }); diff --git a/editors/vscode/src/landing/scene.ts b/editors/vscode/src/landing/scene.ts index a3dabc8310..f0a284e98e 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 { presentedPhases, presentedStack, stackTone } from "./present"; +import { presentedMission, presentedPhases, presentedStack, stackTone } from "./present"; import mass from "./mass.json"; +import mission from "./mission.json"; import phases from "./phases.json"; import stack from "./stack.json"; @@ -32,7 +33,18 @@ export interface SceneClock { advance: number; } -export type SceneProgram = SceneJourney | SceneRollup | SceneClock; +/** A scene whose Run performs an action on an object, posting events as the run waits, and animates the trace it records. */ +export interface SceneAction { + kind: "action"; + /** The action ExecuteAction performs: `MissionSequence::LunarMission`. */ + symbol: string; + /** The object performing it: `MissionSequence::apollo11`. */ + performer: string; + /** The events posted to the performer in order, each once the run waits for it. */ + events: readonly string[]; +} + +export type SceneProgram = SceneJourney | SceneRollup | SceneClock | SceneAction; /** 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"; @@ -188,7 +200,52 @@ export const MISSION_PHASES_SCENE: Scene = { ], }; -export const SCENES: readonly Scene[] = [STACK_SCENE, MASS_ROLLUP_SCENE, MISSION_PHASES_SCENE]; +const LUNAR_MISSION = "#action:MissionSequence::LunarMission"; + +export const LUNAR_MISSION_SCENE: Scene = { + id: "apollo", + title: "Apollo 11 mission", + description: "A lunar mission's steps as an action the engine performs, with the velocity budget left after each burn", + documentName: "mission-sequence.sysml", + rootSymbol: "MissionSequence::apollo11", + view: LUNAR_MISSION, + show: ["edge-labels"], + snapshot: mission, + controls: ["run", "edit", "debug", "download"], + form: { kind: "diagram", present: presentedMission, tone: () => undefined, pngName: "lunar-mission.png" }, + program: { kind: "action", symbol: "MissionSequence::LunarMission", performer: "MissionSequence::apollo11", events: [] }, + variants: [ + { + id: "go", + label: "Send GoForDescent", + activeLabel: "Hold at awaitGo", + rootSymbol: "MissionSequence::apollo11", + view: LUNAR_MISSION, + hint: "Run now posts GoForDescent to Apollo 11 once the accept at awaitGo waits for it, and the run goes on through descent, surfaceOperations, ascent and the return.", + program: { kind: "action", symbol: "MissionSequence::LunarMission", performer: "MissionSequence::apollo11", events: ["GoForDescent"] }, + }, + { + id: "apollo8", + label: "Fly Apollo 8", + activeLabel: "Fly Apollo 11", + rootSymbol: "MissionSequence::apollo8", + view: LUNAR_MISSION, + hint: "Apollo 8 flies the same action with a 5500 m/s budget: the decide at landingGo takes whichever branch the engine's guard allows.", + program: { kind: "action", symbol: "MissionSequence::LunarMission", performer: "MissionSequence::apollo8", events: [] }, + }, + { + id: "unordered", + label: "Remove the start", + activeLabel: "Restore the start", + rootSymbol: "MissionSequence::apollo11", + view: "#action:MissionSequence::LunarMissionUnordered", + hint: "LunarMissionUnordered declares three steps with no first and no then: Run shows what the engine makes of an action with no starting step.", + program: { kind: "action", symbol: "MissionSequence::LunarMissionUnordered", performer: "MissionSequence::apollo11", events: [] }, + }, + ], +}; + +export const SCENES: readonly Scene[] = [STACK_SCENE, MASS_ROLLUP_SCENE, MISSION_PHASES_SCENE, LUNAR_MISSION_SCENE]; /** requiredVariant is the variant `variant` builds on; undefined when it stands alone. */ export function requiredVariant(scene: Scene, variant: SceneVariant): SceneVariant | undefined { diff --git a/editors/vscode/src/landing/shell.ts b/editors/vscode/src/landing/shell.ts index 6083dfc1c8..3870071f5c 100644 --- a/editors/vscode/src/landing/shell.ts +++ b/editors/vscode/src/landing/shell.ts @@ -67,11 +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. */ +/** readSummary counts what a read model shows: a machine's states and transitions, an action's steps and successions, else parts and interfaces. */ export function readSummary(model: LandingModel): string { if (model.machine) { return `${model.machine.states.size} states, ${model.render.edges.length} transitions`; } + if (model.action) { + return `${model.action.nodes.size} steps, ${model.render.edges.length} successions`; + } return `${model.parts.size} parts, ${model.render.edges.length} interfaces`; } @@ -269,6 +272,8 @@ export function mountShell(root: HTMLElement, scene: Scene, options: MountOption } if (current && next !== undefined) { status(next.hint); + } else if (current) { + status(`Showing ${shortName(scene.rootSymbol)} again: ${readSummary(current)}.`); } if (!current) { revertVariant(); diff --git a/overrides/home.html b/overrides/home.html index bd17407f71..b3fe46f499 100644 --- a/overrides/home.html +++ b/overrides/home.html @@ -378,14 +378,26 @@ .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-state.is-live > .shape { + .osml-diagram g.osml-state.is-live > .shape, + .osml-diagram g.osml-step.is-live > .shape { stroke: #ffd54f; stroke-width: 3; } .osml-diagram g.osml-part.is-live, - .osml-diagram g.osml-state.is-live { + .osml-diagram g.osml-state.is-live, + .osml-diagram g.osml-step.is-live { filter: drop-shadow(0 0 .5rem #ffd54f); } + /* A value an action run assigned, written under the step that assigned it as the trace reports it. */ + .osml-diagram text.osml-value { + font-size: 11px; + font-weight: 600; + fill: #ffd54f; + paint-order: stroke; + stroke: rgba(13, 27, 62, .85); + stroke-width: 3px; + pointer-events: none; + } .osml-diagram g.opensysml-edge.is-live .line { stroke: #ffd54f; stroke-width: 2; @@ -963,6 +975,7 @@

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

+