From f401dbcc3d350ace7448ab397a600b96056f94e4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:32:59 +0000 Subject: [PATCH 1/4] feat(landing): Apollo 11 action scene on the ExecuteAction trace The homepage hero gains an "Apollo 11 mission" scene drawing MissionSequence::LunarMission as an action diagram. Run performs the action on apollo11 through the wasm engine with trace: true and replays the records the engine wrote: the token at each node a step reached, each assigned value written under the node that assigned it, the branch the decide took, and the wait at awaitGo for GoForDescent. Toggles post GoForDescent to the waiting accept, fly apollo8, or perform LunarMissionUnordered, showing whatever the engine answers. model.ts keeps an action view as a LandingAction (its nodes by the name the trace records carry) beside the part-shaped and machine-shaped models; debugSteps follows step/wait/decide/assign records and accumulates the assignments. The first paint is pinned by tests/wasm/landing_mission_test.go, which also pins the four traced runs the scene offers. Co-Authored-By: jason.han --- .../landing-apollo-action-scene.added.md | 1 + editors/vscode/src/landing/main.ts | 94 +- editors/vscode/src/landing/mission.json | 1578 +++++++++++++++++ editors/vscode/src/landing/model.test.ts | 131 +- editors/vscode/src/landing/model.ts | 145 +- editors/vscode/src/landing/present.ts | 5 + editors/vscode/src/landing/scene.test.ts | 39 +- editors/vscode/src/landing/scene.ts | 63 +- overrides/home.html | 21 +- tests/wasm/landing_mission_test.go | 317 ++++ 10 files changed, 2367 insertions(+), 27 deletions(-) create mode 100644 changes/unreleased/landing-apollo-action-scene.added.md create mode 100644 editors/vscode/src/landing/mission.json create mode 100644 tests/wasm/landing_mission_test.go 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..bd66b73d92 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 }); } @@ -1091,6 +1128,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 +1218,40 @@ 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 outputs = [...(result.outputs ?? [])].map(([name, value]) => `${name} = ${value}`); + status(`${ran}: ${steps.length} trace records${outputs.length === 0 ? "" : `; ${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 +1324,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 +1342,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 +1380,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..e3970db00e 100644 --- a/editors/vscode/src/landing/model.test.ts +++ b/editors/vscode/src/landing/model.test.ts @@ -13,14 +13,16 @@ 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"; interface LandingFixture { hash: string; @@ -34,6 +36,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 +130,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 +148,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 +195,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 +542,108 @@ 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.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..432148f39b 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,12 @@ 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 "sequence": + return value.elements.map(valueText).join(", "); case "undetermined": return value.reason === "" ? "undetermined" : `undetermined (${value.reason})`; case "unset": @@ -682,6 +778,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 +826,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/overrides/home.html b/overrides/home.html index 5040a095b5..e2d417d7a4 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.

+