From 3cdeee7c04086f2158d518b3725316c5f85e2c98 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 24 Aug 2026 11:09:22 +0200 Subject: [PATCH 1/3] feat(devtools): add planner-backed simulation --- .changeset/calm-planners-trace.md | 7 + packages/devtools/README.md | 12 +- packages/devtools/src/DevServer.ts | 8 +- packages/devtools/src/DevToolsProtocol.ts | 266 +++++++++++++ packages/devtools/src/ProjectInspector.ts | 5 + .../src/internal/browser/planner-example.ts | 67 ++++ .../src/internal/browser/simulation-client.ts | 20 + .../devtools/src/internal/browser/styles.css | 158 +++++++- .../src/internal/browser/visualizer-app.ts | 372 ++++++++++++++++-- .../src/internal/browser/visualizer.ts | 2 +- packages/devtools/src/internal/devServer.ts | 98 ++++- .../devtools/src/internal/evaluationWorker.ts | 298 +++++++++++++- .../devtools/src/internal/projectInspector.ts | 73 +++- .../devtools/test/ProjectInspector.test.ts | 133 +++++++ 14 files changed, 1446 insertions(+), 73 deletions(-) create mode 100644 .changeset/calm-planners-trace.md create mode 100644 packages/devtools/src/internal/browser/planner-example.ts create mode 100644 packages/devtools/src/internal/browser/simulation-client.ts diff --git a/.changeset/calm-planners-trace.md b/.changeset/calm-planners-trace.md new file mode 100644 index 0000000..89acedd --- /dev/null +++ b/.changeset/calm-planners-trace.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine-devtools": minor +--- + +Add planner-backed simulation sessions to the web visualizer. Machine input and event payloads can be entered as JSON, while each isolated step uses the real Effect Machine planner and shows selected branches, topology changes, raised and emitted events, planned commands, completion, and output as a structured trace. + +Planning evaluates synchronous statechart callbacks but does not commit commands or start runtime activities. Schema and planning failures remain visible beside the machine topology. diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 52ad096..ef3b2ef 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -66,16 +66,20 @@ Run the devtools only against code you trust. The server has no authentication a The visualizer shows topology, active initial paths, state annotations, events, transitions, branches, state updates, activities, source metadata, and diagnostics. The tree supports pointer and keyboard navigation, subtree expansion, related-state highlighting, and structured detail inspection. -Simulation works from the serialized machine document and never runs project code. It advances only when an event has one required direct transition whose target is statically known. +Simulation uses the same `Machine.planInitial` and `Machine.plan` semantics as the core package. Start a session with optional JSON machine input, select an enabled event, edit its JSON payload, and inspect the resulting macrostep as structured microsteps. The trace includes selected branches, before/after topology, exits, entries, state updates, raised events, emitted events, planned commands, completion, and output. -Declinable transitions, conditional branches, parallel transitions, history, and choices return an indeterminate result. Deterministic steps report skipped state updates, runtime effects, raised events, reentry lifecycles, and automatic stabilization instead of pretending to execute them. +Each plan loads the exported machine in a fresh worker, decodes the portable session snapshot, and evaluates synchronous statechart callbacks. This supports conditional branches, parallel transitions, history, choices, state updates, reentry, and automatic stabilization. It also means synchronous code inside initial, transition, entry, exit, choice, history, and output callbacks runs during planning. + +The planner does not commit commands, start activities, invoke children, deliver `sendTo` events, or run returned Effects. Commands and emissions are shown in the trace instead. A worker is discarded after every request and a planning request is limited to ten seconds, but the devtools are still intended only for trusted projects. + +Simulation sessions use encoded snapshots and are tied to one source revision. A file change remounts the latest document; restart the simulation to use the new definition. Schema decoding and planning failures remain visible as diagnostics without discarding the topology. ## Programmatic modules The first release publishes three programmatic modules: -- `DevToolsProtocol` defines the versioned worker and browser messages. +- `DevToolsProtocol` defines the versioned worker, browser, and planner-session messages. - `MachineDocument` defines and constructs the serializable inspection document. -- `MachineSimulator` provides the side-effect-free document simulator. +- `MachineSimulator` provides the conservative, document-only simulator for consumers that cannot load project code. The project inspector, registry, worker, and local server remain implementation modules. Their interfaces can change without becoming package-level compatibility commitments. diff --git a/packages/devtools/src/DevServer.ts b/packages/devtools/src/DevServer.ts index f22d274..e53d959 100644 --- a/packages/devtools/src/DevServer.ts +++ b/packages/devtools/src/DevServer.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect" import * as Schema from "effect/Schema" import * as internal from "./internal/devServer.js" import type * as MachineRegistry from "./MachineRegistry.js" +import type * as ProjectInspector from "./ProjectInspector.js" /** * @category models @@ -41,5 +42,8 @@ export class DevServerError extends Schema.Error( * @category constructors * @since 0.23.0 */ -export const run = (options: Options): Effect.Effect => - internal.run(DevServerError, options) +export const run = (options: Options): Effect.Effect< + never, + DevServerError, + MachineRegistry.MachineRegistry | ProjectInspector.ProjectInspector +> => internal.run(DevServerError, options) diff --git a/packages/devtools/src/DevToolsProtocol.ts b/packages/devtools/src/DevToolsProtocol.ts index c8a32af..355d6b6 100644 --- a/packages/devtools/src/DevToolsProtocol.ts +++ b/packages/devtools/src/DevToolsProtocol.ts @@ -144,3 +144,269 @@ export const RegistrySnapshot = Schema.Struct({ * @since 0.23.0 */ export type RegistrySnapshot = Schema.Schema.Type + +/** + * JSON-safe representation of a persisted machine snapshot. + * + * @category schemas + * @since 0.24.0 + */ +export const EncodedSnapshot = Schema.Struct({ + _tag: Schema.Literal("MachineSnapshot"), + active: Schema.Array(Schema.Struct({ + path: Schema.String, + value: Schema.optionalKey(Schema.Json) + })), + completed: Schema.optionalKey(Schema.Array(Schema.Struct({ + path: Schema.String, + output: Schema.optionalKey(Schema.Json) + }))), + history: Schema.optionalKey(Schema.Record( + Schema.String, + Schema.Struct({ + mode: Schema.Literals(["shallow", "deep"]), + active: Schema.Array(Schema.String), + values: Schema.Record(Schema.String, Schema.Json) + }) + )) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type EncodedSnapshot = Schema.Schema.Type + +const SimulationRequestFields = { + protocolVersion: Schema.Literal(protocolVersion), + key: Schema.String, + revision: Schema.Natural, + source: MachineDocument.Source +} + +/** + * Starts an isolated planner session. Omitting `input` calls a machine that + * declares no input; otherwise the JSON value is decoded by its input schema. + * + * @category schemas + * @since 0.24.0 + */ +export const StartSimulation = Schema.Struct({ + ...SimulationRequestFields, + _tag: Schema.tag("StartSimulation"), + input: Schema.optionalKey(Schema.Json) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type StartSimulation = Schema.Schema.Type + +/** + * Plans one JSON event from an encoded session snapshot. + * + * @category schemas + * @since 0.24.0 + */ +export const SendSimulationEvent = Schema.Struct({ + ...SimulationRequestFields, + _tag: Schema.tag("SendSimulationEvent"), + step: Schema.Natural, + snapshot: EncodedSnapshot, + event: Schema.Json +}) + +/** + * @category models + * @since 0.24.0 + */ +export type SendSimulationEvent = Schema.Schema.Type + +/** + * Request accepted by the isolated machine planner. + * + * @category schemas + * @since 0.24.0 + */ +export const SimulationRequest = Schema.Union([StartSimulation, SendSimulationEvent]) + +/** + * @category models + * @since 0.24.0 + */ +export type SimulationRequest = Schema.Schema.Type + +/** + * A compact view of one logical machine snapshot. + * + * @category schemas + * @since 0.24.0 + */ +export const SimulationSnapshot = Schema.Struct({ + activePaths: Schema.Array(Schema.String), + candidateEvents: Schema.Array(Schema.String) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type SimulationSnapshot = Schema.Schema.Type + +/** + * Transition selected by the planner after hierarchy and conflict resolution. + * + * @category schemas + * @since 0.24.0 + */ +export const PlannedTransition = Schema.Struct({ + source: Schema.String, + trigger: MachineDocument.Trigger, + reenter: Schema.Boolean, + branchIndex: Schema.Natural, + branchKey: Schema.NullOr(Schema.String), + target: Schema.NullOr(Schema.String), + resolvedTarget: Schema.NullOr(Schema.String), + updates: Schema.Array(Schema.String) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type PlannedTransition = Schema.Schema.Type + +/** + * Closed command produced by planning. Commands are displayed but never + * committed by the visualizer. + * + * @category schemas + * @since 0.24.0 + */ +export const PlannedCommand = Schema.Union([ + Schema.Struct({ + _tag: Schema.tag("SendTo"), + target: Schema.String, + event: Schema.Json + }), + Schema.Struct({ + _tag: Schema.tag("Stop"), + target: Schema.String + }) +]) + +/** + * @category models + * @since 0.24.0 + */ +export type PlannedCommand = Schema.Schema.Type + +/** + * One statechart microstep retained in a planned macrostep. + * + * @category schemas + * @since 0.24.0 + */ +export const SimulationMicrostep = Schema.Struct({ + index: Schema.Natural, + event: Schema.Json, + transitions: Schema.Array(PlannedTransition), + raisedEvents: Schema.Array(Schema.Json), + emittedEvents: Schema.Array(Schema.Json), + commands: Schema.Array(PlannedCommand), + exitPaths: Schema.Array(Schema.String), + entryPaths: Schema.Array(Schema.String), + activePaths: Schema.Array(Schema.String), + changed: Schema.Boolean +}) + +/** + * @category models + * @since 0.24.0 + */ +export type SimulationMicrostep = Schema.Schema.Type + +/** + * Structured trace for an initial plan or one received event. + * + * @category schemas + * @since 0.24.0 + */ +export const SimulationFrame = Schema.Struct({ + step: Schema.Natural, + trigger: Schema.Union([ + Schema.Struct({ _tag: Schema.tag("Initial"), input: Schema.optionalKey(Schema.Json) }), + Schema.Struct({ _tag: Schema.tag("Event"), event: Schema.Json }) + ]), + before: SimulationSnapshot, + after: SimulationSnapshot, + microsteps: Schema.Array(SimulationMicrostep), + commands: Schema.Array(PlannedCommand), + emittedEvents: Schema.Array(Schema.Json), + done: Schema.Boolean, + output: Schema.optionalKey(Schema.Json) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type SimulationFrame = Schema.Schema.Type + +/** + * Successful planner response containing the next portable session state. + * + * @category schemas + * @since 0.24.0 + */ +export const SimulationReady = Schema.Struct({ + protocolVersion: Schema.Literal(protocolVersion), + _tag: Schema.tag("SimulationReady"), + key: Schema.String, + revision: Schema.Natural, + step: Schema.Natural, + snapshot: EncodedSnapshot, + current: SimulationSnapshot, + frame: SimulationFrame +}) + +/** + * @category models + * @since 0.24.0 + */ +export type SimulationReady = Schema.Schema.Type + +/** + * Recoverable failure produced while loading, decoding, or planning a session. + * + * @category schemas + * @since 0.24.0 + */ +export const SimulationFailed = Schema.Struct({ + protocolVersion: Schema.Literal(protocolVersion), + _tag: Schema.tag("SimulationFailed"), + key: Schema.String, + revision: Schema.Natural, + diagnostics: Schema.Array(Diagnostic) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type SimulationFailed = Schema.Schema.Type + +/** + * Result returned by the isolated machine planner. + * + * @category schemas + * @since 0.24.0 + */ +export const SimulationResult = Schema.Union([SimulationReady, SimulationFailed]) + +/** + * @category models + * @since 0.24.0 + */ +export type SimulationResult = Schema.Schema.Type diff --git a/packages/devtools/src/ProjectInspector.ts b/packages/devtools/src/ProjectInspector.ts index 933b2ee..2697d17 100644 --- a/packages/devtools/src/ProjectInspector.ts +++ b/packages/devtools/src/ProjectInspector.ts @@ -92,6 +92,11 @@ export class ProjectInspector extends Context.Service Effect.Effect, DiscoveryError | EvaluationError> + /** @internal */ + readonly simulate: ( + request: DevToolsProtocol.SimulationRequest, + options: Pick + ) => Effect.Effect }>()("@typeonce/effect-machine-devtools/ProjectInspector") {} /** diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts new file mode 100644 index 0000000..577dc14 --- /dev/null +++ b/packages/devtools/src/internal/browser/planner-example.ts @@ -0,0 +1,67 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +class Idle extends Schema.TaggedClass("PlannerIdle")("Idle", { owner: Schema.String }) {} +class Working extends Schema.TaggedClass("PlannerWorking")("Working", { + owner: Schema.String, + job: Schema.String +}) {} +class Finished extends Schema.TaggedClass("PlannerFinished")("Finished", { job: Schema.String }) {} + +class Begin extends Schema.TaggedClass("PlannerBegin")("Begin", { + job: Schema.String, + priority: Schema.Literals(["normal", "urgent"]) +}) {} +class Cancel extends Schema.TaggedClass("PlannerCancel")("Cancel", { reason: Schema.String }) {} +class AutoFinish extends Schema.TaggedClass("PlannerAutoFinish")("AutoFinish", {}) {} +class Planned extends Schema.TaggedClass("PlannerPlanned")("Planned", { job: Schema.String }) {} + +const Events = Machine.events(Begin, Cancel) +const InternalEvents = Machine.internalEvents(AutoFinish) +const Emissions = Machine.emittedEvents(Planned) +const States = Machine.states({ + Idle, + Working, + Finished: { schema: Finished, type: "final", output: Schema.String } +}) + +export const plannerMachine = Machine.make({ + id: "planner-example", + states: States.states, + events: Events, + internalEvents: InternalEvents, + emittedEvents: Emissions, + input: Schema.Struct({ owner: Schema.String }), + initial: (to) => to.Idle().resolve(({ input, target }) => target.decoded(new Idle({ owner: input.owner }))) +}).handle({ + Idle: { + on: { + Begin: (to) => + to.branches({ + urgent: { title: "Finish immediately", target: to.full.Working() }, + normal: { title: "Wait in working", target: to.full.Working() } + }).resolve(({ event, self, select, state }, enqueue) => { + enqueue.emit(new Planned({ job: event.job })) + enqueue.sendTo(self, Events.Cancel({ reason: "planner command example" })) + if (event.priority === "urgent") enqueue.raise(InternalEvents.AutoFinish()) + const working = new Working({ owner: state.owner, job: event.job }) + return event.priority === "urgent" + ? select.urgent.decoded(working) + : select.normal.decoded(working) + }) + } + }, + Working: { + on: { + AutoFinish: (to) => + to.full.Finished().resolve(({ state, target }) => target.decoded(new Finished({ job: state.job }))), + Cancel: (to) => + to.full.Idle().resolve(({ event, state, target }) => + target.decoded(new Idle({ owner: `${state.owner} · ${event.reason}` })) + ) + } + }, + Finished: { + output: ({ state }) => state.job + } +}) diff --git a/packages/devtools/src/internal/browser/simulation-client.ts b/packages/devtools/src/internal/browser/simulation-client.ts new file mode 100644 index 0000000..5ca4c39 --- /dev/null +++ b/packages/devtools/src/internal/browser/simulation-client.ts @@ -0,0 +1,20 @@ +import * as Schema from "effect/Schema" +import * as DevToolsProtocol from "../../DevToolsProtocol.js" + +export const requestSimulation = async ( + request: DevToolsProtocol.SimulationRequest +): Promise => { + const response = await fetch("/api/simulations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request) + }) + const body: unknown = await response.json() + if (!response.ok) { + const message = typeof body === "object" && body !== null && "message" in body + ? String(body.message) + : `Simulation request failed with status ${response.status}` + throw new Error(message) + } + return Schema.decodeUnknownSync(DevToolsProtocol.SimulationResult)(body) +} diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css index bf33fca..16a2527 100644 --- a/packages/devtools/src/internal/browser/styles.css +++ b/packages/devtools/src/internal/browser/styles.css @@ -297,11 +297,11 @@ button { font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.simulation-feedback[data-status="indeterminate"] { +.simulation-feedback[data-status="pending"] { color: #d9ae7e; } -.simulation-feedback[data-status="blocked"] { +.simulation-feedback[data-status="error"] { color: #d58d89; } @@ -328,6 +328,11 @@ button { outline: none; } +.event-button:disabled { + color: #666d76; + cursor: wait; +} + .topology-node { background: transparent; } @@ -746,6 +751,155 @@ button { padding: 0 12px; } +.simulation-composer { + display: grid; + gap: 10px; +} + +.simulation-composer .section-heading { + margin-bottom: 0; +} + +.json-editor { + width: 100%; + min-height: 150px; + resize: vertical; + padding: 12px; + border: 1px solid var(--line); + border-radius: 3px; + color: #dfe7f5; + background: #0b0d10; + font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + tab-size: 2; + outline: none; +} + +.json-editor:focus { + border-color: #4d6f9f; +} + +.simulation-action { + justify-self: start; + padding: 7px 10px; + border: 0; + border-radius: 3px; + color: #e4edff; + background: #315d9f; + font-size: 11px; + cursor: pointer; +} + +.simulation-action:hover, +.simulation-action:focus-visible { + background: #3b6db8; + outline: none; +} + +.simulation-action:disabled { + color: #7e8796; + background: #252a32; + cursor: wait; +} + +.simulation-note, +.editor-error { + margin: 0; + font-size: 11px; + line-height: 1.55; +} + +.simulation-note { + color: #777f8a; +} + +.editor-error, +.trace-error { + color: #eca6a2; +} + +.trace-header .json-value { + margin-top: 18px; +} + +.json-value, +.trace-error { + max-width: 100%; + margin: 8px 0 0; + padding: 10px 11px; + border: 1px solid var(--line-soft); + color: #bac8dc; + background: #0d0f12; + font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.trace-topology { + display: grid; + gap: 10px; +} + +.trace-topology .section-heading { + margin-bottom: 1px; +} + +.trace-path-group, +.trace-values, +.trace-transitions { + display: grid; + gap: 7px; +} + +.trace-path-group { + grid-template-columns: 78px minmax(0, 1fr); + align-items: start; +} + +.trace-paths { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 4px 10px; +} + +.trace-label { + color: #747c87; + font-size: 10px; + line-height: 1.5; + text-transform: uppercase; +} + +.trace-card { + padding-bottom: 12px; +} + +.trace-card > .trace-path-group, +.trace-card > .trace-values, +.trace-card > .trace-transitions { + margin: 0 12px 10px; +} + +.trace-transition { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.trace-transition + .trace-path-group { + margin: 0 0 4px; +} + +.trace-results { + padding-bottom: 24px; +} + +.trace-results > .trace-values { + margin-top: 12px; +} + @media (max-width: 900px) { .devtools-shell { grid-template-columns: 1fr; diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index 09a500f..6635c23 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -1,11 +1,17 @@ -import type { Diagnostic } from "../../DevToolsProtocol.js" +import { + type Diagnostic, + protocolVersion, + type SimulationFrame, + type SimulationReady, + type SimulationRequest +} from "../../DevToolsProtocol.js" import type { Activity as VisualizationActivity, Branch as VisualizationBranch, MachineDocument as VisualizationDocument, Transition as VisualizationTransition } from "../../MachineDocument.js" -import * as MachineSimulator from "../../MachineSimulator.js" +import { requestSimulation } from "./simulation-client.js" import { type EventInspection, type IncomingTransition, @@ -159,22 +165,27 @@ const inspectionSection = (title: string, count: number): HTMLElement => { return header } -const simulationResultMessage = (result: MachineSimulator.StepResult): string => { - if (result._tag === "Applied") return `${result.event} applied · runtime code was skipped` - if (result._tag === "Blocked") return `${result.event} is not enabled in the current topology` - const reasons: Record = { - "multiple-transitions": "multiple active transitions", - "declinable-transition": "acceptance depends on runtime code", - "conditional-branches": "the selected branch depends on runtime code", - "history-target": "history resolution needs runtime state", - "choice-target": "choice resolution needs runtime code", - "missing-target": "the target is not present in the document" - } - return `${result.event} was not applied · ${reasons[result.reason]}` +const prettyJson = (value: unknown): string => JSON.stringify(value, null, 2) + +const jsonBlock = (value: unknown): HTMLElement => createElement("pre", "json-value", prettyJson(value)) + +const eventName = (value: unknown): string => + typeof value === "object" && value !== null && "_tag" in value ? String(value._tag) : "event" + +const parseJson = (value: string): { readonly ok: true; readonly value: unknown } | { + readonly ok: false + readonly message: string +} => { + try { + return { ok: true, value: JSON.parse(value) } + } catch (cause) { + return { ok: false, message: cause instanceof Error ? cause.message : String(cause) } + } } export const renderVisualizer = ( root: HTMLElement, + machineKey: string, visualization: VisualizationDocument, diagnostics: ReadonlyArray = [] ): void => { @@ -186,10 +197,12 @@ export const renderVisualizer = ( const relatedPaths = new Set() let selectedPath: string | undefined let selectedEvent: string | undefined - let simulation: MachineSimulator.Session | undefined + let selectedFrame: SimulationFrame | undefined + let simulation: SimulationReady | undefined + let simulationPending = false - const activePaths = (): ReadonlyArray => simulation?.snapshot.activePaths ?? model.activePaths - const candidateEvents = (): ReadonlyArray => simulation?.snapshot.candidateEvents ?? model.candidateEvents + const activePaths = (): ReadonlyArray => simulation?.current.activePaths ?? model.activePaths + const candidateEvents = (): ReadonlyArray => simulation?.current.candidateEvents ?? model.candidateEvents const shell = createElement("main", "app-shell") const workspace = createElement("section", "workspace") @@ -309,6 +322,296 @@ export const renderVisualizer = ( transitions.append(renderTransition(transition, navigateToState, true)) ) inspector.append(transitions) + + if (simulation !== undefined) { + const composer = createElement("section", "inspector-section simulation-composer") + composer.append(inspectionSection("Event payload", 1)) + const editor = createElement("textarea", "json-editor") + editor.value = prettyJson({ _tag: inspection.event }) + editor.spellcheck = false + editor.setAttribute("aria-label", `${inspection.event} JSON payload`) + const error = createElement("div", "editor-error") + error.hidden = true + const send = createElement("button", "simulation-action", "Plan event") + send.type = "button" + send.disabled = simulationPending + send.addEventListener("click", () => { + const parsed = parseJson(editor.value) + if (!parsed.ok) { + error.textContent = parsed.message + error.hidden = false + return + } + error.hidden = true + const source = visualization.source + if (source === null || simulation === undefined) return + void runSimulation({ + _tag: "SendSimulationEvent", + protocolVersion, + key: machineKey, + revision: visualization.revision, + source, + step: simulation.step, + snapshot: simulation.snapshot, + event: parsed.value as never + }) + }) + const note = createElement( + "p", + "simulation-note", + "Schema decoding and synchronous transition resolvers run in an isolated worker. Planned commands are not committed." + ) + composer.append(editor, error, send, note) + inspector.append(composer) + } + } + + const renderSimulationFailure = (failureDiagnostics: ReadonlyArray): void => { + inspector.replaceChildren() + const header = createElement("header", "inspector-header") + const eyebrow = createElement("div", "inspector-eyebrow") + eyebrow.append(badge("planner error", "error")) + header.append(eyebrow, createElement("h2", undefined, "Simulation could not continue")) + inspector.append(header) + const list = createElement("section", "inspector-section trace-list") + failureDiagnostics.forEach((item) => { + const card = createElement("article", "inspection-card trace-card") + card.append(createElement("strong", undefined, item.code), createElement("pre", "trace-error", item.message)) + list.append(card) + }) + inspector.append(list) + } + + const renderPathGroup = (label: string, paths: ReadonlyArray): HTMLElement => { + const group = createElement("div", "trace-path-group") + group.append(createElement("span", "trace-label", label)) + const values = createElement("div", "trace-paths") + if (paths.length === 0) values.append(createElement("span", "section-empty", "none")) + paths.forEach((path) => values.append(stateLink(path, path, navigateToState))) + group.append(values) + return group + } + + const renderTraceValues = (label: string, values: ReadonlyArray): HTMLElement => { + const section = createElement("div", "trace-values") + section.append(createElement("span", "trace-label", label)) + if (values.length === 0) { + section.append(createElement("span", "section-empty", "none")) + } else { + values.forEach((value) => section.append(jsonBlock(value))) + } + return section + } + + const renderSimulationTrace = (frame: SimulationFrame): void => { + inspector.replaceChildren() + const header = createElement("header", "inspector-header trace-header") + const eyebrow = createElement("div", "inspector-eyebrow") + eyebrow.append(badge("planned", "active")) + if (frame.done) eyebrow.append(badge("done", "state")) + const title = frame.trigger._tag === "Initial" + ? "Initial plan" + : `Event · ${eventName(frame.trigger.event)}` + header.append(eyebrow, createElement("h2", undefined, title)) + header.append(metadata([ + ["Step", String(frame.step)], + ["Microsteps", String(frame.microsteps.length)], + ["Active before", String(frame.before.activePaths.length)], + ["Active after", String(frame.after.activePaths.length)] + ])) + const received = frame.trigger._tag === "Initial" ? frame.trigger.input : frame.trigger.event + if (received !== undefined) header.append(jsonBlock(received)) + inspector.append(header) + + const topology = createElement("section", "inspector-section trace-topology") + topology.append(inspectionSection("Topology change", frame.microsteps.length)) + topology.append( + renderPathGroup("Before", frame.before.activePaths), + renderPathGroup("After", frame.after.activePaths) + ) + inspector.append(topology) + + const steps = createElement("section", "inspector-section trace-list") + steps.append(inspectionSection("Microsteps", frame.microsteps.length)) + if (frame.microsteps.length === 0) { + steps.append(createElement( + "p", + "section-empty", + frame.trigger._tag === "Initial" + ? "No automatic microsteps were needed." + : "No transition accepted this event." + )) + } + frame.microsteps.forEach((step) => { + const card = createElement("article", "inspection-card trace-card") + const cardHeader = createElement("div", "card-header") + const cardTitle = createElement("div", "card-title") + cardTitle.append( + badge(`#${step.index + 1}`, "count"), + createElement("strong", undefined, eventName(step.event)) + ) + cardHeader.append(cardTitle, badge(step.changed ? "changed" : "unchanged", step.changed ? "active" : "neutral")) + card.append(cardHeader) + if (step.transitions.length > 0) { + const selected = createElement("div", "trace-transitions") + selected.append(createElement("span", "trace-label", "Selected transitions")) + step.transitions.forEach((transition) => { + const row = createElement("div", "trace-transition") + row.append(stateLink(transition.source, transition.source, navigateToState)) + row.append(createElement("span", "branch-arrow", "→")) + const target = transition.resolvedTarget ?? transition.target + if (target === null) row.append(createElement("span", "branch-target", "No target")) + else row.append(stateLink(target, target, navigateToState)) + if (transition.branchKey !== null) row.append(badge(transition.branchKey, "condition")) + if (transition.reenter) row.append(badge("reenter")) + selected.append(row) + if (transition.updates.length > 0) selected.append(renderPathGroup("Updates", transition.updates)) + }) + card.append(selected) + } + card.append(renderPathGroup("Exit", step.exitPaths), renderPathGroup("Entry", step.entryPaths)) + if (step.raisedEvents.length > 0) card.append(renderTraceValues("Raised", step.raisedEvents)) + if (step.emittedEvents.length > 0) card.append(renderTraceValues("Emitted", step.emittedEvents)) + if (step.commands.length > 0) card.append(renderTraceValues("Commands", step.commands)) + card.append(renderPathGroup("Active after", step.activePaths)) + steps.append(card) + }) + inspector.append(steps) + + if (frame.commands.length > 0 || frame.emittedEvents.length > 0 || frame.output !== undefined) { + const results = createElement("section", "inspector-section trace-results") + results.append(inspectionSection("Plan result", frame.commands.length + frame.emittedEvents.length)) + if (frame.commands.length > 0) results.append(renderTraceValues("Planned commands", frame.commands)) + if (frame.emittedEvents.length > 0) results.append(renderTraceValues("Emitted events", frame.emittedEvents)) + if (frame.output !== undefined) results.append(renderTraceValues("Output", [frame.output])) + inspector.append(results) + } + } + + const renderStartSimulation = (): void => { + inspector.replaceChildren() + const header = createElement("header", "inspector-header") + const eyebrow = createElement("div", "inspector-eyebrow") + eyebrow.append(badge("planner", "active")) + header.append(eyebrow, createElement("h2", undefined, "Start simulation")) + inspector.append(header) + const composer = createElement("section", "inspector-section simulation-composer") + composer.append(inspectionSection("Machine input", 1)) + const editor = createElement("textarea", "json-editor") + editor.placeholder = "Optional JSON input" + editor.spellcheck = false + editor.setAttribute("aria-label", "Machine input JSON") + const error = createElement("div", "editor-error") + error.hidden = true + const start = createElement("button", "simulation-action", "Plan initial state") + start.type = "button" + start.disabled = simulationPending || visualization.source === null + start.addEventListener("click", () => { + const source = visualization.source + if (source === null) return + const input = editor.value.trim() + let value: unknown + if (input.length > 0) { + const parsed = parseJson(input) + if (!parsed.ok) { + error.textContent = parsed.message + error.hidden = false + return + } + value = parsed.value + } + error.hidden = true + const request: SimulationRequest = { + _tag: "StartSimulation", + protocolVersion, + key: machineKey, + revision: visualization.revision, + source, + ...(input.length > 0 ? { input: value as never } : {}) + } + void runSimulation(request) + }) + composer.append( + editor, + error, + start, + createElement( + "p", + "simulation-note", + "Leave input empty for machines without input. Initialization and synchronous callbacks run; runtime activities and commands do not." + ) + ) + inspector.append(composer) + } + + async function runSimulation(request: SimulationRequest): Promise { + if (simulationPending) return + simulationPending = true + simulationFeedback.textContent = "Planning in an isolated worker…" + simulationFeedback.dataset.status = "pending" + updateSimulationUi() + try { + const result = await requestSimulation(request) + if (result._tag === "SimulationFailed") { + selectedFrame = undefined + if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") + selectedEvent = undefined + simulationFeedback.textContent = result.diagnostics[0]?.message ?? "Simulation failed" + simulationFeedback.dataset.status = "error" + renderSimulationFailure(result.diagnostics) + return + } + simulation = result + if (selectedPath !== undefined) { + nodes.get(selectedPath)?.classList.remove("is-selected") + rows.get(selectedPath)?.setAttribute("aria-selected", "false") + } + if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") + selectedPath = undefined + selectedEvent = undefined + selectedFrame = result.frame + clearRelations() + result.frame.microsteps.forEach((step) => { + step.transitions.forEach((transition) => { + relatedPaths.add(transition.source) + nodes.get(transition.source)?.classList.add("is-related-source") + }) + step.entryPaths.forEach((path) => { + relatedPaths.add(path) + nodes.get(path)?.classList.add("is-related-target") + }) + step.transitions.flatMap((transition) => transition.updates).forEach((path) => { + relatedPaths.add(path) + nodes.get(path)?.classList.add("is-related-update") + }) + }) + clearButton.disabled = false + simulationFeedback.textContent = result.frame.trigger._tag === "Initial" + ? "Initial state planned" + : result.frame.microsteps.length === 0 + ? "No transition accepted the value" + : `${result.frame.microsteps.length} microstep${result.frame.microsteps.length === 1 ? "" : "s"} planned` + simulationFeedback.dataset.status = "applied" + } catch (cause) { + selectedFrame = undefined + if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") + selectedEvent = undefined + const failure: Diagnostic = { + severity: "error", + code: "simulation-request-failed", + message: cause instanceof Error ? cause.message : String(cause), + location: visualization.source === null + ? null + : { file: visualization.source.file, line: null, column: null }, + statePath: null + } + simulationFeedback.textContent = failure.message + simulationFeedback.dataset.status = "error" + renderSimulationFailure([failure]) + } finally { + simulationPending = false + updateSimulationUi() + } } const clearRelations = (): void => { @@ -327,6 +630,7 @@ export const renderVisualizer = ( clearRelations() selectedPath = undefined selectedEvent = undefined + selectedFrame = undefined clearButton.disabled = true renderEmptyInspector() } @@ -380,6 +684,7 @@ export const renderVisualizer = ( if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") selectedPath = path selectedEvent = undefined + selectedFrame = undefined nodes.get(path)?.classList.add("is-selected") rows.get(path)?.setAttribute("aria-selected", "true") markRelatedStates(inspection) @@ -404,6 +709,7 @@ export const renderVisualizer = ( if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") selectedPath = undefined selectedEvent = event + selectedFrame = undefined eventButtons.get(event)?.classList.add("is-selected") clearRelations() markTransitions(inspection.transitions) @@ -511,16 +817,8 @@ export const renderVisualizer = ( candidates.forEach((event) => { const button = createElement("button", `event-button${event === selectedEvent ? " is-selected" : ""}`, event) button.type = "button" - button.addEventListener("click", () => { - if (simulation !== undefined) { - const result = MachineSimulator.send(simulation, event) - if (result._tag === "Applied") simulation = { document: visualization, snapshot: result.session } - simulationFeedback.textContent = simulationResultMessage(result) - simulationFeedback.dataset.status = result._tag.toLowerCase() - updateSimulationUi() - } - selectEvent(event) - }) + button.disabled = simulationPending + button.addEventListener("click", () => selectEvent(event)) eventButtons.set(event, button) events.append(button) }) @@ -536,18 +834,21 @@ export const renderVisualizer = ( const hasRuntimeState = simulation !== undefined || model.hasSnapshot runtimeDot.classList.toggle("has-snapshot", hasRuntimeState) runtimeText.textContent = simulation !== undefined - ? `${active.size} active · step ${simulation.snapshot.step}` + ? `${active.size} active · step ${simulation.step}` : diagnostics.length > 0 ? "Partial" : model.hasSnapshot ? `${active.size} active` : "Structure only" simulationButton.textContent = simulation === undefined ? "Start simulation" : "Reset simulation" + simulationButton.disabled = simulationPending || model.roots.length === 0 || visualization.source === null revealActiveButton.disabled = active.size === 0 events.hidden = !hasRuntimeState - simulationFeedback.hidden = simulation === undefined + simulationFeedback.hidden = simulation === undefined && !simulationPending && simulationFeedback.textContent === "" renderEventButtons() - if (selectedPath !== undefined) { + if (selectedFrame !== undefined) { + renderSimulationTrace(selectedFrame) + } else if (selectedPath !== undefined) { const inspection = model.inspectState(selectedPath) if (inspection !== undefined) renderInspection(inspection) } else if (selectedEvent !== undefined) { @@ -557,13 +858,18 @@ export const renderVisualizer = ( simulationButton.addEventListener("click", () => { if (simulation === undefined) { - simulation = MachineSimulator.start(visualization) - simulationFeedback.textContent = "Best-effort simulation started · user code will not run" - simulationFeedback.dataset.status = "applied" + selectedPath = undefined + selectedEvent = undefined + selectedFrame = undefined + clearRelations() + clearButton.disabled = false + renderStartSimulation() } else { simulation = undefined + selectedFrame = undefined simulationFeedback.textContent = "" delete simulationFeedback.dataset.status + clearSelection() } updateSimulationUi() }) diff --git a/packages/devtools/src/internal/browser/visualizer.ts b/packages/devtools/src/internal/browser/visualizer.ts index 6dc1642..867c150 100644 --- a/packages/devtools/src/internal/browser/visualizer.ts +++ b/packages/devtools/src/internal/browser/visualizer.ts @@ -22,7 +22,7 @@ export const mountVisualizer = (root: HTMLElement, source: MachineResult): void switch (source._tag) { case "Ready": case "Partial": - renderVisualizer(root, source.document, source.diagnostics) + renderVisualizer(root, source.key, source.document, source.diagnostics) break case "Failed": renderError(root, source) diff --git a/packages/devtools/src/internal/devServer.ts b/packages/devtools/src/internal/devServer.ts index 113dbd8..b332c8b 100644 --- a/packages/devtools/src/internal/devServer.ts +++ b/packages/devtools/src/internal/devServer.ts @@ -1,12 +1,15 @@ import { type ChokidarOptions, type FSWatcher, watch } from "chokidar" import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" import * as Stream from "effect/Stream" import type { IncomingMessage, ServerResponse } from "node:http" import { isAbsolute, relative, resolve } from "node:path" import { fileURLToPath } from "node:url" import { createServer, type Plugin, type ViteDevServer } from "vite" import type * as DevServer from "../DevServer.js" +import * as DevToolsProtocol from "../DevToolsProtocol.js" import * as MachineRegistry from "../MachineRegistry.js" +import * as ProjectInspector from "../ProjectInspector.js" type DevServerErrorConstructor = typeof DevServer.DevServerError @@ -41,20 +44,62 @@ const isIgnored = (file: string): boolean => part === "references" ) -const writeJson = (response: ServerResponse, value: unknown): void => { - response.statusCode = 200 +const writeJson = (response: ServerResponse, value: unknown, status = 200): void => { + response.statusCode = status response.setHeader("content-type", "application/json; charset=utf-8") response.setHeader("cache-control", "no-store") response.end(JSON.stringify(value)) } +const readJson = (request: IncomingMessage): Promise => + new Promise((resolveBody, reject) => { + const chunks: Array = [] + let size = 0 + request.on("data", (chunk: Buffer) => { + size += chunk.byteLength + if (size > 1_000_000) { + reject(new Error("Simulation requests are limited to 1 MB")) + request.destroy() + return + } + chunks.push(chunk) + }) + request.on("end", () => { + try { + resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8"))) + } catch (cause) { + reject(cause) + } + }) + request.on("error", reject) + }) + +const staleSimulation = ( + request: DevToolsProtocol.SimulationRequest, + message: string +): DevToolsProtocol.SimulationFailed => ({ + _tag: "SimulationFailed", + protocolVersion: DevToolsProtocol.protocolVersion, + key: request.key, + revision: request.revision, + diagnostics: [{ + severity: "error", + code: "simulation-stale", + message, + location: { file: request.source.file, line: null, column: null }, + statePath: null + }] +}) + const apiPlugin = ( - registry: MachineRegistry.MachineRegistry["Service"] + root: string, + registry: MachineRegistry.MachineRegistry["Service"], + inspector: ProjectInspector.ProjectInspector["Service"] ): Plugin => ({ name: "effect-machine-devtools-api", configureServer(server) { server.middlewares.use((request: IncomingMessage, response: ServerResponse, next: () => void) => { - if (request.url === "/api/machines") { + if (request.method === "GET" && request.url === "/api/machines") { void Effect.runPromise(registry.get).then( (snapshot) => writeJson(response, snapshot), (cause) => { @@ -64,6 +109,37 @@ const apiPlugin = ( ) return } + if (request.method === "POST" && request.url === "/api/simulations") { + void Effect.runPromise( + Effect.gen(function*() { + const body = yield* Effect.tryPromise({ + try: () => readJson(request), + catch: (cause) => cause + }) + const simulationRequest = yield* Schema.decodeUnknownEffect(DevToolsProtocol.SimulationRequest)(body) + const snapshot = yield* registry.get + const current = snapshot.results.find((result) => result.key === simulationRequest.key) + if (current === undefined || current._tag === "Failed") { + return staleSimulation(simulationRequest, "The machine is no longer available; restart the simulation") + } + if ( + current.document.revision !== simulationRequest.revision || + current.document.source?.file !== simulationRequest.source.file || + current.document.source.exportName !== simulationRequest.source.exportName + ) { + return staleSimulation( + simulationRequest, + "The machine changed; restart the simulation from its latest revision" + ) + } + return yield* inspector.simulate(simulationRequest, { root }) + }) + ).then( + (result) => writeJson(response, result), + (cause) => writeJson(response, { message: String(cause) }, 400) + ) + return + } if (request.url !== "/api/events") { next() return @@ -90,7 +166,8 @@ const apiPlugin = ( const acquire = ( ErrorType: DevServerErrorConstructor, options: DevServer.Options, - registry: MachineRegistry.MachineRegistry["Service"] + registry: MachineRegistry.MachineRegistry["Service"], + inspector: ProjectInspector.ProjectInspector["Service"] ): Effect.Effect => Effect.tryPromise({ try: async () => { @@ -98,7 +175,7 @@ const acquire = ( root: packageRoot, appType: "spa", logLevel: "error", - plugins: [apiPlugin(registry)], + plugins: [apiPlugin(options.root, registry, inspector)], server: { host: options.host, port: options.port, @@ -154,11 +231,16 @@ export const watcherOptions = (options: DevServer.Options): ChokidarOptions => export const run = ( ErrorType: DevServerErrorConstructor, options: DevServer.Options -): Effect.Effect => +): Effect.Effect< + never, + DevServer.DevServerError, + MachineRegistry.MachineRegistry | ProjectInspector.ProjectInspector +> => Effect.gen(function*() { const registry = yield* MachineRegistry.MachineRegistry + const inspector = yield* ProjectInspector.ProjectInspector const server = yield* Effect.acquireRelease( - acquire(ErrorType, options, registry), + acquire(ErrorType, options, registry, inspector), (server) => Effect.promise(() => server.close()) ) yield* Effect.acquireRelease( diff --git a/packages/devtools/src/internal/evaluationWorker.ts b/packages/devtools/src/internal/evaluationWorker.ts index 74a27d3..76426f6 100644 --- a/packages/devtools/src/internal/evaluationWorker.ts +++ b/packages/devtools/src/internal/evaluationWorker.ts @@ -1,8 +1,9 @@ import * as NodeWorkerRunner from "@effect/platform-node/NodeWorkerRunner" -import type { Machine } from "@typeonce/effect-machine" +import { Machine } from "@typeonce/effect-machine" import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" import * as WorkerRunner from "effect/unstable/workers/WorkerRunner" -import { resolve } from "node:path" +import { isAbsolute, relative, resolve } from "node:path" import { pathToFileURL } from "node:url" import type { ViteDevServer } from "vite" import * as DevToolsProtocol from "../DevToolsProtocol.js" @@ -10,15 +11,26 @@ import * as MachineDocument from "../MachineDocument.js" import type * as ProjectInspector from "../ProjectInspector.js" interface EvaluationRequest { + readonly _tag: "InspectMachines" readonly root: string readonly revision: number readonly candidates: ReadonlyArray } interface EvaluationResponse { + readonly _tag: "InspectedMachines" readonly results: ReadonlyArray } +interface SimulationWorkerRequest { + readonly _tag: "Simulate" + readonly root: string + readonly request: DevToolsProtocol.SimulationRequest +} + +type WorkerRequest = EvaluationRequest | SimulationWorkerRequest +type WorkerResponse = EvaluationResponse | DevToolsProtocol.SimulationResult + const isMachine = (value: unknown): value is Machine.Machine.Any => typeof value === "object" && value !== null && @@ -41,7 +53,37 @@ const diagnostic = ( statePath: null }) -const messageOf = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause) +const messageOf = (cause: unknown): string => { + if (cause instanceof Error && cause.message.length > 0) return cause.message + if (typeof cause === "object" && cause !== null && "cause" in cause && Schema.isSchemaError(cause.cause)) { + const boundary = "boundary" in cause ? String(cause.boundary) : "value" + return `Invalid machine ${boundary}: ${cause.cause.message}` + } + try { + const encoded = JSON.stringify(cause, null, 2) + if (encoded !== undefined && encoded !== "{}") return encoded + } catch { + // Fall back to the runtime string representation below. + } + const rendered = String(cause) + return rendered.length > 0 ? rendered : "The planner failed without a diagnostic message" +} + +const jsonValue = (value: unknown): Schema.Json => { + const seen = new WeakSet() + const encoded = JSON.stringify(value, (_key, current: unknown) => { + if (typeof current === "bigint") return `${current}n` + if (typeof current === "function") return `[Function ${current.name || "anonymous"}]` + if (typeof current === "symbol") return String(current) + if (typeof current === "undefined") return null + if (typeof current === "object" && current !== null) { + if (seen.has(current)) return "[Circular]" + seen.add(current) + } + return current + }) + return encoded === undefined ? null : JSON.parse(encoded) as Schema.Json +} const failed = ( candidate: ProjectInspector.Candidate, @@ -121,16 +163,256 @@ const handle = (server: ViteDevServer, request: EvaluationRequest): Effect.Effec Effect.forEach(request.candidates, (candidate) => evaluateCandidate(server, request, candidate), { concurrency: 1 }).pipe( - Effect.map((results) => ({ results: results.flat() })) + Effect.map((results) => ({ _tag: "InspectedMachines" as const, results: results.flat() })) + ) + +interface DynamicMicrostep { + readonly next: unknown + readonly event: unknown + readonly transitions: ReadonlyArray + readonly commands: ReadonlyArray + readonly raisedEvents: ReadonlyArray + readonly emittedEvents: ReadonlyArray + readonly exitPaths: ReadonlyArray + readonly entryPaths: ReadonlyArray + readonly changed: boolean +} + +interface DynamicPlan { + readonly startingState?: unknown + readonly state?: unknown + readonly next?: unknown + readonly commands: ReadonlyArray + readonly emittedEvents: ReadonlyArray + readonly microsteps: ReadonlyArray + readonly done: boolean + readonly output: unknown +} + +const planInitial = Machine.planInitial as unknown as ( + machine: Machine.Machine.Any, + ...input: ReadonlyArray +) => Effect.Effect + +const plan = Machine.plan as ( + machine: Machine.Machine.Any, + snapshot: unknown, + event: unknown +) => Effect.Effect + +const encodeSnapshot = Machine.encodeSnapshot as ( + machine: Machine.Machine.Any, + snapshot: unknown +) => Effect.Effect + +const decodeSnapshot = Machine.decodeSnapshot as ( + machine: Machine.Machine.Any, + snapshot: unknown +) => Effect.Effect + +const configuration = Machine.configuration as ( + machine: Machine.Machine.Any, + snapshot: unknown +) => ReadonlyArray<{ readonly path: string }> + +const enabled = Machine.enabled as ( + machine: Machine.Machine.Any, + snapshot: unknown +) => ReadonlyArray + +const simulationEvent = (machine: Machine.Machine.Any, value: Schema.Json): unknown => { + if (typeof value !== "object" || value === null || Array.isArray(value) || !("_tag" in value)) return value + const tag = value._tag + if (typeof tag !== "string" && typeof tag !== "number") return value + const constructor = Reflect.get(machine.events, tag) + if (typeof constructor !== "function") return value + const { _tag: _, ...payload } = value + return constructor(payload) +} + +const simulationSnapshot = ( + machine: Machine.Machine.Any, + snapshot: unknown +): DevToolsProtocol.SimulationSnapshot => ({ + activePaths: configuration(machine, snapshot).map((node) => node.path), + candidateEvents: enabled(machine, snapshot).map(String) +}) + +const trigger = (value: Machine.Machine.TransitionTrigger): MachineDocument.Trigger => { + switch (value.type) { + case "event": + return { type: "event", event: String(value.event) } + case "always": + return { type: "always" } + case "done": + return { type: "done" } + case "choice": + return { type: "choice" } + case "invoke": + return { type: "invoke", id: value.id, outcome: value.outcome } + } +} + +const commandTarget = (target: unknown): string => { + if (typeof target === "string") return target + if (typeof target === "object" && target !== null && "id" in target) return String(target.id) + return String(target) +} + +const command = (value: Machine.Command): DevToolsProtocol.PlannedCommand => + value._tag === "SendTo" + ? { _tag: "SendTo", target: commandTarget(value.target), event: jsonValue(value.event) } + : { _tag: "Stop", target: commandTarget(value.child) } + +const microstep = ( + machine: Machine.Machine.Any, + value: DynamicMicrostep, + index: number +): DevToolsProtocol.SimulationMicrostep => ({ + index, + event: jsonValue(value.event), + transitions: value.transitions.map((transition) => ({ + source: transition.source, + trigger: trigger(transition.trigger), + reenter: transition.reenter, + branchIndex: transition.branchIndex, + branchKey: transition.branchKey ?? null, + target: transition.target ?? null, + resolvedTarget: transition.resolvedTarget ?? null, + updates: [...transition.updates] + })), + commands: value.commands.map(command), + raisedEvents: value.raisedEvents.map(jsonValue), + emittedEvents: value.emittedEvents.map(jsonValue), + exitPaths: [...value.exitPaths], + entryPaths: [...value.entryPaths], + activePaths: configuration(machine, value.next).map((node) => node.path), + changed: value.changed +}) + +const simulationDiagnostic = ( + request: DevToolsProtocol.SimulationRequest, + code: string, + cause: unknown +): DevToolsProtocol.SimulationFailed => ({ + _tag: "SimulationFailed", + protocolVersion: DevToolsProtocol.protocolVersion, + key: request.key, + revision: request.revision, + diagnostics: [diagnostic(request.source.file, code, messageOf(cause))] +}) + +const isProjectFile = (root: string, file: string): boolean => { + const absoluteRoot = resolve(root) + const absoluteFile = resolve(absoluteRoot, file) + const projectPath = relative(absoluteRoot, absoluteFile) + return projectPath !== "" && !projectPath.startsWith("..") && !isAbsolute(projectPath) +} + +const loadSimulationMachine = ( + server: ViteDevServer, + workerRequest: SimulationWorkerRequest +): Effect.Effect => { + const request = workerRequest.request + if (!isProjectFile(workerRequest.root, request.source.file)) { + return Effect.fail(new Error("The requested machine source is outside the project root")) + } + if (request.source.exportName === null) { + return Effect.fail(new Error("The requested machine does not have an exported module binding")) + } + return Effect.tryPromise({ + try: () => server.ssrLoadModule(pathToFileURL(resolve(workerRequest.root, request.source.file)).href), + catch: (cause) => cause + }).pipe( + Effect.flatMap((module) => { + const candidate = module[request.source.exportName!] + return isMachine(candidate) + ? Effect.succeed(candidate) + : Effect.fail(new Error(`Export ${request.source.exportName} is not an Effect Machine`)) + }) + ) +} + +const makeSimulationReady = ( + request: DevToolsProtocol.SimulationRequest, + machine: Machine.Machine.Any, + before: unknown, + after: unknown, + planResult: DynamicPlan +): Effect.Effect => + Effect.map(encodeSnapshot(machine, after), (snapshot) => { + const step = request._tag === "StartSimulation" ? 0 : request.step + 1 + const frame: DevToolsProtocol.SimulationFrame = { + step, + trigger: request._tag === "StartSimulation" + ? { + _tag: "Initial", + ...(Object.hasOwn(request, "input") ? { input: request.input } : {}) + } + : { _tag: "Event", event: request.event }, + before: simulationSnapshot(machine, before), + after: simulationSnapshot(machine, after), + microsteps: planResult.microsteps.map((value, index) => microstep(machine, value, index)), + commands: planResult.commands.map(command), + emittedEvents: planResult.emittedEvents.map(jsonValue), + done: planResult.done, + ...(planResult.done && planResult.output !== undefined ? { output: jsonValue(planResult.output) } : {}) + } + return { + _tag: "SimulationReady", + protocolVersion: DevToolsProtocol.protocolVersion, + key: request.key, + revision: request.revision, + step, + snapshot, + current: frame.after, + frame + } + }) + +const simulate = ( + server: ViteDevServer, + workerRequest: SimulationWorkerRequest +): Effect.Effect => { + const request = workerRequest.request + return loadSimulationMachine(server, workerRequest).pipe( + Effect.flatMap((machine) => { + if (request._tag === "StartSimulation") { + const planned = Object.hasOwn(request, "input") + ? planInitial(machine, request.input) + : planInitial(machine) + return Effect.flatMap(planned, (result) => { + const before = result.startingState ?? result.state + const after = result.state + if (before === undefined || after === undefined) { + return Effect.fail(new Error("The initial planner did not return a state snapshot")) + } + return makeSimulationReady(request, machine, before, after, result) + }) + } + return Effect.flatMap( + decodeSnapshot(machine, request.snapshot), + (before) => + Effect.flatMap(plan(machine, before, simulationEvent(machine, request.event)), (result) => { + if (result.next === undefined) return Effect.fail(new Error("The planner did not return a next snapshot")) + return makeSimulationReady(request, machine, before, result.next, result) + }) + ) + }), + Effect.catch((cause) => Effect.succeed(simulationDiagnostic(request, "simulation-planning-failed", cause))) ) +} export const run = (server: ViteDevServer): Promise => { return Effect.gen(function*() { const platform = yield* WorkerRunner.WorkerRunnerPlatform - const runner = yield* platform.start() - yield* runner.run((_portId, request) => - Effect.flatMap(handle(server, request), (response) => runner.send(0, response)) - ) + const runner = yield* platform.start() + yield* runner.run((_portId, request) => { + const response: Effect.Effect = request._tag === "InspectMachines" + ? handle(server, request) + : simulate(server, request) + return Effect.flatMap(response, (value) => runner.send(0, value)) + }) }).pipe( Effect.provide(NodeWorkerRunner.layer), Effect.runPromise diff --git a/packages/devtools/src/internal/projectInspector.ts b/packages/devtools/src/internal/projectInspector.ts index c2bbfd3..53bcee0 100644 --- a/packages/devtools/src/internal/projectInspector.ts +++ b/packages/devtools/src/internal/projectInspector.ts @@ -28,15 +28,26 @@ const defaultExclude = [ ] as const interface EvaluationRequest { + readonly _tag: "InspectMachines" readonly root: string readonly revision: number readonly candidates: ReadonlyArray } interface EvaluationResponse { + readonly _tag: "InspectedMachines" readonly results: ReadonlyArray } +interface SimulationWorkerRequest { + readonly _tag: "Simulate" + readonly root: string + readonly request: DevToolsProtocol.SimulationRequest +} + +type WorkerRequest = EvaluationRequest | SimulationWorkerRequest +type WorkerResponse = EvaluationResponse | DevToolsProtocol.SimulationResult + const scriptKind = (file: string): ts.ScriptKind => { if (file.endsWith(".tsx")) return ts.ScriptKind.TSX if (file.endsWith(".jsx")) return ts.ScriptKind.JSX @@ -169,6 +180,22 @@ const WorkerLayer = NodeWorker.layer(() => }) ) +const runWorker = ( + request: WorkerRequest +): Effect.Effect => + Effect.scoped( + Effect.gen(function*() { + const platform = yield* Worker.WorkerPlatform + const worker = yield* platform.spawn(0) + const response = yield* Deferred.make() + const runner = yield* Effect.forkScoped( + worker.run((message) => Deferred.succeed(response, message)) + ) + yield* worker.send(request) + return yield* Effect.raceFirst(Deferred.await(response), Fiber.join(runner)) + }) + ).pipe(Effect.provide(WorkerLayer)) + const evaluate = ( api: PublicApi, candidates: ReadonlyArray, @@ -177,28 +204,22 @@ const evaluate = ( if (candidates.length === 0) return Effect.succeed([]) const request: EvaluationRequest = { + _tag: "InspectMachines", root: options.root, revision: options.revision ?? 0, candidates } - return Effect.scoped( - Effect.gen(function*() { - const platform = yield* Worker.WorkerPlatform - const worker = yield* platform.spawn(0) - const response = yield* Deferred.make() - const runner = yield* Effect.forkScoped( - worker.run((message) => Deferred.succeed(response, message)) - ) - yield* worker.send(request) - const message = yield* Effect.raceFirst(Deferred.await(response), Fiber.join(runner)) - return yield* Effect.forEach( + return runWorker(request).pipe( + Effect.flatMap((message) => { + if (message._tag !== "InspectedMachines") { + return Effect.fail(new Error(`Unexpected worker response: ${message._tag}`)) + } + return Effect.forEach( message.results, (result) => Schema.decodeUnknownEffect(DevToolsProtocol.MachineResult)(result) ) - }) - ).pipe( - Effect.provide(WorkerLayer), + }), Effect.mapError((cause) => new api.EvaluationError({ message: "The isolated machine evaluator failed", @@ -208,6 +229,27 @@ const evaluate = ( ) } +const simulate = ( + api: PublicApi, + request: DevToolsProtocol.SimulationRequest, + options: Pick +): Effect.Effect => + runWorker({ _tag: "Simulate", root: options.root, request }).pipe( + Effect.timeout("10 seconds"), + Effect.flatMap((response) => { + if (response._tag === "InspectedMachines") { + return Effect.fail(new Error("The isolated planner returned an inspection response")) + } + return Schema.decodeUnknownEffect(DevToolsProtocol.SimulationResult)(response) + }), + Effect.mapError((cause) => + new api.EvaluationError({ + message: "The isolated machine planner failed", + cause + }) + ) + ) + const make = (api: PublicApi) => Effect.gen(function*() { const discover = yield* makeDiscovery(api) @@ -216,7 +258,8 @@ const make = (api: PublicApi) => return api.ProjectInspector.of({ discover, evaluate: (candidates, options) => evaluate(api, candidates, options), - inspect + inspect, + simulate: (request, options) => simulate(api, request, options) }) }) diff --git a/packages/devtools/test/ProjectInspector.test.ts b/packages/devtools/test/ProjectInspector.test.ts index c6d8fba..fea5f10 100644 --- a/packages/devtools/test/ProjectInspector.test.ts +++ b/packages/devtools/test/ProjectInspector.test.ts @@ -1,8 +1,10 @@ import { assert, describe, it } from "@effect/vitest" import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import * as DevToolsProtocol from "../src/DevToolsProtocol.js" import { parseCandidate } from "../src/internal/projectInspector.js" import * as ProjectInspector from "../src/ProjectInspector.js" @@ -62,6 +64,137 @@ describe("ProjectInspector", () => { } }).pipe(Effect.provide(ProjectInspector.layer))) + it.effect("plans dynamic resolvers from a portable isolated session", () => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const source = { + file: "packages/devtools/src/internal/browser/example-machine.ts", + exportName: "machine" + } + const started = yield* inspector.simulate({ + _tag: "StartSimulation", + protocolVersion: DevToolsProtocol.protocolVersion, + key: `${source.file}#${source.exportName}`, + revision: 12, + source + }, { root: process.cwd() }) + + assert.strictEqual(started._tag, "SimulationReady") + if (started._tag !== "SimulationReady") return + assert.strictEqual(started.step, 0) + assert.deepStrictEqual(started.current.candidateEvents, ["Start", "Refresh", "Disconnect"]) + + const stepped = yield* inspector.simulate({ + _tag: "SendSimulationEvent", + protocolVersion: DevToolsProtocol.protocolVersion, + key: started.key, + revision: started.revision, + source, + step: started.step, + snapshot: started.snapshot, + event: { _tag: "Start" } + }, { root: process.cwd() }) + + assert.strictEqual( + stepped._tag, + "SimulationReady", + stepped._tag === "SimulationFailed" ? stepped.diagnostics[0]?.message : undefined + ) + if (stepped._tag !== "SimulationReady") return + assert.strictEqual(stepped.step, 1) + assert.deepStrictEqual(stepped.current.activePaths, [ + "application", + "application.workflow", + "application.workflow.running", + "application.workflow.running.editing", + "application.connection", + "application.connection.online" + ]) + assert.strictEqual(stepped.frame.microsteps[0]?.transitions[0]?.source, "application.workflow.idle") + assert.deepStrictEqual(stepped.frame.microsteps[0]?.transitions[0]?.updates, ["application.workflow"]) + assert.deepStrictEqual(stepped.frame.commands, []) + assert.deepStrictEqual(Schema.decodeUnknownSync(DevToolsProtocol.SimulationResult)(stepped), stepped) + }).pipe(Effect.provide(ProjectInspector.layer))) + + it.effect("returns schema failures as simulation diagnostics", () => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const source = { + file: "packages/devtools/src/internal/browser/example-machine.ts", + exportName: "machine" + } + const started = yield* inspector.simulate({ + _tag: "StartSimulation", + protocolVersion: DevToolsProtocol.protocolVersion, + key: "example", + revision: 0, + source + }, { root: process.cwd() }) + if (started._tag !== "SimulationReady") return + const failed = yield* inspector.simulate({ + _tag: "SendSimulationEvent", + protocolVersion: DevToolsProtocol.protocolVersion, + key: started.key, + revision: started.revision, + source, + step: started.step, + snapshot: started.snapshot, + event: { _tag: "UnknownEvent" } + }, { root: process.cwd() }) + + assert.strictEqual(failed._tag, "SimulationFailed") + if (failed._tag === "SimulationFailed") { + assert.strictEqual(failed.diagnostics[0]?.code, "simulation-planning-failed") + } + }).pipe(Effect.provide(ProjectInspector.layer))) + + it.effect("reports payload branches, raised events, emissions, commands, and output", () => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const source = { + file: "packages/devtools/src/internal/browser/planner-example.ts", + exportName: "plannerMachine" + } + const started = yield* inspector.simulate({ + _tag: "StartSimulation", + protocolVersion: DevToolsProtocol.protocolVersion, + key: "planner-example", + revision: 3, + source, + input: { owner: "Agent" } + }, { root: process.cwd() }) + assert.strictEqual(started._tag, "SimulationReady") + if (started._tag !== "SimulationReady") return + + const planned = yield* inspector.simulate({ + _tag: "SendSimulationEvent", + protocolVersion: DevToolsProtocol.protocolVersion, + key: started.key, + revision: started.revision, + source, + step: started.step, + snapshot: started.snapshot, + event: { _tag: "Begin", job: "release", priority: "urgent" } + }, { root: process.cwd() }) + + assert.strictEqual(planned._tag, "SimulationReady") + if (planned._tag !== "SimulationReady") return + assert.deepStrictEqual(planned.current.activePaths, ["Finished"]) + assert.deepStrictEqual(planned.frame.microsteps.map((step) => (step.event as { _tag: string })._tag), [ + "Begin", + "AutoFinish" + ]) + assert.strictEqual(planned.frame.microsteps[0]?.transitions[0]?.branchKey, "urgent") + assert.deepStrictEqual(planned.frame.emittedEvents, [{ _tag: "Planned", job: "release" }]) + assert.deepStrictEqual(planned.frame.commands, [{ + _tag: "SendTo", + target: "planner-example", + event: { _tag: "Cancel" } + }]) + assert.strictEqual(planned.frame.done, true) + assert.strictEqual(planned.frame.output, "release") + }).pipe(Effect.provide(ProjectInspector.layer))) + it.effect("retains a known candidate only while its source is syntactically incomplete", () => Effect.acquireUseRelease( Effect.promise(() => mkdtemp(join(tmpdir(), "effect-machine-inspector-"))), From 1e38d6bcc46e280a65d0d98dcf18e3db5c91010e Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 24 Aug 2026 11:35:39 +0200 Subject: [PATCH 2/3] fix(devtools): render schema-driven simulation inputs --- .changeset/calm-planners-trace.md | 5 +- packages/devtools/README.md | 2 +- packages/devtools/src/DevToolsProtocol.ts | 2 +- packages/devtools/src/MachineDocument.ts | 55 +- .../src/internal/browser/input-form.ts | 492 ++++++++++++++++++ .../devtools/src/internal/browser/styles.css | 142 ++++- .../src/internal/browser/visualizer-app.ts | 202 ++++--- .../devtools/src/internal/evaluationWorker.ts | 12 +- .../devtools/src/internal/machineDocument.ts | 77 ++- .../devtools/test/MachineDocument.test.ts | 23 +- .../devtools/test/MachineRegistry.test.ts | 8 +- .../InteractiveTextVisualization.test.ts | 33 ++ packages/effect-machine/src/Machine.ts | 16 + .../src/internal/machine/machine.ts | 4 + .../test/machine/Visualization.test.ts | 6 + .../typetest/machine/Inspection.tst.ts | 1 + 16 files changed, 969 insertions(+), 111 deletions(-) create mode 100644 packages/devtools/src/internal/browser/input-form.ts diff --git a/.changeset/calm-planners-trace.md b/.changeset/calm-planners-trace.md index 89acedd..6e82b57 100644 --- a/.changeset/calm-planners-trace.md +++ b/.changeset/calm-planners-trace.md @@ -1,7 +1,8 @@ --- +"@typeonce/effect-machine": minor "@typeonce/effect-machine-devtools": minor --- -Add planner-backed simulation sessions to the web visualizer. Machine input and event payloads can be entered as JSON, while each isolated step uses the real Effect Machine planner and shows selected branches, topology changes, raised and emitted events, planned commands, completion, and output as a structured trace. +Add planner-backed simulation sessions to the web visualizer. Machine and event inputs are rendered as fields from their Effect schemas, while each isolated step uses the real Effect Machine planner and shows selected branches, concrete topology changes, raised and emitted events, planned commands, completion, and output as a structured trace. -Planning evaluates synchronous statechart callbacks but does not commit commands or start runtime activities. Schema and planning failures remain visible beside the machine topology. +Expose `Machine.inputEventSchemas` so inspection tools can describe or construct valid public events without reaching into the opaque event protocol. Planning evaluates synchronous statechart callbacks but does not commit commands or start runtime activities. Schema and planning failures remain visible beside the machine topology. diff --git a/packages/devtools/README.md b/packages/devtools/README.md index ef3b2ef..9f185bb 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -66,7 +66,7 @@ Run the devtools only against code you trust. The server has no authentication a The visualizer shows topology, active initial paths, state annotations, events, transitions, branches, state updates, activities, source metadata, and diagnostics. The tree supports pointer and keyboard navigation, subtree expansion, related-state highlighting, and structured detail inspection. -Simulation uses the same `Machine.planInitial` and `Machine.plan` semantics as the core package. Start a session with optional JSON machine input, select an enabled event, edit its JSON payload, and inspect the resulting macrostep as structured microsteps. The trace includes selected branches, before/after topology, exits, entries, state updates, raised events, emitted events, planned commands, completion, and output. +Simulation uses the same `Machine.planInitial` and `Machine.plan` semantics as the core package. Machine input and event payload controls are derived from their Effect schemas. Start a session, send an enabled event, and inspect the resulting macrostep as structured microsteps. Events without payload fields run when clicked; events with input open a form first. The trace includes selected branches, before/after topology, exits, entries, state updates, raised events, emitted events, planned commands, completion, and output. Each plan loads the exported machine in a fresh worker, decodes the portable session snapshot, and evaluates synchronous statechart callbacks. This supports conditional branches, parallel transitions, history, choices, state updates, reentry, and automatic stabilization. It also means synchronous code inside initial, transition, entry, exit, choice, history, and output callbacks runs during planning. diff --git a/packages/devtools/src/DevToolsProtocol.ts b/packages/devtools/src/DevToolsProtocol.ts index 355d6b6..9ed71e4 100644 --- a/packages/devtools/src/DevToolsProtocol.ts +++ b/packages/devtools/src/DevToolsProtocol.ts @@ -12,7 +12,7 @@ import * as MachineDocument from "./MachineDocument.js" * @category models * @since 0.23.0 */ -export const protocolVersion = 1 as const +export const protocolVersion = 2 as const /** * @category schemas diff --git a/packages/devtools/src/MachineDocument.ts b/packages/devtools/src/MachineDocument.ts index 036b705..8e26da3 100644 --- a/packages/devtools/src/MachineDocument.ts +++ b/packages/devtools/src/MachineDocument.ts @@ -13,7 +13,7 @@ import * as internal from "./internal/machineDocument.js" * @category models * @since 0.23.0 */ -export const schemaVersion = 1 as const +export const schemaVersion = 2 as const /** * Source module and export that produced a machine. @@ -216,6 +216,58 @@ export const Snapshot = Schema.Struct({ */ export type Snapshot = Schema.Schema.Type +/** + * Canonical JSON Schema used to render one machine input form. + * + * @category schemas + * @since 0.24.0 + */ +export const InputSchema = Schema.Struct({ + dialect: Schema.Literal("draft-2020-12"), + schema: Schema.Json, + definitions: Schema.Record(Schema.String, Schema.Json) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type InputSchema = Schema.Schema.Type + +/** + * Public event input paired with the schema for its payload. + * + * @category schemas + * @since 0.24.0 + */ +export const EventInput = Schema.Struct({ + event: Schema.String, + schema: InputSchema +}) + +/** + * @category models + * @since 0.24.0 + */ +export type EventInput = Schema.Schema.Type + +/** + * Machine and public event inputs available to devtools forms. + * + * @category schemas + * @since 0.24.0 + */ +export const Inputs = Schema.Struct({ + machine: Schema.NullOr(InputSchema), + events: Schema.Array(EventInput) +}) + +/** + * @category models + * @since 0.24.0 + */ +export type Inputs = Schema.Schema.Type + /** * Complete, versioned, JSON-safe machine inspection document. * @@ -232,6 +284,7 @@ export const MachineDocument = Schema.Struct({ states: Schema.Array(State), transitions: Schema.Array(Transition), activities: Schema.Array(Activity), + inputs: Inputs, snapshot: Schema.NullOr(Snapshot) }) diff --git a/packages/devtools/src/internal/browser/input-form.ts b/packages/devtools/src/internal/browser/input-form.ts new file mode 100644 index 0000000..0d40af5 --- /dev/null +++ b/packages/devtools/src/internal/browser/input-form.ts @@ -0,0 +1,492 @@ +import type { InputSchema } from "../../MachineDocument.js" + +type JsonPrimitive = string | number | boolean | null + +export type InputField = + | { + readonly _tag: "String" + readonly title: string | undefined + readonly description: string | undefined + readonly defaultValue: string | undefined + readonly format: string | undefined + readonly minLength: number | undefined + readonly maxLength: number | undefined + readonly pattern: string | undefined + } + | { + readonly _tag: "Number" + readonly title: string | undefined + readonly description: string | undefined + readonly defaultValue: number | undefined + readonly integer: boolean + readonly minimum: number | undefined + readonly maximum: number | undefined + } + | { + readonly _tag: "Boolean" + readonly title: string | undefined + readonly description: string | undefined + readonly defaultValue: boolean | undefined + } + | { + readonly _tag: "Enum" + readonly title: string | undefined + readonly description: string | undefined + readonly values: ReadonlyArray + readonly defaultValue: JsonPrimitive | undefined + } + | { + readonly _tag: "Literal" + readonly title: string | undefined + readonly description: string | undefined + readonly value: JsonPrimitive + } + | { + readonly _tag: "Object" + readonly title: string | undefined + readonly description: string | undefined + readonly fields: ReadonlyArray<{ + readonly key: string + readonly required: boolean + readonly field: InputField + }> + } + | { + readonly _tag: "Array" + readonly title: string | undefined + readonly description: string | undefined + readonly item: InputField + readonly minItems: number + readonly maxItems: number | undefined + } + | { + readonly _tag: "Union" + readonly title: string | undefined + readonly description: string | undefined + readonly alternatives: ReadonlyArray + } + | { + readonly _tag: "Unsupported" + readonly title: string | undefined + readonly description: string | undefined + readonly reason: string + } + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const stringValue = (value: unknown): string | undefined => typeof value === "string" ? value : undefined + +const numberValue = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined + +const primitiveValue = (value: unknown): JsonPrimitive | undefined => + value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? value + : undefined + +const resolveReference = ( + value: unknown, + definitions: Readonly>, + seen: ReadonlySet = new Set() +): unknown => { + if (!isRecord(value) || typeof value.$ref !== "string" || !value.$ref.startsWith("#/$defs/")) return value + const name = decodeURIComponent(value.$ref.slice("#/$defs/".length)) + if (seen.has(name)) return undefined + const next = definitions[name] + return resolveReference(next, definitions, new Set([...seen, name])) +} + +const annotations = (schema: Record) => ({ + title: stringValue(schema.title), + description: stringValue(schema.description) +}) + +const project = ( + value: unknown, + definitions: Readonly> +): InputField => { + const resolved = resolveReference(value, definitions) + if (!isRecord(resolved)) { + return { + _tag: "Unsupported", + title: undefined, + description: undefined, + reason: "This input schema cannot be represented as fields." + } + } + const common = annotations(resolved) + const alternatives = Array.isArray(resolved.oneOf) + ? resolved.oneOf + : Array.isArray(resolved.anyOf) + ? resolved.anyOf + : undefined + if (alternatives !== undefined) { + return { + _tag: "Union", + ...common, + alternatives: alternatives.map((alternative) => project(alternative, definitions)) + } + } + const constant = primitiveValue(resolved.const) + if (constant !== undefined || resolved.const === null) { + return { _tag: "Literal", ...common, value: constant ?? null } + } + if (Array.isArray(resolved.enum)) { + const values = resolved.enum.map(primitiveValue).filter((item): item is JsonPrimitive => item !== undefined) + if (values.length > 0) { + return { + _tag: "Enum", + ...common, + values, + defaultValue: primitiveValue(resolved.default) + } + } + } + switch (resolved.type) { + case "string": + return { + _tag: "String", + ...common, + defaultValue: stringValue(resolved.default), + format: stringValue(resolved.format), + minLength: numberValue(resolved.minLength), + maxLength: numberValue(resolved.maxLength), + pattern: stringValue(resolved.pattern) + } + case "integer": + case "number": + return { + _tag: "Number", + ...common, + defaultValue: numberValue(resolved.default), + integer: resolved.type === "integer", + minimum: numberValue(resolved.minimum), + maximum: numberValue(resolved.maximum) + } + case "boolean": + return { + _tag: "Boolean", + ...common, + defaultValue: typeof resolved.default === "boolean" ? resolved.default : undefined + } + case "null": + return { _tag: "Literal", ...common, value: null } + case "object": { + if (!isRecord(resolved.properties)) { + return { _tag: "Object", ...common, fields: [] } + } + const required = new Set( + Array.isArray(resolved.required) + ? resolved.required.filter((item): item is string => typeof item === "string") + : [] + ) + return { + _tag: "Object", + ...common, + fields: Object.entries(resolved.properties).map(([key, child]) => ({ + key, + required: required.has(key), + field: project(child, definitions) + })) + } + } + case "array": + return { + _tag: "Array", + ...common, + item: project(resolved.items, definitions), + minItems: numberValue(resolved.minItems) ?? 0, + maxItems: numberValue(resolved.maxItems) + } + default: + return { + _tag: "Unsupported", + ...common, + reason: "This input schema has no concrete JSON shape to render." + } + } +} + +export const projectInputSchema = (document: InputSchema): InputField => project(document.schema, document.definitions) + +export interface InputFormResult { + readonly ok: boolean + readonly value?: unknown +} + +export interface InputForm { + readonly element: HTMLFormElement + readonly hasFields: boolean + readonly supported: boolean + readonly read: () => InputFormResult +} + +interface Control { + readonly element: HTMLElement + readonly interactive: boolean + readonly supported: boolean + readonly read: () => unknown +} + +let nextControlId = 0 + +const element = ( + tag: Tag, + className?: string, + text?: string +): HTMLElementTagNameMap[Tag] => { + const node = document.createElement(tag) + if (className !== undefined) node.className = className + if (text !== undefined) node.textContent = text + return node +} + +const valueKey = (value: JsonPrimitive): string => JSON.stringify(value) + +const labelText = (field: InputField, fallback: string): string => field.title ?? fallback + +const description = (field: InputField): HTMLElement | undefined => + field.description === undefined ? undefined : element("p", "input-description", field.description) + +const renderControl = (field: InputField, name: string): Control => { + switch (field._tag) { + case "String": { + const input = element("input", "input-control") + input.type = field.format === "date-time" + ? "datetime-local" + : field.format === "date" + ? "date" + : field.format === "email" + ? "email" + : field.format === "uri" + ? "url" + : "text" + input.name = name + input.value = field.defaultValue ?? "" + if (field.minLength !== undefined) input.minLength = field.minLength + if (field.maxLength !== undefined) input.maxLength = field.maxLength + if (field.pattern !== undefined) input.pattern = field.pattern + return { element: input, interactive: true, supported: true, read: () => input.value } + } + case "Number": { + const input = element("input", "input-control") + input.type = "number" + input.name = name + input.step = field.integer ? "1" : "any" + if (field.defaultValue !== undefined) input.value = String(field.defaultValue) + if (field.minimum !== undefined) input.min = String(field.minimum) + if (field.maximum !== undefined) input.max = String(field.maximum) + return { + element: input, + interactive: true, + supported: true, + read: () => input.value === "" ? undefined : Number(input.value) + } + } + case "Boolean": { + const wrapper = element("label", "boolean-control") + const input = element("input") + input.type = "checkbox" + input.name = name + input.checked = field.defaultValue ?? false + wrapper.append(input, element("span", undefined, "Enabled")) + return { element: wrapper, interactive: true, supported: true, read: () => input.checked } + } + case "Enum": { + const select = element("select", "input-control") + select.name = name + field.values.forEach((value) => { + const option = element("option", undefined, String(value)) + option.value = valueKey(value) + option.selected = Object.is(value, field.defaultValue) + select.append(option) + }) + return { + element: select, + interactive: true, + supported: true, + read: () => JSON.parse(select.value) as JsonPrimitive + } + } + case "Literal": { + const output = element("output", "literal-control", String(field.value)) + return { element: output, interactive: false, supported: true, read: () => field.value } + } + case "Object": { + const group = element("fieldset", "input-object") + const legend = element("legend", undefined, labelText(field, name)) + group.append(legend) + const controls: Array<{ + readonly key: string + readonly included: HTMLInputElement | undefined + readonly control: Control + }> = [] + for (const property of field.fields) { + const row = element("div", "input-field") + const heading = element("div", "input-field-heading") + const label = element("label", "input-label", labelText(property.field, property.key)) + const required = property.required ? element("span", "input-required", "required") : undefined + let included: HTMLInputElement | undefined + const control = renderControl(property.field, `${name}.${property.key}`) + const labelled = control.element instanceof HTMLInputElement || control.element instanceof HTMLSelectElement + ? control.element + : control.element.querySelector(":scope > input, :scope > select") + if (labelled !== null) { + const id = `machine-input-${nextControlId++}` + labelled.id = id + label.htmlFor = id + } + if (property.required) { + if ( + control.element instanceof HTMLSelectElement || + (control.element instanceof HTMLInputElement && control.element.type !== "checkbox") + ) { + control.element.required = true + } + heading.append(label, required!) + } else { + const optional = element("label", "input-optional") + included = element("input") + included.type = "checkbox" + optional.append(included, element("span", undefined, "include")) + heading.append(label, optional) + control.element.toggleAttribute("inert", true) + control.element.classList.add("is-disabled") + control.element.querySelectorAll( + "input, select, button" + ).forEach((item) => item.disabled = true) + if ( + control.element instanceof HTMLInputElement || + control.element instanceof HTMLSelectElement || + control.element instanceof HTMLButtonElement + ) control.element.disabled = true + included.addEventListener("change", () => { + control.element.toggleAttribute("inert", !included!.checked) + control.element.classList.toggle("is-disabled", !included!.checked) + control.element.querySelectorAll( + "input, select, button" + ).forEach((item) => item.disabled = !included!.checked) + if ( + control.element instanceof HTMLInputElement || + control.element instanceof HTMLSelectElement || + control.element instanceof HTMLButtonElement + ) control.element.disabled = !included!.checked + }) + } + row.append(heading, control.element) + const details = description(property.field) + if (details !== undefined) row.append(details) + group.append(row) + controls.push({ key: property.key, included, control }) + } + return { + element: group, + interactive: controls.some(({ control }) => control.interactive), + supported: controls.every(({ control }) => control.supported), + read: () => + Object.fromEntries( + controls + .filter(({ included }) => included === undefined || included.checked) + .map(({ key, control }) => [key, control.read()]) + .filter((entry) => entry[1] !== undefined) + ) + } + } + case "Array": { + const group = element("fieldset", "input-array") + group.append(element("legend", undefined, labelText(field, name))) + const items = element("div", "input-array-items") + const controls: Array<{ readonly row: HTMLElement; readonly control: Control }> = [] + const add = element("button", "input-array-add", "Add item") + add.type = "button" + const addItem = (): void => { + if (field.maxItems !== undefined && controls.length >= field.maxItems) return + const row = element("div", "input-array-item") + const control = renderControl(field.item, `${name}.${controls.length}`) + const remove = element("button", "input-array-remove", "Remove") + remove.type = "button" + remove.addEventListener("click", () => { + row.remove() + const index = controls.findIndex((item) => item.row === row) + if (index >= 0) controls.splice(index, 1) + add.disabled = false + }) + row.append(control.element, remove) + items.append(row) + controls.push({ row, control }) + add.disabled = field.maxItems !== undefined && controls.length >= field.maxItems + } + for (let index = 0; index < field.minItems; index++) addItem() + add.addEventListener("click", addItem) + group.append(items, add) + return { + element: group, + interactive: true, + supported: controls.every(({ control }) => control.supported) && field.item._tag !== "Unsupported", + read: () => controls.map(({ control }) => control.read()) + } + } + case "Union": { + const group = element("fieldset", "input-union") + group.append(element("legend", undefined, labelText(field, name))) + const select = element("select", "input-control") + const body = element("div", "input-union-body") + let selected = 0 + const controls = field.alternatives.map((alternative, index) => { + const control = renderControl(alternative, `${name}.${index}`) + const option = element("option", undefined, labelText(alternative, `Option ${index + 1}`)) + option.value = String(index) + select.append(option) + return control + }) + const update = (): void => { + selected = Number(select.value) + body.replaceChildren(controls[selected]?.element ?? element("div")) + } + select.addEventListener("change", update) + group.append(select, body) + update() + return { + element: group, + interactive: true, + supported: controls.every(({ supported }) => supported), + read: () => controls[selected]?.read() + } + } + case "Unsupported": { + const message = element("p", "input-unsupported", field.reason) + return { element: message, interactive: false, supported: false, read: () => undefined } + } + } +} + +export const renderInputForm = ( + schema: InputSchema, + options: { + readonly name: string + readonly fixed?: Readonly> + readonly omit?: ReadonlyArray + } +): InputForm => { + const form = element("form", "schema-form") + const projected = projectInputSchema(schema) + const visible = projected._tag === "Object" && options.omit !== undefined + ? { ...projected, fields: projected.fields.filter(({ key }) => !options.omit!.includes(key)) } + : projected + const control = renderControl(visible, options.name) + form.append(control.element) + return { + element: form, + hasFields: control.interactive, + supported: control.supported, + read: () => { + if (!form.reportValidity() || !control.supported) return { ok: false } + const value = control.read() + return { + ok: true, + value: isRecord(value) && options.fixed !== undefined ? { ...value, ...options.fixed } : value + } + } + } +} diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css index 16a2527..fc3951e 100644 --- a/packages/devtools/src/internal/browser/styles.css +++ b/packages/devtools/src/internal/browser/styles.css @@ -387,7 +387,7 @@ button { } .topology-node.is-related-target > .state-row { - background: rgb(75 125 255 / 9%); + background: rgb(75 125 255 / 15%); } .topology-node.is-related-source > .state-row { @@ -760,22 +760,146 @@ button { margin-bottom: 0; } -.json-editor { +.schema-form, +.input-object, +.input-array, +.input-union, +.input-field, +.input-array-items { + display: grid; + gap: 10px; +} + +.schema-form { + max-width: 520px; +} + +.input-object, +.input-array, +.input-union { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.input-object > legend, +.input-array > legend, +.input-union > legend { + margin-bottom: 8px; + padding: 0; + color: #d5d8dd; + font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.input-object .input-object, +.input-object .input-array, +.input-object .input-union { + padding: 11px; + border: 1px solid var(--line-soft); + background: #101216; +} + +.input-field { + gap: 6px; +} + +.input-field + .input-field { + padding-top: 10px; + border-top: 1px solid var(--line-soft); +} + +.input-field-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.input-label, +.input-required, +.input-optional, +.boolean-control { + font-size: 11px; +} + +.input-label { + color: #cfd3d9; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.input-required { + color: #737b86; +} + +.input-optional, +.boolean-control { + display: inline-flex; + align-items: center; + gap: 6px; + color: #9199a4; +} + +.input-control { width: 100%; - min-height: 150px; - resize: vertical; - padding: 12px; + min-height: 34px; + padding: 7px 9px; border: 1px solid var(--line); border-radius: 3px; color: #dfe7f5; background: #0b0d10; - font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - tab-size: 2; + font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; outline: none; } -.json-editor:focus { - border-color: #4d6f9f; +.input-control:focus { + border-color: #557db8; +} + +.input-description, +.input-unsupported { + margin: 0; + font-size: 11px; + line-height: 1.55; +} + +.input-description { + color: #737b86; +} + +.input-unsupported { + color: #d7a5a2; +} + +.literal-control { + color: #aeb6c1; + font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.is-disabled { + opacity: 0.45; +} + +.input-array-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 7px; +} + +.input-array-add, +.input-array-remove { + padding: 6px 8px; + border: 0; + border-radius: 3px; + color: #9ebce9; + background: #1b2431; + font-size: 10px; + cursor: pointer; +} + +.input-array-add { + justify-self: start; } .simulation-action { diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index 6635c23..07de845 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -11,6 +11,7 @@ import type { MachineDocument as VisualizationDocument, Transition as VisualizationTransition } from "../../MachineDocument.js" +import { renderInputForm } from "./input-form.js" import { requestSimulation } from "./simulation-client.js" import { type EventInspection, @@ -165,6 +166,12 @@ const inspectionSection = (title: string, count: number): HTMLElement => { return header } +const formSection = (title: string): HTMLElement => { + const header = createElement("div", "section-heading") + header.append(createElement("h3", undefined, title)) + return header +} + const prettyJson = (value: unknown): string => JSON.stringify(value, null, 2) const jsonBlock = (value: unknown): HTMLElement => createElement("pre", "json-value", prettyJson(value)) @@ -172,15 +179,9 @@ const jsonBlock = (value: unknown): HTMLElement => createElement("pre", "json-va const eventName = (value: unknown): string => typeof value === "object" && value !== null && "_tag" in value ? String(value._tag) : "event" -const parseJson = (value: string): { readonly ok: true; readonly value: unknown } | { - readonly ok: false - readonly message: string -} => { - try { - return { ok: true, value: JSON.parse(value) } - } catch (cause) { - return { ok: false, message: cause instanceof Error ? cause.message : String(cause) } - } +const activeTopology = (paths: ReadonlyArray): string => { + const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`))) + return leaves.map((path) => path.split(".").at(-1) ?? path).join(" + ") || "none" } export const renderVisualizer = ( @@ -194,6 +195,7 @@ export const renderVisualizer = ( const nodes = new Map() const statuses = new Map() const eventButtons = new Map() + const eventSchemas = new Map(visualization.inputs.events.map(({ event, schema }) => [event, schema])) const relatedPaths = new Set() let selectedPath: string | undefined let selectedEvent: string | undefined @@ -316,54 +318,60 @@ export const renderVisualizer = ( ])) inspector.append(header) + if (simulation !== undefined && candidate) { + const schema = eventSchemas.get(inspection.event) + const composer = createElement("section", "inspector-section simulation-composer") + composer.append(formSection("Event input")) + if (schema === undefined) { + composer.append(createElement( + "p", + "input-unsupported", + "No public input schema is available for this event." + )) + } else { + const input = renderInputForm(schema, { + name: inspection.event, + fixed: { _tag: inspection.event }, + omit: ["_tag"] + }) + const send = createElement("button", "simulation-action", `Send ${inspection.event}`) + send.type = "submit" + send.disabled = simulationPending || !input.supported + input.element.addEventListener("submit", (event) => { + event.preventDefault() + const result = input.read() + const source = visualization.source + if (!result.ok || source === null || simulation === undefined) return + void runSimulation({ + _tag: "SendSimulationEvent", + protocolVersion, + key: machineKey, + revision: visualization.revision, + source, + step: simulation.step, + snapshot: simulation.snapshot, + event: result.value as never + }) + }) + input.element.append(send) + composer.append( + input.element, + createElement( + "p", + "simulation-note", + "The real planner validates this event and shows every synchronous transition it selects." + ) + ) + } + inspector.append(composer) + } + const transitions = createElement("section", "inspector-section") transitions.append(inspectionSection("Transitions", inspection.transitions.length)) inspection.transitions.forEach((transition) => transitions.append(renderTransition(transition, navigateToState, true)) ) inspector.append(transitions) - - if (simulation !== undefined) { - const composer = createElement("section", "inspector-section simulation-composer") - composer.append(inspectionSection("Event payload", 1)) - const editor = createElement("textarea", "json-editor") - editor.value = prettyJson({ _tag: inspection.event }) - editor.spellcheck = false - editor.setAttribute("aria-label", `${inspection.event} JSON payload`) - const error = createElement("div", "editor-error") - error.hidden = true - const send = createElement("button", "simulation-action", "Plan event") - send.type = "button" - send.disabled = simulationPending - send.addEventListener("click", () => { - const parsed = parseJson(editor.value) - if (!parsed.ok) { - error.textContent = parsed.message - error.hidden = false - return - } - error.hidden = true - const source = visualization.source - if (source === null || simulation === undefined) return - void runSimulation({ - _tag: "SendSimulationEvent", - protocolVersion, - key: machineKey, - revision: visualization.revision, - source, - step: simulation.step, - snapshot: simulation.snapshot, - event: parsed.value as never - }) - }) - const note = createElement( - "p", - "simulation-note", - "Schema decoding and synchronous transition resolvers run in an isolated worker. Planned commands are not committed." - ) - composer.append(editor, error, send, note) - inspector.append(composer) - } } const renderSimulationFailure = (failureDiagnostics: ReadonlyArray): void => { @@ -496,49 +504,40 @@ export const renderVisualizer = ( header.append(eyebrow, createElement("h2", undefined, "Start simulation")) inspector.append(header) const composer = createElement("section", "inspector-section simulation-composer") - composer.append(inspectionSection("Machine input", 1)) - const editor = createElement("textarea", "json-editor") - editor.placeholder = "Optional JSON input" - editor.spellcheck = false - editor.setAttribute("aria-label", "Machine input JSON") - const error = createElement("div", "editor-error") - error.hidden = true - const start = createElement("button", "simulation-action", "Plan initial state") - start.type = "button" - start.disabled = simulationPending || visualization.source === null - start.addEventListener("click", () => { + composer.append(formSection("Machine input")) + const input = visualization.inputs.machine + if (input === null) { + composer.append(createElement("p", "section-empty", "This machine does not declare startup input.")) + inspector.append(composer) + return + } + const form = renderInputForm(input, { name: "Machine input" }) + const start = createElement("button", "simulation-action", "Start simulation") + start.type = "submit" + start.disabled = simulationPending || visualization.source === null || !form.supported + form.element.addEventListener("submit", (event) => { + event.preventDefault() const source = visualization.source if (source === null) return - const input = editor.value.trim() - let value: unknown - if (input.length > 0) { - const parsed = parseJson(input) - if (!parsed.ok) { - error.textContent = parsed.message - error.hidden = false - return - } - value = parsed.value - } - error.hidden = true + const result = form.read() + if (!result.ok) return const request: SimulationRequest = { _tag: "StartSimulation", protocolVersion, key: machineKey, revision: visualization.revision, source, - ...(input.length > 0 ? { input: value as never } : {}) + input: result.value as never } void runSimulation(request) }) + form.element.append(start) composer.append( - editor, - error, - start, + form.element, createElement( "p", "simulation-note", - "Leave input empty for machines without input. Initialization and synchronous callbacks run; runtime activities and commands do not." + "Initialization and synchronous callbacks run in isolation. Runtime activities and planned commands are not started." ) ) inspector.append(composer) @@ -586,11 +585,17 @@ export const renderVisualizer = ( }) }) clearButton.disabled = false + const before = activeTopology(result.frame.before.activePaths) + const after = activeTopology(result.frame.after.activePaths) simulationFeedback.textContent = result.frame.trigger._tag === "Initial" - ? "Initial state planned" + ? `Started in ${after}` : result.frame.microsteps.length === 0 - ? "No transition accepted the value" - : `${result.frame.microsteps.length} microstep${result.frame.microsteps.length === 1 ? "" : "s"} planned` + ? `${eventName(result.frame.trigger.event)} was not accepted · remained in ${after}` + : before === after + ? `${eventName(result.frame.trigger.event)} handled · remained in ${after}` + : `${before} → ${after} · ${result.frame.microsteps.length} microstep${ + result.frame.microsteps.length === 1 ? "" : "s" + }` simulationFeedback.dataset.status = "applied" } catch (cause) { selectedFrame = undefined @@ -714,6 +719,27 @@ export const renderVisualizer = ( clearRelations() markTransitions(inspection.transitions) clearButton.disabled = false + const schema = eventSchemas.get(event) + if (simulation !== undefined && candidateEvents().includes(event) && schema !== undefined) { + const input = renderInputForm(schema, { name: event, fixed: { _tag: event }, omit: ["_tag"] }) + if (!input.hasFields && input.supported) { + const result = input.read() + const source = visualization.source + if (result.ok && source !== null) { + void runSimulation({ + _tag: "SendSimulationEvent", + protocolVersion, + key: machineKey, + revision: visualization.revision, + source, + step: simulation.step, + snapshot: simulation.snapshot, + event: result.value as never + }) + return + } + } + } renderEventInspection(inspection) } @@ -863,7 +889,17 @@ export const renderVisualizer = ( selectedFrame = undefined clearRelations() clearButton.disabled = false - renderStartSimulation() + if (visualization.inputs.machine === null && visualization.source !== null) { + void runSimulation({ + _tag: "StartSimulation", + protocolVersion, + key: machineKey, + revision: visualization.revision, + source: visualization.source + }) + } else { + renderStartSimulation() + } } else { simulation = undefined selectedFrame = undefined diff --git a/packages/devtools/src/internal/evaluationWorker.ts b/packages/devtools/src/internal/evaluationWorker.ts index 76426f6..4a48368 100644 --- a/packages/devtools/src/internal/evaluationWorker.ts +++ b/packages/devtools/src/internal/evaluationWorker.ts @@ -224,6 +224,7 @@ const simulationEvent = (machine: Machine.Machine.Any, value: Schema.Json): unkn if (typeof value !== "object" || value === null || Array.isArray(value) || !("_tag" in value)) return value const tag = value._tag if (typeof tag !== "string" && typeof tag !== "number") return value + if (!Object.hasOwn(machine.events, tag)) return value const constructor = Reflect.get(machine.events, tag) if (typeof constructor !== "function") return value const { _tag: _, ...payload } = value @@ -233,10 +234,13 @@ const simulationEvent = (machine: Machine.Machine.Any, value: Schema.Json): unkn const simulationSnapshot = ( machine: Machine.Machine.Any, snapshot: unknown -): DevToolsProtocol.SimulationSnapshot => ({ - activePaths: configuration(machine, snapshot).map((node) => node.path), - candidateEvents: enabled(machine, snapshot).map(String) -}) +): DevToolsProtocol.SimulationSnapshot => { + const publicEvents = new Set(Reflect.ownKeys(machine.events).map(String)) + return { + activePaths: configuration(machine, snapshot).map((node) => node.path), + candidateEvents: enabled(machine, snapshot).map(String).filter((event) => publicEvents.has(event)) + } +} const trigger = (value: Machine.Machine.TransitionTrigger): MachineDocument.Trigger => { switch (value.type) { diff --git a/packages/devtools/src/internal/machineDocument.ts b/packages/devtools/src/internal/machineDocument.ts index 2f8c974..c0b7158 100644 --- a/packages/devtools/src/internal/machineDocument.ts +++ b/packages/devtools/src/internal/machineDocument.ts @@ -1,4 +1,5 @@ import { Machine } from "@typeonce/effect-machine" +import * as Schema from "effect/Schema" import type * as Public from "../MachineDocument.js" const enabled = Machine.enabled as ( @@ -6,6 +7,72 @@ const enabled = Machine.enabled as ( snapshot: unknown ) => ReadonlyArray +const inputEventSchemas = Machine.inputEventSchemas as ( + machine: Machine.Machine.Any +) => ReadonlyArray + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const resolveReference = ( + value: unknown, + definitions: Readonly> +): Record | undefined => { + if (!isRecord(value)) return undefined + if (typeof value.$ref !== "string" || !value.$ref.startsWith("#/$defs/")) return value + const name = decodeURIComponent(value.$ref.slice("#/$defs/".length)) + const target = definitions[name] + return isRecord(target) ? target : undefined +} + +const tagsOf = ( + value: unknown, + definitions: Readonly> +): ReadonlyArray => { + const schema = resolveReference(value, definitions) + if (schema === undefined || !isRecord(schema.properties)) return [] + const tag = resolveReference(schema.properties._tag, definitions) + if (tag === undefined) return [] + if (typeof tag.const === "string" || typeof tag.const === "number") return [String(tag.const)] + return Array.isArray(tag.enum) + ? tag.enum.filter((item): item is string | number => typeof item === "string" || typeof item === "number").map( + String + ) + : [] +} + +const inputSchema = (schema: Schema.Top): Public.InputSchema => { + const document = Schema.toJsonSchemaDocument(schema) + return { + dialect: document.dialect, + schema: document.schema as Schema.Json, + definitions: document.definitions as Record + } +} + +const eventInputs = (machine: Machine.Machine.Any): ReadonlyArray => { + const inputs: Array = [] + for (const eventSchema of inputEventSchemas(machine)) { + const document = inputSchema(eventSchema) + const root = document.schema + const record = isRecord(root) ? root : undefined + const variants = record !== undefined && Array.isArray(record.anyOf) + ? record.anyOf + : record !== undefined && Array.isArray(record.oneOf) + ? record.oneOf + : [root] + for (const variant of variants) { + for (const event of tagsOf(variant, document.definitions)) { + inputs.push({ + event, + schema: { ...document, schema: variant as Schema.Json } + }) + } + } + } + return inputs +} + const selection = (value: Machine.Machine.TransitionTargetSelection): Public.Selection => ({ path: value.path ?? null, kind: value.kind, @@ -102,7 +169,7 @@ export const make = ( const initial = Machine.initialDefinition(machine) return { - schemaVersion: 1, + schemaVersion: 2, revision: options.revision ?? 0, source: options.source ?? null, machineId: machine.id ?? "Machine", @@ -128,11 +195,17 @@ export const make = ( })), transitions, activities, + inputs: { + machine: machine.input === undefined ? null : inputSchema(machine.input), + events: eventInputs(machine) + }, snapshot: options.snapshot === undefined ? null : { activePaths: Machine.configuration(machine, options.snapshot).map((node) => node.path), - candidateEvents: enabled(machine, options.snapshot).map(String) + candidateEvents: enabled(machine, options.snapshot) + .map(String) + .filter((event) => Object.hasOwn(machine.events, event)) } } } diff --git a/packages/devtools/test/MachineDocument.test.ts b/packages/devtools/test/MachineDocument.test.ts index 40c195c..d635a79 100644 --- a/packages/devtools/test/MachineDocument.test.ts +++ b/packages/devtools/test/MachineDocument.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import * as Schema from "effect/Schema" import * as DevToolsProtocol from "../src/DevToolsProtocol.js" import { machine, snapshot } from "../src/internal/browser/example-machine.js" +import { plannerMachine } from "../src/internal/browser/planner-example.js" import * as MachineDocument from "../src/MachineDocument.js" describe("MachineDocument", () => { @@ -12,7 +13,7 @@ describe("MachineDocument", () => { snapshot }) - assert.strictEqual(document.schemaVersion, 1) + assert.strictEqual(document.schemaVersion, 2) assert.strictEqual(document.revision, 3) assert.deepStrictEqual(document.source, { file: "/project/src/workflow.ts", @@ -21,6 +22,20 @@ describe("MachineDocument", () => { assert.deepStrictEqual(Schema.decodeUnknownSync(MachineDocument.MachineDocument)(document), document) }) + it("captures form schemas for machine and public event inputs", () => { + const document = MachineDocument.make(plannerMachine) + const begin = document.inputs.events.find(({ event }) => event === "Begin") + + assert.deepStrictEqual(document.inputs.machine?.schema, { + type: "object", + properties: { owner: { type: "string" } }, + required: ["owner"], + additionalProperties: false + }) + assert.deepStrictEqual(begin?.schema.schema, { $ref: "#/$defs/PlannerBeginEncoded" }) + assert.deepStrictEqual(document.inputs.events.map(({ event }) => event), ["Begin", "Cancel"]) + }) + it("validates ready, partial, and failed evaluation results", () => { const document = MachineDocument.make(machine) const diagnostic: DevToolsProtocol.Diagnostic = { @@ -33,21 +48,21 @@ describe("MachineDocument", () => { const results: ReadonlyArray = [ { _tag: "Ready", - protocolVersion: 1, + protocolVersion: 2, key: "workflow.ts#workflow", document, diagnostics: [] }, { _tag: "Partial", - protocolVersion: 1, + protocolVersion: 2, key: "workflow.ts#workflow", document, diagnostics: [diagnostic] }, { _tag: "Failed", - protocolVersion: 1, + protocolVersion: 2, key: "broken.ts#machine", source: { file: "broken.ts", exportName: "machine" }, machineId: null, diff --git a/packages/devtools/test/MachineRegistry.test.ts b/packages/devtools/test/MachineRegistry.test.ts index 3a0b8f0..07758da 100644 --- a/packages/devtools/test/MachineRegistry.test.ts +++ b/packages/devtools/test/MachineRegistry.test.ts @@ -11,14 +11,14 @@ describe("MachineRegistry", () => { }) const ready: DevToolsProtocol.Ready = { _tag: "Ready", - protocolVersion: 1, + protocolVersion: 2, key: "src/workflow.ts#workflow", document, diagnostics: [] } const failed: DevToolsProtocol.Failed = { _tag: "Failed", - protocolVersion: 1, + protocolVersion: 2, key: ready.key, source: { file: "src/workflow.ts", exportName: "workflow" }, machineId: null, @@ -48,14 +48,14 @@ describe("MachineRegistry", () => { }) const ready: DevToolsProtocol.Ready = { _tag: "Ready", - protocolVersion: 1, + protocolVersion: 2, key: "src/workflow.ts#workflow", document, diagnostics: [] } const failed: DevToolsProtocol.Failed = { _tag: "Failed", - protocolVersion: 1, + protocolVersion: 2, key: "src/workflow.ts#renamed", source: { file: "src/workflow.ts", exportName: "renamed" }, machineId: null, diff --git a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts index f3939fa..79e7c82 100644 --- a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts +++ b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts @@ -3,6 +3,8 @@ import { Machine } from "@typeonce/effect-machine" import * as Schema from "effect/Schema" import { makeTextRenderer } from "../../../../effect-machine/test/machine/visualization/text.js" import { machine, snapshot } from "../../../src/internal/browser/example-machine.js" +import { projectInputSchema } from "../../../src/internal/browser/input-form.js" +import { plannerMachine } from "../../../src/internal/browser/planner-example.js" import { textTreeToString } from "../../../src/internal/browser/text-tree.js" import { makeVisualizerModel } from "../../../src/internal/browser/visualizer-model.js" import * as MachineDocument from "../../../src/MachineDocument.js" @@ -111,4 +113,35 @@ describe("Interactive text visualization", () => { assert.strictEqual(model.hasSnapshot, false) assert.strictEqual(model.inspectState("application"), undefined) }) + + it("projects machine and event schemas into concrete form fields", () => { + const document = MachineDocument.make(plannerMachine) + const input = document.inputs.machine === null ? undefined : projectInputSchema(document.inputs.machine) + const beginSchema = document.inputs.events.find(({ event }) => event === "Begin")?.schema + const begin = beginSchema === undefined ? undefined : projectInputSchema(beginSchema) + + assert.deepStrictEqual(input, { + _tag: "Object", + title: undefined, + description: undefined, + fields: [{ + key: "owner", + required: true, + field: { + _tag: "String", + title: undefined, + description: undefined, + defaultValue: undefined, + format: undefined, + minLength: undefined, + maxLength: undefined, + pattern: undefined + } + }] + }) + assert.strictEqual(begin?._tag, "Object") + if (begin?._tag !== "Object") return + assert.deepStrictEqual(begin.fields.map(({ key }) => key), ["_tag", "job", "priority"]) + assert.strictEqual(begin.fields[2]?.field._tag, "Enum") + }) }) diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 48bf81f..2f4428e 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -9177,6 +9177,22 @@ export const initialDefinition: (machine: M) => Machine.I Machine.RootStateIdentifier>> > = internal.initialDefinition +/** + * Returns the schemas accepted by the machine's public event protocol. + * + * **Details** + * + * The returned tuple retains the schemas supplied to {@link events}, including + * grouped tagged unions. Internal and emitted event schemas are excluded. This + * getter is intended for tooling that needs to describe or construct valid + * external inputs without reaching into the opaque event protocol. + * + * @category getters + * @since 0.24.0 + */ +export const inputEventSchemas: (machine: M) => Machine.InputEvents = + internal.inputEventSchemas + /** * Returns every registered transition handler in state definition order. * diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index 8faf1aa..b074f10 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -2097,6 +2097,10 @@ export const initialDefinition = ( Machine.RootStateIdentifier>> > +export const inputEventSchemas = ( + machine: M +): Machine.InputEvents => Protocol.inputEventSchemas(machine) as Machine.InputEvents + export const transitionDefinitions = ( machine: M ): ReadonlyArray< diff --git a/packages/effect-machine/test/machine/Visualization.test.ts b/packages/effect-machine/test/machine/Visualization.test.ts index f473245..029d306 100644 --- a/packages/effect-machine/test/machine/Visualization.test.ts +++ b/packages/effect-machine/test/machine/Visualization.test.ts @@ -17,6 +17,7 @@ class Disabled extends Schema.TaggedClass("Disabled")("Disabled", {}) class Start extends Schema.TaggedClass("Start")("Start", {}) {} class Disconnect extends Schema.TaggedClass("Disconnect")("Disconnect", {}) {} class Refresh extends Schema.TaggedClass("Refresh")("Refresh", {}) {} +class InternalRefresh extends Schema.TaggedClass("InternalRefresh")("InternalRefresh", {}) {} const States = Machine.states({ application: { @@ -83,6 +84,7 @@ const machineDefinition = Machine.make({ id: "inspection-example", states: States.states, events: Machine.events(Start, Disconnect, Refresh), + internalEvents: Machine.internalEvents(InternalRefresh), initial: (to) => to.application.initial.resolve(() => initial) }) @@ -187,6 +189,10 @@ const renderLifecycleMachine = makeTextRenderer< >(Machine) describe("Machine structural visualization", () => { + it("exposes only the public input event schemas", () => { + assert.deepStrictEqual(Machine.inputEventSchemas(machine), [Start, Disconnect, Refresh]) + }) + it("exposes the static root initial selection without executing the resolver", () => { const inspectOnly = Machine.make({ states: States.states, diff --git a/packages/effect-machine/typetest/machine/Inspection.tst.ts b/packages/effect-machine/typetest/machine/Inspection.tst.ts index f5e4f03..00afdfe 100644 --- a/packages/effect-machine/typetest/machine/Inspection.tst.ts +++ b/packages/effect-machine/typetest/machine/Inspection.tst.ts @@ -67,6 +67,7 @@ describe("Machine inspection", () => { }) it("preserves state paths for structural inspection", () => { + expect(Machine.inputEventSchemas(machine)).type.toBe() const nodes = Machine.stateNodes(machine) expect(nodes[0]!.path).type.toBe<"root" | "root.idle" | "root.recent">() expect(nodes.find((node) => node.type === "history")!.path).type.toBe<"root.recent">() From 65cf02d56af2bde10090eeab2dd888cd9aa58499 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 24 Aug 2026 12:05:44 +0200 Subject: [PATCH 3/3] feat(devtools): validate schema-derived inputs --- .changeset/calm-planners-trace.md | 2 +- packages/devtools/README.md | 4 +- packages/devtools/src/DevToolsProtocol.ts | 21 +- .../src/internal/browser/input-form.ts | 213 ++++++++++++++++-- .../src/internal/browser/planner-example.ts | 82 ++++++- .../devtools/src/internal/browser/styles.css | 44 +++- .../src/internal/browser/visualizer-app.ts | 23 +- packages/devtools/src/internal/devServer.ts | 1 + .../devtools/src/internal/evaluationWorker.ts | 132 +++++++++-- .../devtools/test/MachineDocument.test.ts | 20 +- .../devtools/test/ProjectInspector.test.ts | 63 +++++- .../InteractiveTextVisualization.test.ts | 66 ++++-- 12 files changed, 598 insertions(+), 73 deletions(-) diff --git a/.changeset/calm-planners-trace.md b/.changeset/calm-planners-trace.md index 6e82b57..88ca820 100644 --- a/.changeset/calm-planners-trace.md +++ b/.changeset/calm-planners-trace.md @@ -3,6 +3,6 @@ "@typeonce/effect-machine-devtools": minor --- -Add planner-backed simulation sessions to the web visualizer. Machine and event inputs are rendered as fields from their Effect schemas, while each isolated step uses the real Effect Machine planner and shows selected branches, concrete topology changes, raised and emitted events, planned commands, completion, and output as a structured trace. +Add planner-backed simulation sessions to the web visualizer. Machine and event inputs are rendered as fields from their Effect schemas, including type and constraint metadata, nested objects, arrays, unions, enums, literals, booleans, strings, and numbers. Browser constraints provide immediate feedback, while authoritative Effect Schema failures are mapped back to their fields. Each isolated step uses the real Effect Machine planner and shows selected branches, concrete topology changes, raised and emitted events, planned commands, completion, and output as a structured trace. Expose `Machine.inputEventSchemas` so inspection tools can describe or construct valid public events without reaching into the opaque event protocol. Planning evaluates synchronous statechart callbacks but does not commit commands or start runtime activities. Schema and planning failures remain visible beside the machine topology. diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 9f185bb..fb51dd6 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -66,7 +66,9 @@ Run the devtools only against code you trust. The server has no authentication a The visualizer shows topology, active initial paths, state annotations, events, transitions, branches, state updates, activities, source metadata, and diagnostics. The tree supports pointer and keyboard navigation, subtree expansion, related-state highlighting, and structured detail inspection. -Simulation uses the same `Machine.planInitial` and `Machine.plan` semantics as the core package. Machine input and event payload controls are derived from their Effect schemas. Start a session, send an enabled event, and inspect the resulting macrostep as structured microsteps. Events without payload fields run when clicked; events with input open a form first. The trace includes selected branches, before/after topology, exits, entries, state updates, raised events, emitted events, planned commands, completion, and output. +Simulation uses the same `Machine.planInitial` and `Machine.plan` semantics as the core package. Machine input and event payload controls are derived from their Effect schemas. Fields show their projected type, description, required or optional status, and constraints such as ranges, lengths, patterns, enum choices, and defaults. Supported controls include strings, numbers, booleans, enums, literals, nested objects, arrays, and unions. Browser constraints provide immediate feedback, then Effect Schema validates the complete value in the worker and reports failures beside the corresponding fields. + +Start a session, send an enabled event, and inspect the resulting macrostep as structured microsteps. Events without payload fields run when clicked; events with input open a form first. The trace includes selected branches, before/after topology, exits, entries, state updates, raised events, emitted events, planned commands, completion, and output. Each plan loads the exported machine in a fresh worker, decodes the portable session snapshot, and evaluates synchronous statechart callbacks. This supports conditional branches, parallel transitions, history, choices, state updates, reentry, and automatic stabilization. It also means synchronous code inside initial, transition, entry, exit, choice, history, and output callbacks runs during planning. diff --git a/packages/devtools/src/DevToolsProtocol.ts b/packages/devtools/src/DevToolsProtocol.ts index 9ed71e4..12f5dcf 100644 --- a/packages/devtools/src/DevToolsProtocol.ts +++ b/packages/devtools/src/DevToolsProtocol.ts @@ -377,6 +377,24 @@ export const SimulationReady = Schema.Struct({ */ export type SimulationReady = Schema.Schema.Type +/** + * One authoritative Effect Schema issue associated with a machine or event + * input path. + * + * @category schemas + * @since 0.24.0 + */ +export const InputIssue = Schema.Struct({ + path: Schema.Array(Schema.Union([Schema.String, Schema.Number])), + message: Schema.String +}) + +/** + * @category models + * @since 0.24.0 + */ +export type InputIssue = Schema.Schema.Type + /** * Recoverable failure produced while loading, decoding, or planning a session. * @@ -388,7 +406,8 @@ export const SimulationFailed = Schema.Struct({ _tag: Schema.tag("SimulationFailed"), key: Schema.String, revision: Schema.Natural, - diagnostics: Schema.Array(Diagnostic) + diagnostics: Schema.Array(Diagnostic), + inputIssues: Schema.Array(InputIssue) }) /** diff --git a/packages/devtools/src/internal/browser/input-form.ts b/packages/devtools/src/internal/browser/input-form.ts index 0d40af5..1b2c16a 100644 --- a/packages/devtools/src/internal/browser/input-form.ts +++ b/packages/devtools/src/internal/browser/input-form.ts @@ -1,3 +1,4 @@ +import type { InputIssue } from "../../DevToolsProtocol.js" import type { InputSchema } from "../../MachineDocument.js" type JsonPrimitive = string | number | boolean | null @@ -102,12 +103,43 @@ const annotations = (schema: Record) => ({ description: stringValue(schema.description) }) +const mergeAllOf = ( + schema: Record, + definitions: Readonly> +): Record => { + if (!Array.isArray(schema.allOf)) return schema + const { allOf, ...base } = schema + return Object.assign( + base, + ...allOf + .map((part) => resolveReference(part, definitions)) + .filter(isRecord) + .map((part) => mergeAllOf(part, definitions)) + ) +} + +const effectNumberAlternative = ( + alternatives: ReadonlyArray, + definitions: Readonly> +): Record | undefined => { + if (alternatives.length !== 2) return undefined + const resolved = alternatives.map((alternative) => resolveReference(alternative, definitions)) + const number = resolved.find((alternative) => isRecord(alternative) && alternative.type === "number") + const encodedNonFinite = resolved.find((alternative) => { + if (!isRecord(alternative) || alternative.type !== "string" || !Array.isArray(alternative.enum)) return false + const enumValues: ReadonlyArray = alternative.enum + return enumValues.length === 3 && + ["Infinity", "-Infinity", "NaN"].every((value) => enumValues.includes(value)) + }) + return isRecord(number) && encodedNonFinite !== undefined ? number : undefined +} + const project = ( value: unknown, definitions: Readonly> ): InputField => { - const resolved = resolveReference(value, definitions) - if (!isRecord(resolved)) { + const referenced = resolveReference(value, definitions) + if (!isRecord(referenced)) { return { _tag: "Unsupported", title: undefined, @@ -115,6 +147,7 @@ const project = ( reason: "This input schema cannot be represented as fields." } } + const resolved = mergeAllOf(referenced, definitions) const common = annotations(resolved) const alternatives = Array.isArray(resolved.oneOf) ? resolved.oneOf @@ -122,6 +155,17 @@ const project = ( ? resolved.anyOf : undefined if (alternatives !== undefined) { + const effectNumber = effectNumberAlternative(alternatives, definitions) + if (effectNumber !== undefined) { + return { + _tag: "Number", + ...common, + defaultValue: numberValue(resolved.default ?? effectNumber.default), + integer: false, + minimum: numberValue(resolved.minimum ?? effectNumber.minimum), + maximum: numberValue(resolved.maximum ?? effectNumber.maximum) + } + } return { _tag: "Union", ...common, @@ -220,6 +264,9 @@ export interface InputForm { readonly hasFields: boolean readonly supported: boolean readonly read: () => InputFormResult + readonly clearIssues: () => void + readonly setIssues: (issues: ReadonlyArray) => void + readonly setPending: (pending: boolean) => void } interface Control { @@ -229,6 +276,10 @@ interface Control { readonly read: () => unknown } +interface RenderContext { + readonly issueTargets: Map +} + let nextControlId = 0 const element = ( @@ -249,7 +300,72 @@ const labelText = (field: InputField, fallback: string): string => field.title ? const description = (field: InputField): HTMLElement | undefined => field.description === undefined ? undefined : element("p", "input-description", field.description) -const renderControl = (field: InputField, name: string): Control => { +const fieldType = (field: InputField): string => { + switch (field._tag) { + case "String": + return field.format ?? "string" + case "Number": + return field.integer ? "integer" : "number" + case "Boolean": + return "boolean" + case "Enum": + return "enum" + case "Literal": + return "literal" + case "Object": + return "object" + case "Array": + return `${fieldType(field.item)}[]` + case "Union": + return "union" + case "Unsupported": + return "unsupported" + } +} + +const fieldConstraints = (field: InputField): ReadonlyArray => { + switch (field._tag) { + case "String": + return [ + field.minLength === undefined ? undefined : `min ${field.minLength} characters`, + field.maxLength === undefined ? undefined : `max ${field.maxLength} characters`, + field.pattern === undefined ? undefined : `pattern ${field.pattern}`, + field.defaultValue === undefined ? undefined : `default ${field.defaultValue}` + ].filter((value): value is string => value !== undefined) + case "Number": + return [ + field.minimum === undefined ? undefined : `min ${field.minimum}`, + field.maximum === undefined ? undefined : `max ${field.maximum}`, + field.defaultValue === undefined ? undefined : `default ${field.defaultValue}` + ].filter((value): value is string => value !== undefined) + case "Enum": + return [ + `${field.values.length} options`, + field.defaultValue === undefined ? undefined : `default ${String(field.defaultValue)}` + ].filter((value): value is string => value !== undefined) + case "Literal": + return [`fixed ${String(field.value)}`] + case "Object": + return [`${field.fields.length} fields`] + case "Array": + return [ + field.minItems === 0 ? undefined : `min ${field.minItems} items`, + field.maxItems === undefined ? undefined : `max ${field.maxItems} items` + ].filter((value): value is string => value !== undefined) + case "Union": + return [`${field.alternatives.length} variants`] + case "Boolean": + case "Unsupported": + return [] + } +} + +const renderControl = ( + field: InputField, + name: string, + context: RenderContext, + path: ReadonlyArray +): Control => { switch (field._tag) { case "String": { const input = element("input", "input-control") @@ -326,9 +442,12 @@ const renderControl = (field: InputField, name: string): Control => { const row = element("div", "input-field") const heading = element("div", "input-field-heading") const label = element("label", "input-label", labelText(property.field, property.key)) + const identity = element("div", "input-field-identity") + identity.append(label, element("span", "input-field-type", fieldType(property.field))) const required = property.required ? element("span", "input-required", "required") : undefined let included: HTMLInputElement | undefined - const control = renderControl(property.field, `${name}.${property.key}`) + const propertyPath = [...path, property.key] + const control = renderControl(property.field, `${name}.${property.key}`, context, propertyPath) const labelled = control.element instanceof HTMLInputElement || control.element instanceof HTMLSelectElement ? control.element : control.element.querySelector(":scope > input, :scope > select") @@ -338,19 +457,20 @@ const renderControl = (field: InputField, name: string): Control => { label.htmlFor = id } if (property.required) { - if ( - control.element instanceof HTMLSelectElement || - (control.element instanceof HTMLInputElement && control.element.type !== "checkbox") - ) { + if (control.element instanceof HTMLSelectElement) { control.element.required = true + } else if (control.element instanceof HTMLInputElement) { + control.element.required = property.field._tag === "Number" || + (property.field._tag === "String" && + (property.field.format !== undefined || (property.field.minLength ?? 0) > 0)) } - heading.append(label, required!) + heading.append(identity, required!) } else { const optional = element("label", "input-optional") included = element("input") included.type = "checkbox" optional.append(included, element("span", undefined, "include")) - heading.append(label, optional) + heading.append(identity, optional) control.element.toggleAttribute("inert", true) control.element.classList.add("is-disabled") control.element.querySelectorAll( @@ -374,9 +494,19 @@ const renderControl = (field: InputField, name: string): Control => { ) control.element.disabled = !included!.checked }) } + const issues = element("div", "input-errors") + issues.hidden = true + context.issueTargets.set(JSON.stringify(propertyPath), issues) row.append(heading, control.element) + const constraints = fieldConstraints(property.field) + if (constraints.length > 0) { + const metadata = element("div", "input-constraints") + constraints.forEach((constraint) => metadata.append(element("span", undefined, constraint))) + row.append(metadata) + } const details = description(property.field) if (details !== undefined) row.append(details) + row.append(issues) group.append(row) controls.push({ key: property.key, included, control }) } @@ -397,25 +527,33 @@ const renderControl = (field: InputField, name: string): Control => { const group = element("fieldset", "input-array") group.append(element("legend", undefined, labelText(field, name))) const items = element("div", "input-array-items") - const controls: Array<{ readonly row: HTMLElement; readonly control: Control }> = [] + const controls: Array<{ + readonly row: HTMLElement + readonly control: Control + readonly remove: HTMLButtonElement + }> = [] const add = element("button", "input-array-add", "Add item") add.type = "button" + const refreshActions = (): void => { + add.disabled = field.maxItems !== undefined && controls.length >= field.maxItems + controls.forEach(({ remove }) => remove.disabled = controls.length <= field.minItems) + } const addItem = (): void => { if (field.maxItems !== undefined && controls.length >= field.maxItems) return const row = element("div", "input-array-item") - const control = renderControl(field.item, `${name}.${controls.length}`) + const control = renderControl(field.item, `${name}.${controls.length}`, context, [...path, controls.length]) const remove = element("button", "input-array-remove", "Remove") remove.type = "button" remove.addEventListener("click", () => { row.remove() const index = controls.findIndex((item) => item.row === row) if (index >= 0) controls.splice(index, 1) - add.disabled = false + refreshActions() }) row.append(control.element, remove) items.append(row) - controls.push({ row, control }) - add.disabled = field.maxItems !== undefined && controls.length >= field.maxItems + controls.push({ row, control, remove }) + refreshActions() } for (let index = 0; index < field.minItems; index++) addItem() add.addEventListener("click", addItem) @@ -434,7 +572,7 @@ const renderControl = (field: InputField, name: string): Control => { const body = element("div", "input-union-body") let selected = 0 const controls = field.alternatives.map((alternative, index) => { - const control = renderControl(alternative, `${name}.${index}`) + const control = renderControl(alternative, `${name}.${index}`, context, path) const option = element("option", undefined, labelText(alternative, `Option ${index + 1}`)) option.value = String(index) select.append(option) @@ -470,23 +608,62 @@ export const renderInputForm = ( } ): InputForm => { const form = element("form", "schema-form") + const formIssues = element("div", "input-errors input-errors-form") + formIssues.hidden = true + const context: RenderContext = { issueTargets: new Map() } const projected = projectInputSchema(schema) const visible = projected._tag === "Object" && options.omit !== undefined ? { ...projected, fields: projected.fields.filter(({ key }) => !options.omit!.includes(key)) } : projected - const control = renderControl(visible, options.name) - form.append(control.element) + const control = renderControl(visible, options.name, context, []) + form.append(formIssues, control.element) + const clearIssues = (): void => { + formIssues.replaceChildren() + formIssues.hidden = true + context.issueTargets.forEach((target) => { + target.replaceChildren() + target.hidden = true + }) + } + const setIssues = (issues: ReadonlyArray): void => { + clearIssues() + const grouped = new Map>() + for (const issue of issues) { + let target: HTMLElement = formIssues + for (let length = issue.path.length; length > 0; length--) { + const candidate = context.issueTargets.get(JSON.stringify(issue.path.slice(0, length))) + if (candidate !== undefined) { + target = candidate + break + } + } + const messages = grouped.get(target) ?? [] + if (!messages.includes(issue.message)) messages.push(issue.message) + grouped.set(target, messages) + } + grouped.forEach((messages, target) => { + messages.forEach((message) => target.append(element("div", undefined, message))) + target.hidden = false + }) + } return { element: form, hasFields: control.interactive, supported: control.supported, read: () => { + clearIssues() if (!form.reportValidity() || !control.supported) return { ok: false } const value = control.read() return { ok: true, value: isRecord(value) && options.fixed !== undefined ? { ...value, ...options.fixed } : value } + }, + clearIssues, + setIssues, + setPending: (pending) => { + form.toggleAttribute("inert", pending) + form.setAttribute("aria-busy", String(pending)) } } } diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts index 577dc14..5add2dd 100644 --- a/packages/devtools/src/internal/browser/planner-example.ts +++ b/packages/devtools/src/internal/browser/planner-example.ts @@ -8,9 +8,56 @@ class Working extends Schema.TaggedClass("PlannerWorking")("Working", { }) {} class Finished extends Schema.TaggedClass("PlannerFinished")("Finished", { job: Schema.String }) {} +const Owner = Schema.NonEmptyString.annotate({ + title: "Owner", + description: "A non-empty name carried into the initial Idle state." +}) +const Attempts = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 5 })).annotate({ + title: "Attempts", + description: "An integer between one and five." +}) +const Labels = Schema.Array(Schema.NonEmptyString).check(Schema.isLengthBetween(1, 3)).annotate({ + title: "Labels", + description: "One to three non-empty labels." +}) +const Route = Schema.Union([ + Schema.Literal("direct").annotate({ title: "Direct" }), + Schema.Struct({ + queue: Schema.NonEmptyString.annotate({ + title: "Queue", + description: "The queue used by the queued route." + }) + }).annotate({ title: "Queued" }) +]).annotate({ + title: "Route", + description: "Choose a literal direct route or provide a queue." +}) + class Begin extends Schema.TaggedClass("PlannerBegin")("Begin", { - job: Schema.String, - priority: Schema.Literals(["normal", "urgent"]) + job: Schema.NonEmptyString.annotate({ + title: "Job", + description: "A non-empty job name used as the final output." + }), + priority: Schema.Literals(["normal", "urgent"]).annotate({ + title: "Priority", + description: "Urgent jobs raise AutoFinish during the same macrostep." + }), + estimate: Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 100 })).annotate({ + title: "Estimate", + description: "A numeric estimate between zero and one hundred." + }), + approved: Schema.Boolean.annotate({ + title: "Approved", + description: "A boolean checkbox included in the event payload." + }), + notes: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(80)).annotate({ + title: "Notes", + description: "Optional text limited to eighty characters." + }) + ), + labels: Schema.optionalKey(Labels), + route: Schema.optionalKey(Route) }) {} class Cancel extends Schema.TaggedClass("PlannerCancel")("Cancel", { reason: Schema.String }) {} class AutoFinish extends Schema.TaggedClass("PlannerAutoFinish")("AutoFinish", {}) {} @@ -31,7 +78,36 @@ export const plannerMachine = Machine.make({ events: Events, internalEvents: InternalEvents, emittedEvents: Emissions, - input: Schema.Struct({ owner: Schema.String }), + input: Schema.Struct({ + owner: Owner, + attempts: Attempts, + notifications: Schema.Boolean.annotate({ + title: "Notifications", + description: "A required boolean with false as a valid value." + }), + mode: Schema.Literals(["guided", "automatic"]).annotate({ + title: "Mode", + description: "A fixed set of startup modes." + }), + note: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(40)).annotate({ + title: "Note", + description: "Optional startup text limited to forty characters." + }) + ), + labels: Schema.optionalKey(Labels), + preferences: Schema.optionalKey( + Schema.Struct({ + retries: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3 })).annotate({ + title: "Retries" + }), + dryRun: Schema.Boolean.annotate({ title: "Dry run" }) + }).annotate({ + title: "Preferences", + description: "An optional nested object." + }) + ) + }), initial: (to) => to.Idle().resolve(({ input, target }) => target.decoded(new Idle({ owner: input.owner }))) }).handle({ Idle: { diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css index fc3951e..44d4a67 100644 --- a/packages/devtools/src/internal/browser/styles.css +++ b/packages/devtools/src/internal/browser/styles.css @@ -816,6 +816,15 @@ button { gap: 12px; } +.input-field-identity, +.input-constraints { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + .input-label, .input-required, .input-optional, @@ -828,6 +837,28 @@ button { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.input-field-type, +.input-constraints span { + color: #8294ad; + font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.input-field-type { + padding: 2px 5px; + border-radius: 3px; + background: rgb(75 125 255 / 12%); +} + +.input-constraints { + margin-top: -1px; +} + +.input-constraints span + span::before { + margin-right: 6px; + color: #4f5762; + content: "·"; +} + .input-required { color: #737b86; } @@ -857,7 +888,8 @@ button { } .input-description, -.input-unsupported { +.input-unsupported, +.input-errors { margin: 0; font-size: 11px; line-height: 1.55; @@ -871,6 +903,16 @@ button { color: #d7a5a2; } +.input-errors { + color: #efaaa6; +} + +.input-errors-form { + padding: 8px 10px; + border: 1px solid rgb(224 89 84 / 30%); + background: rgb(224 89 84 / 8%); +} + .literal-control { color: #aeb6c1; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index 07de845..47140bf 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -11,7 +11,7 @@ import type { MachineDocument as VisualizationDocument, Transition as VisualizationTransition } from "../../MachineDocument.js" -import { renderInputForm } from "./input-form.js" +import { type InputForm, renderInputForm } from "./input-form.js" import { requestSimulation } from "./simulation-client.js" import { type EventInspection, @@ -202,6 +202,7 @@ export const renderVisualizer = ( let selectedFrame: SimulationFrame | undefined let simulation: SimulationReady | undefined let simulationPending = false + let activeInputForm: InputForm | undefined const activePaths = (): ReadonlyArray => simulation?.current.activePaths ?? model.activePaths const candidateEvents = (): ReadonlyArray => simulation?.current.candidateEvents ?? model.candidateEvents @@ -228,6 +229,7 @@ export const renderVisualizer = ( simulationButton.disabled = model.roots.length === 0 const renderEmptyInspector = (): void => { + activeInputForm = undefined inspector.replaceChildren() const summary = createElement("div", "inspector-empty") summary.append(createElement("span", "inspector-empty-kind", "Machine")) @@ -244,6 +246,7 @@ export const renderVisualizer = ( } const renderInspection = (inspection: StateInspection): void => { + activeInputForm = undefined inspector.replaceChildren() const header = createElement("header", "inspector-header") const breadcrumbs = createElement("nav", "breadcrumbs") @@ -305,6 +308,7 @@ export const renderVisualizer = ( } const renderEventInspection = (inspection: EventInspection): void => { + activeInputForm = undefined inspector.replaceChildren() const header = createElement("header", "inspector-header") const eyebrow = createElement("div", "inspector-eyebrow") @@ -334,6 +338,7 @@ export const renderVisualizer = ( fixed: { _tag: inspection.event }, omit: ["_tag"] }) + activeInputForm = input const send = createElement("button", "simulation-action", `Send ${inspection.event}`) send.type = "submit" send.disabled = simulationPending || !input.supported @@ -375,6 +380,7 @@ export const renderVisualizer = ( } const renderSimulationFailure = (failureDiagnostics: ReadonlyArray): void => { + activeInputForm = undefined inspector.replaceChildren() const header = createElement("header", "inspector-header") const eyebrow = createElement("div", "inspector-eyebrow") @@ -412,6 +418,7 @@ export const renderVisualizer = ( } const renderSimulationTrace = (frame: SimulationFrame): void => { + activeInputForm = undefined inspector.replaceChildren() const header = createElement("header", "inspector-header trace-header") const eyebrow = createElement("div", "inspector-eyebrow") @@ -497,6 +504,7 @@ export const renderVisualizer = ( } const renderStartSimulation = (): void => { + activeInputForm = undefined inspector.replaceChildren() const header = createElement("header", "inspector-header") const eyebrow = createElement("div", "inspector-eyebrow") @@ -512,6 +520,7 @@ export const renderVisualizer = ( return } const form = renderInputForm(input, { name: "Machine input" }) + activeInputForm = form const start = createElement("button", "simulation-action", "Start simulation") start.type = "submit" start.disabled = simulationPending || visualization.source === null || !form.supported @@ -546,12 +555,20 @@ export const renderVisualizer = ( async function runSimulation(request: SimulationRequest): Promise { if (simulationPending) return simulationPending = true + activeInputForm?.clearIssues() + activeInputForm?.setPending(true) simulationFeedback.textContent = "Planning in an isolated worker…" simulationFeedback.dataset.status = "pending" updateSimulationUi() try { const result = await requestSimulation(request) if (result._tag === "SimulationFailed") { + if (result.inputIssues.length > 0 && activeInputForm !== undefined) { + simulationFeedback.textContent = "Some input fields are invalid" + simulationFeedback.dataset.status = "error" + activeInputForm.setIssues(result.inputIssues) + return + } selectedFrame = undefined if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") selectedEvent = undefined @@ -561,6 +578,7 @@ export const renderVisualizer = ( return } simulation = result + activeInputForm = undefined if (selectedPath !== undefined) { nodes.get(selectedPath)?.classList.remove("is-selected") rows.get(selectedPath)?.setAttribute("aria-selected", "false") @@ -615,6 +633,7 @@ export const renderVisualizer = ( renderSimulationFailure([failure]) } finally { simulationPending = false + activeInputForm?.setPending(false) updateSimulationUi() } } @@ -877,7 +896,7 @@ export const renderVisualizer = ( } else if (selectedPath !== undefined) { const inspection = model.inspectState(selectedPath) if (inspection !== undefined) renderInspection(inspection) - } else if (selectedEvent !== undefined) { + } else if (selectedEvent !== undefined && activeInputForm === undefined) { renderEventInspection(model.inspectEvent(selectedEvent)) } } diff --git a/packages/devtools/src/internal/devServer.ts b/packages/devtools/src/internal/devServer.ts index b332c8b..0a68e8c 100644 --- a/packages/devtools/src/internal/devServer.ts +++ b/packages/devtools/src/internal/devServer.ts @@ -82,6 +82,7 @@ const staleSimulation = ( protocolVersion: DevToolsProtocol.protocolVersion, key: request.key, revision: request.revision, + inputIssues: [], diagnostics: [{ severity: "error", code: "simulation-stale", diff --git a/packages/devtools/src/internal/evaluationWorker.ts b/packages/devtools/src/internal/evaluationWorker.ts index 4a48368..42e88b4 100644 --- a/packages/devtools/src/internal/evaluationWorker.ts +++ b/packages/devtools/src/internal/evaluationWorker.ts @@ -2,6 +2,7 @@ import * as NodeWorkerRunner from "@effect/platform-node/NodeWorkerRunner" import { Machine } from "@typeonce/effect-machine" import * as Effect from "effect/Effect" import * as Schema from "effect/Schema" +import * as SchemaIssue from "effect/SchemaIssue" import * as WorkerRunner from "effect/unstable/workers/WorkerRunner" import { isAbsolute, relative, resolve } from "node:path" import { pathToFileURL } from "node:url" @@ -54,6 +55,7 @@ const diagnostic = ( }) const messageOf = (cause: unknown): string => { + if (Schema.isSchemaError(cause)) return `Invalid machine value: ${cause.message}` if (cause instanceof Error && cause.message.length > 0) return cause.message if (typeof cause === "object" && cause !== null && "cause" in cause && Schema.isSchemaError(cause.cause)) { const boundary = "boundary" in cause ? String(cause.boundary) : "value" @@ -210,6 +212,83 @@ const decodeSnapshot = Machine.decodeSnapshot as ( snapshot: unknown ) => Effect.Effect +const validateSchemaInput = ( + schema: Schema.Top, + input: unknown +): Effect.Effect => + schema.makeEffect(input as never, { parseOptions: { errors: "all" } }).pipe( + Effect.asVoid, + Effect.mapError((issue) => new Schema.SchemaError(issue)) + ) + +const isJsonSchemaRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const resolveJsonSchemaReference = ( + value: unknown, + definitions: Readonly> +): Record | undefined => { + if (!isJsonSchemaRecord(value)) return undefined + if (typeof value.$ref !== "string" || !value.$ref.startsWith("#/$defs/")) return value + const target = definitions[decodeURIComponent(value.$ref.slice("#/$defs/".length))] + return isJsonSchemaRecord(target) ? target : undefined +} + +const inputEventTags = (schema: Machine.Machine.TaggedSchema): ReadonlySet => { + const document = Schema.toJsonSchemaDocument(schema) + const root = resolveJsonSchemaReference(document.schema, document.definitions) ?? document.schema + const variants = isJsonSchemaRecord(root) && Array.isArray(root.anyOf) + ? root.anyOf + : isJsonSchemaRecord(root) && Array.isArray(root.oneOf) + ? root.oneOf + : [root] + const tags = new Set() + for (const variant of variants) { + const resolved = resolveJsonSchemaReference(variant, document.definitions) + if (resolved === undefined || !isJsonSchemaRecord(resolved.properties)) continue + const tag = resolveJsonSchemaReference(resolved.properties._tag, document.definitions) + if (tag === undefined) continue + if (typeof tag.const === "string" || typeof tag.const === "number") tags.add(String(tag.const)) + if (Array.isArray(tag.enum)) { + tag.enum.forEach((value) => { + if (typeof value === "string" || typeof value === "number") tags.add(String(value)) + }) + } + } + return tags +} + +const inputEventSchemas = Machine.inputEventSchemas as ( + machine: Machine.Machine.Any +) => ReadonlyArray + +const validateInitialInput = ( + machine: Machine.Machine.Any, + request: DevToolsProtocol.StartSimulation +): Effect.Effect => { + if (machine.input === undefined) { + return Object.hasOwn(request, "input") + ? Effect.fail(new Error("This machine does not accept startup input")) + : Effect.void + } + return validateSchemaInput(machine.input, request.input) +} + +const validateEventInput = ( + machine: Machine.Machine.Any, + event: unknown +): Effect.Effect => { + if (typeof event !== "object" || event === null || !("_tag" in event)) { + return Effect.fail(new Error("A public event requires a _tag discriminator")) + } + const tag = String(event._tag) + const schemas = inputEventSchemas(machine) + const schema = schemas.find((schema) => inputEventTags(schema).has(tag)) + return schema === undefined + ? Effect.fail(new Error(`This machine does not accept the public event ${tag}`)) + : validateSchemaInput(schema, event) +} + const configuration = Machine.configuration as ( machine: Machine.Machine.Any, snapshot: unknown @@ -303,9 +382,23 @@ const simulationDiagnostic = ( protocolVersion: DevToolsProtocol.protocolVersion, key: request.key, revision: request.revision, + inputIssues: inputIssuesOf(cause), diagnostics: [diagnostic(request.source.file, code, messageOf(cause))] }) +const inputIssuesOf = (cause: unknown): ReadonlyArray => { + const schemaError = Schema.isSchemaError(cause) + ? cause + : typeof cause === "object" && cause !== null && "cause" in cause && Schema.isSchemaError(cause.cause) + ? cause.cause + : undefined + if (schemaError === undefined) return [] + return SchemaIssue.makeFormatterStandardSchemaV1()(schemaError.issue).issues.map((issue) => ({ + path: issue.path?.map((part) => typeof part === "number" ? part : String(part)) ?? [], + message: issue.message + })) +} + const isProjectFile = (root: string, file: string): boolean => { const absoluteRoot = resolve(root) const absoluteFile = resolve(absoluteRoot, file) @@ -382,26 +475,29 @@ const simulate = ( return loadSimulationMachine(server, workerRequest).pipe( Effect.flatMap((machine) => { if (request._tag === "StartSimulation") { - const planned = Object.hasOwn(request, "input") - ? planInitial(machine, request.input) - : planInitial(machine) - return Effect.flatMap(planned, (result) => { - const before = result.startingState ?? result.state - const after = result.state - if (before === undefined || after === undefined) { - return Effect.fail(new Error("The initial planner did not return a state snapshot")) - } - return makeSimulationReady(request, machine, before, after, result) + return Effect.flatMap(validateInitialInput(machine, request), () => { + const planned = Object.hasOwn(request, "input") + ? planInitial(machine, request.input) + : planInitial(machine) + return Effect.flatMap(planned, (result) => { + const before = result.startingState ?? result.state + const after = result.state + if (before === undefined || after === undefined) { + return Effect.fail(new Error("The initial planner did not return a state snapshot")) + } + return makeSimulationReady(request, machine, before, after, result) + }) }) } - return Effect.flatMap( - decodeSnapshot(machine, request.snapshot), - (before) => - Effect.flatMap(plan(machine, before, simulationEvent(machine, request.event)), (result) => { - if (result.next === undefined) return Effect.fail(new Error("The planner did not return a next snapshot")) - return makeSimulationReady(request, machine, before, result.next, result) - }) - ) + return Effect.flatMap(validateEventInput(machine, request.event), () => + Effect.flatMap( + decodeSnapshot(machine, request.snapshot), + (before) => + Effect.flatMap(plan(machine, before, simulationEvent(machine, request.event)), (result) => { + if (result.next === undefined) return Effect.fail(new Error("The planner did not return a next snapshot")) + return makeSimulationReady(request, machine, before, result.next, result) + }) + )) }), Effect.catch((cause) => Effect.succeed(simulationDiagnostic(request, "simulation-planning-failed", cause))) ) diff --git a/packages/devtools/test/MachineDocument.test.ts b/packages/devtools/test/MachineDocument.test.ts index d635a79..d269008 100644 --- a/packages/devtools/test/MachineDocument.test.ts +++ b/packages/devtools/test/MachineDocument.test.ts @@ -25,13 +25,21 @@ describe("MachineDocument", () => { it("captures form schemas for machine and public event inputs", () => { const document = MachineDocument.make(plannerMachine) const begin = document.inputs.events.find(({ event }) => event === "Begin") + const machineSchema = document.inputs.machine?.schema as { + readonly properties?: Readonly> + readonly required?: ReadonlyArray + } | undefined - assert.deepStrictEqual(document.inputs.machine?.schema, { - type: "object", - properties: { owner: { type: "string" } }, - required: ["owner"], - additionalProperties: false - }) + assert.deepStrictEqual(Object.keys(machineSchema?.properties ?? {}), [ + "owner", + "attempts", + "notifications", + "mode", + "note", + "labels", + "preferences" + ]) + assert.deepStrictEqual(machineSchema?.required, ["owner", "attempts", "notifications", "mode"]) assert.deepStrictEqual(begin?.schema.schema, { $ref: "#/$defs/PlannerBeginEncoded" }) assert.deepStrictEqual(document.inputs.events.map(({ event }) => event), ["Begin", "Cancel"]) }) diff --git a/packages/devtools/test/ProjectInspector.test.ts b/packages/devtools/test/ProjectInspector.test.ts index fea5f10..ddee0e1 100644 --- a/packages/devtools/test/ProjectInspector.test.ts +++ b/packages/devtools/test/ProjectInspector.test.ts @@ -145,9 +145,57 @@ describe("ProjectInspector", () => { assert.strictEqual(failed._tag, "SimulationFailed") if (failed._tag === "SimulationFailed") { assert.strictEqual(failed.diagnostics[0]?.code, "simulation-planning-failed") + assert.deepStrictEqual(failed.inputIssues, []) } }).pipe(Effect.provide(ProjectInspector.layer))) + it.effect("returns authoritative input issues with field paths", () => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const source = { + file: "packages/devtools/src/internal/browser/planner-example.ts", + exportName: "plannerMachine" + } + const started = yield* inspector.simulate({ + _tag: "StartSimulation", + protocolVersion: DevToolsProtocol.protocolVersion, + key: "planner-input-validation", + revision: 0, + source, + input: { + owner: "Agent", + attempts: 2, + notifications: false, + mode: "guided" + } + }, { root: process.cwd() }) + if (started._tag !== "SimulationReady") return + + const failed = yield* inspector.simulate({ + _tag: "SendSimulationEvent", + protocolVersion: DevToolsProtocol.protocolVersion, + key: started.key, + revision: started.revision, + source, + step: started.step, + snapshot: started.snapshot, + event: { + _tag: "Begin", + job: "", + priority: "unsupported", + estimate: 101, + approved: true + } + }, { root: process.cwd() }) + + assert.strictEqual(failed._tag, "SimulationFailed") + if (failed._tag !== "SimulationFailed") return + assert.deepStrictEqual( + failed.inputIssues.map(({ path }) => path), + [["job"], ["priority"], ["estimate"]] + ) + }).pipe(Effect.provide(ProjectInspector.layer))) + it.effect("reports payload branches, raised events, emissions, commands, and output", () => Effect.gen(function*() { const inspector = yield* ProjectInspector.ProjectInspector @@ -161,7 +209,12 @@ describe("ProjectInspector", () => { key: "planner-example", revision: 3, source, - input: { owner: "Agent" } + input: { + owner: "Agent", + attempts: 2, + notifications: false, + mode: "guided" + } }, { root: process.cwd() }) assert.strictEqual(started._tag, "SimulationReady") if (started._tag !== "SimulationReady") return @@ -174,7 +227,13 @@ describe("ProjectInspector", () => { source, step: started.step, snapshot: started.snapshot, - event: { _tag: "Begin", job: "release", priority: "urgent" } + event: { + _tag: "Begin", + job: "release", + priority: "urgent", + estimate: 13, + approved: true + } }, { root: process.cwd() }) assert.strictEqual(planned._tag, "SimulationReady") diff --git a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts index 79e7c82..a11f392 100644 --- a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts +++ b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts @@ -120,28 +120,54 @@ describe("Interactive text visualization", () => { const beginSchema = document.inputs.events.find(({ event }) => event === "Begin")?.schema const begin = beginSchema === undefined ? undefined : projectInputSchema(beginSchema) - assert.deepStrictEqual(input, { - _tag: "Object", - title: undefined, - description: undefined, - fields: [{ - key: "owner", - required: true, - field: { - _tag: "String", - title: undefined, - description: undefined, - defaultValue: undefined, - format: undefined, - minLength: undefined, - maxLength: undefined, - pattern: undefined - } - }] - }) + assert.strictEqual(input?._tag, "Object") + if (input?._tag !== "Object") return + const inputFields = new Map(input.fields.map(({ field, key }) => [key, field])) + assert.deepStrictEqual(input.fields.map(({ key }) => key), [ + "owner", + "attempts", + "notifications", + "mode", + "note", + "labels", + "preferences" + ]) + assert.deepInclude(inputFields.get("owner"), { _tag: "String", title: "Owner", minLength: 1 }) + assert.deepInclude(inputFields.get("attempts"), { _tag: "Number", integer: true, minimum: 1, maximum: 5 }) + assert.strictEqual(inputFields.get("notifications")?._tag, "Boolean") + assert.deepInclude(inputFields.get("mode"), { _tag: "Enum", values: ["guided", "automatic"] }) + assert.deepInclude(inputFields.get("labels"), { _tag: "Array", minItems: 1, maxItems: 3 }) + assert.strictEqual(inputFields.get("preferences")?._tag, "Object") assert.strictEqual(begin?._tag, "Object") if (begin?._tag !== "Object") return - assert.deepStrictEqual(begin.fields.map(({ key }) => key), ["_tag", "job", "priority"]) + assert.deepStrictEqual(begin.fields.map(({ key }) => key), [ + "_tag", + "job", + "priority", + "estimate", + "approved", + "notes", + "labels", + "route" + ]) assert.strictEqual(begin.fields[2]?.field._tag, "Enum") + assert.deepInclude(begin.fields[3]?.field, { _tag: "Number", minimum: 0, maximum: 100 }) + }) + + it("treats Effect's non-finite number encoding as one numeric field", () => { + assert.deepInclude( + projectInputSchema({ + dialect: "draft-2020-12", + schema: { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } + ], + title: "Value" + }, + definitions: {} + }), + { _tag: "Number", title: "Value", integer: false } + ) }) })