diff --git a/.changeset/static-statechart-visualizer.md b/.changeset/static-statechart-visualizer.md new file mode 100644 index 0000000..7cc5e42 --- /dev/null +++ b/.changeset/static-statechart-visualizer.md @@ -0,0 +1,11 @@ +--- +"@typeonce/effect-machine-devtools": minor +--- + +Replace the text-tree topology pane with a statically laid-out statechart. State cards expose value fields and invocations, while routed transition edges and compound regions make the machine topology readable without a draggable canvas. Directional colors distinguish incoming from outgoing relationships, and conditional branches with the same source and target share one topology edge while retaining their full details in the inspector. Machine tabs sit above the full-viewport chart, selection remains visible independently from the on-demand floating inspector, and corner zoom and fit controls provide a whole-machine overview. + +`MachineDocument.State` now retains each state's projected value and output schemas. Consumers of serialized documents must accept `schemaVersion: 3` and the new `valueSchema` and `outputSchema` fields. + +Replace planner-backed browser simulation and `MachineSimulator` with the side-effect-free `MachineWalkthrough` module. A walkthrough derives its initial and active configurations entirely from `MachineDocument`, exposes each documented transition branch as an explicit choice, preserves parallel regions, records shallow and deep history, and retains an immutable timeline with cursor-based time travel. Runtime-resolved targets and first-use history remain visible but unavailable instead of executing callbacks or inventing results. + +The browser now presents public machine and event schemas as read-only contracts and uses transition edges for simulation. Targetless transitions render as clickable self-loops, runtime-resolved targets terminate at disabled dashed placeholders, and state nodes remain read-only. Direct choices advance immediately while ambiguous or unavailable branches appear in a compact anchored picker. The bottom dock is reserved for the time-travel timeline. The browser no longer asks for payload values or evaluates initializers, resolvers, guards, updates, automatic callbacks, or invoke outcomes. Migrate programmatic document exploration from `MachineSimulator.start` and `MachineSimulator.send` to `MachineWalkthrough.start`, `MachineWalkthrough.choices`, and `MachineWalkthrough.take`. diff --git a/packages/devtools/NOTICE b/packages/devtools/NOTICE index b49e70e..91495a8 100644 --- a/packages/devtools/NOTICE +++ b/packages/devtools/NOTICE @@ -1,3 +1,7 @@ Portions are adapted from the Effect project, which is distributed under the MIT License. See https://github.com/Effect-TS/effect and the source history for authorship and provenance. + +This product includes Eclipse Layout Kernel for JavaScript (elkjs), which is +distributed under the Eclipse Public License 2.0. See +https://github.com/kieler/elkjs for source and license information. diff --git a/packages/devtools/README.md b/packages/devtools/README.md index fb51dd6..821988d 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -1,6 +1,6 @@ # Effect Machine devtools -`@typeonce/effect-machine-devtools` scans a local project for exported Effect Machine `.handle(...)` results and serves a live text-tree visualizer. +`@typeonce/effect-machine-devtools` scans a local project for exported Effect Machine `.handle(...)` results and serves a live, statically laid-out statechart. The package is experimental and pre-1.0. Minor releases may change its command options, document schemas, and programmatic modules. @@ -62,26 +62,26 @@ Discovery parses source files without executing them. Evaluation then loads cand Run the devtools only against code you trust. The server has no authentication and binds to the loopback interface by default. Do not expose it on a public or untrusted network. -## Inspection and simulation +## Inspection and walkthroughs -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. +The visualizer shows topology as a read-only statechart with native horizontal and vertical scrolling, incremental zoom, and a fit-to-viewport overview. Machine tabs run across the top so the chart uses the rest of the viewport. States remain grouped inside their compound parents, while orthogonal routes connect each enabled transition without requiring a draggable canvas. State cards show projected value fields and invocations at a glance. A single click selects a state or transition, while a double click opens its dismissible inspector. State selection distinguishes incoming from outgoing relationships, and conditional branches with the same source and target share one topology edge while retaining their full details in the inspector. -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. +The machine document includes projected value and output schemas for every state, plus the public machine and event input contracts. The browser renders those contracts as read-only field metadata: names, projected types, required or optional status, descriptions, ranges, lengths, patterns, and literal or enum values. It never asks for payload values merely to explore a static document. -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. +Start a simulation to enter the document's captured configuration or its declared initial topology. Simulation mode turns transition edges into the control surface while state nodes remain read-only. Targetless transitions are rendered as self-loops, and runtime-resolved targets terminate at disabled dashed placeholders. Click an unambiguous available edge to advance directly; ambiguous branches open a compact picker at the click rather than guessing. Parallel regions stay active independently, compound states enter their declared initial child, and recorded shallow or deep history can be restored later in the same simulation. -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. +Conditional branches, declinable transitions, automatic triggers, and invoke outcomes are shown as explicit choices rather than guessed. A runtime-resolved target or first use of an unrecorded history target remains visible but unavailable. Public event contracts are shown beside their choices, but values are not fabricated because no value can change a document-only decision reliably. -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. +Every selected branch is retained in the immutable bottom timeline. Select an earlier step, then choose a different branch on the chart to truncate the old future and explore another path. The chart keeps candidate edges visible and reveals a new active configuration only when it falls outside the viewport. -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. +Simulations do not load project modules again, call resolvers or guards, apply state updates, run Effects, start invocations, deliver events, or commit commands. They are deliberately topology-only and side-effect-free. A file change remounts the latest document; restart the simulation to explore the new revision. ## Programmatic modules -The first release publishes three programmatic modules: +The package publishes three programmatic modules: -- `DevToolsProtocol` defines the versioned worker, browser, and planner-session messages. +- `DevToolsProtocol` defines the versioned discovery and browser registry messages. - `MachineDocument` defines and constructs the serializable inspection document. -- `MachineSimulator` provides the conservative, document-only simulator for consumers that cannot load project code. +- `MachineWalkthrough` provides immutable, document-only topology exploration with explicit choices, history, and time travel. 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/index.html b/packages/devtools/index.html index 9c802a9..d7f5711 100644 --- a/packages/devtools/index.html +++ b/packages/devtools/index.html @@ -3,8 +3,8 @@ - - Effect Machine · Text visualizer + + Effect Machine · Statechart
diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 274f001..9ace8d5 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -22,7 +22,7 @@ ".": "./src/index.ts", "./DevToolsProtocol": "./src/DevToolsProtocol.ts", "./MachineDocument": "./src/MachineDocument.ts", - "./MachineSimulator": "./src/MachineSimulator.ts", + "./MachineWalkthrough": "./src/MachineWalkthrough.ts", "./package.json": "./package.json", "./internal/*": null }, @@ -36,6 +36,7 @@ "@effect/platform-node": "4.0.0-rc.111", "@typeonce/effect-machine": "workspace:^", "chokidar": "4.0.3", + "elkjs": "0.11.1", "typescript": "6.0.3", "vite": "8.1.5" }, @@ -67,7 +68,7 @@ ".": "./dist/index.js", "./DevToolsProtocol": "./dist/DevToolsProtocol.js", "./MachineDocument": "./dist/MachineDocument.js", - "./MachineSimulator": "./dist/MachineSimulator.js", + "./MachineWalkthrough": "./dist/MachineWalkthrough.js", "./internal/*": null } }, diff --git a/packages/devtools/src/DevToolsProtocol.ts b/packages/devtools/src/DevToolsProtocol.ts index 12f5dcf..4ddc105 100644 --- a/packages/devtools/src/DevToolsProtocol.ts +++ b/packages/devtools/src/DevToolsProtocol.ts @@ -144,288 +144,3 @@ 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 - -/** - * 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. - * - * @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), - inputIssues: Schema.Array(InputIssue) -}) - -/** - * @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/MachineDocument.ts b/packages/devtools/src/MachineDocument.ts index 8e26da3..f495d63 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 = 2 as const +export const schemaVersion = 3 as const /** * Source module and export that produced a machine. @@ -65,6 +65,24 @@ export const Initial = Schema.Struct({ */ export type Initial = Schema.Schema.Type +/** + * Canonical JSON Schema retained for machine contracts. + * + * @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 + /** * @category schemas * @since 0.23.0 @@ -81,6 +99,8 @@ export const State = Schema.Struct({ parent: Schema.NullOr(Schema.String), children: Schema.Array(Schema.String), initial: Schema.NullOr(Schema.String), + valueSchema: Schema.NullOr(InputSchema), + outputSchema: Schema.NullOr(InputSchema), transitionIds: Schema.Array(Schema.String), activityIds: Schema.Array(Schema.String) }) @@ -216,24 +236,6 @@ 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. * diff --git a/packages/devtools/src/MachineSimulator.ts b/packages/devtools/src/MachineSimulator.ts deleted file mode 100644 index a4a569f..0000000 --- a/packages/devtools/src/MachineSimulator.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Side-effect-free, best-effort simulation over a machine document. - * - * @since 0.23.0 - */ -import * as Schema from "effect/Schema" -import * as internal from "./internal/machineSimulator.js" -import type * as MachineDocument from "./MachineDocument.js" - -/** - * Serializable state of a simulation session. - * - * @category schemas - * @since 0.23.0 - */ -export const Snapshot = Schema.Struct({ - step: Schema.Natural, - activePaths: Schema.Array(Schema.String), - candidateEvents: Schema.Array(Schema.String) -}) - -/** - * @category models - * @since 0.23.0 - */ -export type Snapshot = Schema.Schema.Type - -/** - * Opaque simulation state paired with its source document. - * - * @category models - * @since 0.23.0 - */ -export interface Session { - readonly document: MachineDocument.MachineDocument - readonly snapshot: Snapshot -} - -/** - * Runtime behavior deliberately skipped by a best-effort step. - * - * @category schemas - * @since 0.23.0 - */ -export const Note = Schema.Literals([ - "runtime-effects-skipped", - "state-updates-skipped", - "reentry-lifecycles-skipped", - "automatic-transitions-skipped" -]) - -/** - * @category models - * @since 0.23.0 - */ -export type Note = Schema.Schema.Type - -const ResultFields = { - event: Schema.String, - transitionIds: Schema.Array(Schema.String), - session: Snapshot -} - -/** - * A topologically deterministic step. - * - * @category schemas - * @since 0.23.0 - */ -export const Applied = Schema.Struct({ - ...ResultFields, - _tag: Schema.tag("Applied"), - notes: Schema.Array(Note) -}) - -/** - * @category models - * @since 0.23.0 - */ -export type Applied = Schema.Schema.Type - -/** - * An event with no registration in the current active configuration. - * - * @category schemas - * @since 0.23.0 - */ -export const Blocked = Schema.Struct({ - ...ResultFields, - _tag: Schema.tag("Blocked"), - reason: Schema.Literal("event-not-enabled") -}) - -/** - * @category models - * @since 0.23.0 - */ -export type Blocked = Schema.Schema.Type - -/** - * A step whose topology depends on runtime behavior the document cannot safely - * evaluate. - * - * @category schemas - * @since 0.23.0 - */ -export const Indeterminate = Schema.Struct({ - ...ResultFields, - _tag: Schema.tag("Indeterminate"), - reason: Schema.Literals([ - "multiple-transitions", - "declinable-transition", - "conditional-branches", - "history-target", - "choice-target", - "missing-target" - ]) -}) - -/** - * @category models - * @since 0.23.0 - */ -export type Indeterminate = Schema.Schema.Type - -/** - * @category schemas - * @since 0.23.0 - */ -export const StepResult = Schema.Union([Applied, Blocked, Indeterminate]) - -/** - * @category models - * @since 0.23.0 - */ -export type StepResult = Schema.Schema.Type - -/** - * Starts from the captured snapshot when present, otherwise from the static - * initial topology. - * - * @category constructors - * @since 0.23.0 - */ -export const start: (document: MachineDocument.MachineDocument) => Session = internal.start - -/** - * Sends an event without running user code. `Applied` means the active topology - * is statically known; its notes list runtime behavior that was skipped. - * - * @category combinators - * @since 0.23.0 - */ -export const send: (session: Session, event: string) => StepResult = internal.send diff --git a/packages/devtools/src/MachineWalkthrough.ts b/packages/devtools/src/MachineWalkthrough.ts new file mode 100644 index 0000000..eafc250 --- /dev/null +++ b/packages/devtools/src/MachineWalkthrough.ts @@ -0,0 +1,199 @@ +/** + * Side-effect-free topology walkthroughs over machine documents. + * + * @since 0.25.0 + */ +import { dual } from "effect/Function" +import * as Result from "effect/Result" +import * as Schema from "effect/Schema" +import * as internal from "./internal/machineWalkthrough.js" +import type * as MachineDocument from "./MachineDocument.js" + +const TypeId = internal.SessionTypeId + +/** + * An immutable walkthrough session. Its timeline can be inspected and its + * cursor can move without evaluating machine callbacks. + * + * @category models + * @since 0.25.0 + */ +export interface Session { + readonly [TypeId]: typeof TypeId +} + +/** + * Why taking a documented branch requires an explicit human decision. + * + * @category models + * @since 0.25.0 + */ +export type Decision = + | "conditional-branch" + | "declinable-transition" + | "automatic-trigger" + | "invoke-outcome" + +/** + * Why a documented branch cannot advance topology without runtime data. + * + * @category models + * @since 0.25.0 + */ +export type UnavailableReason = "history-unavailable" | "runtime-target" + +/** + * One branch that can be explored from the current active configuration. + * + * @category models + * @since 0.25.0 + */ +export interface Choice { + readonly id: string + readonly transitionId: string + readonly branchId: string + readonly branchIndex: number + readonly branchKey: string | null + readonly title: string | null + readonly source: string + readonly trigger: MachineDocument.Trigger + readonly target: string | null + readonly selection: MachineDocument.Selection + readonly updates: ReadonlyArray + readonly decisions: ReadonlyArray + readonly unavailableReason: UnavailableReason | null + readonly input: MachineDocument.InputSchema | null +} + +/** + * The active state paths at one point in a walkthrough. + * + * @category models + * @since 0.25.0 + */ +export interface Snapshot { + readonly activePaths: ReadonlyArray +} + +/** + * One initial or manually selected topology step. + * + * @category models + * @since 0.25.0 + */ +export interface Frame { + readonly step: number + readonly choice: Choice | null + readonly before: Snapshot + readonly after: Snapshot + readonly exitPaths: ReadonlyArray + readonly entryPaths: ReadonlyArray + readonly changed: boolean +} + +/** + * A requested choice is not available from the cursor's configuration. + * + * @category errors + * @since 0.25.0 + */ +export class ChoiceNotFound extends Schema.Error( + "@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceNotFound" +)({ + _tag: Schema.tag("ChoiceNotFound"), + choiceId: Schema.String +}) {} + +/** + * A choice depends on runtime information absent from the machine document. + * + * @category errors + * @since 0.25.0 + */ +export class ChoiceUnavailable extends Schema.Error( + "@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceUnavailable" +)({ + _tag: Schema.tag("ChoiceUnavailable"), + choiceId: Schema.String, + reason: Schema.Literals(["history-unavailable", "runtime-target"]) +}) {} + +/** + * A requested timeline step does not exist. + * + * @category errors + * @since 0.25.0 + */ +export class StepNotFound extends Schema.Error( + "@typeonce/effect-machine-devtools/MachineWalkthrough/StepNotFound" +)({ + _tag: Schema.tag("StepNotFound"), + step: Schema.Number +}) {} + +const api = { ChoiceNotFound, ChoiceUnavailable, StepNotFound } + +/** + * Starts at the document's captured snapshot when present, otherwise at its + * statically declared initial configuration. + * + * @category constructors + * @since 0.25.0 + */ +export const start: (document: MachineDocument.MachineDocument) => Session = internal.start + +/** + * Returns the frame selected by the session cursor. + * + * @category getters + * @since 0.25.0 + */ +export const current: (self: Session) => Frame = internal.current + +/** + * Returns every retained frame, including frames after the current cursor. + * + * @category getters + * @since 0.25.0 + */ +export const timeline: (self: Session) => ReadonlyArray = internal.timeline + +/** + * Returns the current zero-based timeline position. + * + * @category getters + * @since 0.25.0 + */ +export const cursor: (self: Session) => number = internal.cursor + +/** + * Lists documented branches whose sources are active at the current cursor. + * Runtime-dependent choices remain visible with an unavailable reason. + * + * @category getters + * @since 0.25.0 + */ +export const choices: (self: Session) => ReadonlyArray = internal.choices + +/** + * Takes one documented branch. Taking a branch from the past truncates the + * future timeline before appending the new frame. + * + * @category combinators + * @since 0.25.0 + */ +export const take: { + (choiceId: string): (self: Session) => Result.Result + (self: Session, choiceId: string): Result.Result +} = dual(2, internal.take(api)) + +/** + * Moves the cursor to an existing frame without changing the timeline. + * + * @category combinators + * @since 0.25.0 + */ +export const seek: { + (step: number): (self: Session) => Result.Result + (self: Session, step: number): Result.Result +} = dual(2, internal.seek(api)) diff --git a/packages/devtools/src/ProjectInspector.ts b/packages/devtools/src/ProjectInspector.ts index 2697d17..933b2ee 100644 --- a/packages/devtools/src/ProjectInspector.ts +++ b/packages/devtools/src/ProjectInspector.ts @@ -92,11 +92,6 @@ 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/index.ts b/packages/devtools/src/index.ts index 91df872..d72bb13 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -15,6 +15,6 @@ export * as DevToolsProtocol from "./DevToolsProtocol.js" export * as MachineDocument from "./MachineDocument.js" /** - * @since 0.23.0 + * @since 0.25.0 */ -export * as MachineSimulator from "./MachineSimulator.js" +export * as MachineWalkthrough from "./MachineWalkthrough.js" diff --git a/packages/devtools/src/internal/browser/chart-layout.ts b/packages/devtools/src/internal/browser/chart-layout.ts new file mode 100644 index 0000000..f44d25f --- /dev/null +++ b/packages/devtools/src/internal/browser/chart-layout.ts @@ -0,0 +1,433 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import type { + ELK as ElkApi, + ELKConstructorArguments, + ElkExtendedEdge, + ElkNode, + ElkPoint, + ElkPort +} from "elkjs/lib/elk-api.js" +import ELKBundle from "elkjs/lib/elk.bundled.js" +import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js" + +export const maxVisibleFields = 4 +export const maxVisibleActivities = 3 + +export interface ChartPoint { + readonly x: number + readonly y: number +} + +export interface LaidOutChartNode { + readonly node: ChartNode + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly headerHeight: number +} + +export interface LaidOutChartInitial { + readonly initial: ChartInitial + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +export interface LaidOutChartRuntimeTarget { + readonly target: ChartRuntimeTarget + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +export interface LaidOutChartTransition { + readonly kind: "transition" + readonly edge: ChartEdge + readonly points: ReadonlyArray + readonly label: ChartPoint + readonly labelWidth: number + readonly labelHeight: number +} + +export interface LaidOutChartInitialEdge { + readonly kind: "initial" + readonly initial: ChartInitial + readonly points: ReadonlyArray +} + +export interface LaidOutChart { + readonly width: number + readonly height: number + readonly nodes: ReadonlyArray + readonly initials: ReadonlyArray + readonly runtimeTargets: ReadonlyArray + readonly edges: ReadonlyArray +} + +export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{ + readonly cause: unknown +}> {} + +interface NodeMetric { + readonly width: number + readonly height: number + readonly headerHeight: number +} + +const sectionHeight = (length: number, limit: number): number => { + if (length === 0) return 0 + const visible = Math.min(length, limit) + return 24 + visible * 22 + (length > limit ? 18 : 0) +} + +const nodeMetric = (node: ChartNode): NodeMetric => { + const headerHeight = Math.max( + 78, + 76 + + sectionHeight(node.fields.length, maxVisibleFields) + + sectionHeight(node.activities.length, maxVisibleActivities) + ) + const width = node.type === "choice" || node.type === "history" ? 176 : node.children.length > 0 ? 340 : 276 + return { + width, + height: node.children.length === 0 ? headerHeight : Math.max(240, headerHeight + 104), + headerHeight + } +} + +const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi +const elk = new ELK() + +const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source` +const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target` +const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}` +const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target` +const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}` +const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target` + +const portsByState = (model: ChartModel): ReadonlyMap> => { + const ports = new Map>() + const add = (path: string, id: string, side: "EAST" | "WEST"): void => { + const statePorts = ports.get(path) ?? [] + statePorts.push({ + id, + width: 6, + height: 6, + layoutOptions: { + "elk.port.side": side, + "elk.port.index": String(statePorts.length) + } + }) + ports.set(path, statePorts) + } + + for (const edge of model.edges) { + add(edge.source, sourcePortId(edge), "EAST") + if (edge.kind === "target" && edge.target !== null) { + add(edge.target, targetPortId(edge), "WEST") + } else if (edge.kind === "targetless") { + add(edge.source, targetPortId(edge), "EAST") + } + } + for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST") + return ports +} + +const labelMetric = (label: string): { readonly width: number; readonly height: number } => ({ + width: Math.min(230, Math.max(72, label.length * 7 + 20)), + height: 26 +}) + +const makeGraph = (model: ChartModel): ElkNode => { + const nodesByParent = new Map>() + for (const node of model.nodes) { + const siblings = nodesByParent.get(node.parent) ?? [] + siblings.push(node) + nodesByParent.set(node.parent, siblings) + } + const initialsByParent = new Map>() + for (const initial of model.initials) { + const siblings = initialsByParent.get(initial.parent) ?? [] + siblings.push(initial) + initialsByParent.set(initial.parent, siblings) + } + const runtimeTargetsByParent = new Map>() + for (const target of model.runtimeTargets) { + const siblings = runtimeTargetsByParent.get(target.parent) ?? [] + siblings.push(target) + runtimeTargetsByParent.set(target.parent, siblings) + } + const ports = portsByState(model) + + const children = (parent: string | null): Array => [ + ...(initialsByParent.get(parent) ?? []).map((initial): ElkNode => ({ + id: initialNodeId(initial), + width: 14, + height: 14 + })), + ...(nodesByParent.get(parent) ?? []).map((node): ElkNode => { + const metric = nodeMetric(node) + const descendants = children(node.path) + const common = { + id: node.path, + ports: [...ports.get(node.path) ?? []] + } + if (descendants.length === 0) { + return { + ...common, + width: metric.width, + height: metric.height, + layoutOptions: { + "elk.portConstraints": "FIXED_ORDER", + "elk.spacing.portPort": "22" + } + } + } + return { + ...common, + children: descendants, + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=28,right=28]`, + "elk.nodeSize.constraints": "MINIMUM_SIZE", + "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`, + "elk.portConstraints": "FIXED_ORDER", + "elk.spacing.portPort": "22", + "elk.spacing.nodeNode": "44", + "elk.layered.spacing.nodeNodeBetweenLayers": "108" + } + } + }), + ...(runtimeTargetsByParent.get(parent) ?? []).map((target): ElkNode => ({ + id: runtimeNodeId(target), + width: 118, + height: 34, + ports: [{ + id: runtimeTargetPortId(target), + width: 6, + height: 6, + layoutOptions: { "elk.port.side": "WEST" } + }], + layoutOptions: { "elk.portConstraints": "FIXED_SIDE" } + })) + ] + + return { + id: "chart-root", + children: children(null), + edges: [ + ...model.edges.map((edge): ElkExtendedEdge => { + const label = labelMetric(edge.label) + return { + id: edge.id, + sources: [sourcePortId(edge)], + targets: [targetPortId(edge)], + labels: [{ text: edge.label, width: label.width, height: label.height }] + } + }), + ...model.initials.map((initial): ElkExtendedEdge => ({ + id: initial.id, + sources: [initialNodeId(initial)], + targets: [initialTargetPortId(initial)] + })) + ], + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.hierarchyHandling": "INCLUDE_CHILDREN", + "elk.edgeRouting": "ORTHOGONAL", + "elk.padding": "[top=44,left=44,bottom=44,right=44]", + "elk.spacing.nodeNode": "64", + "elk.layered.spacing.nodeNodeBetweenLayers": "148", + "elk.layered.spacing.edgeNodeBetweenLayers": "42", + "elk.layered.spacing.edgeEdgeBetweenLayers": "26", + "elk.spacing.edgeNode": "28", + "elk.spacing.edgeEdge": "20", + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.layered.crossingMinimization.greedySwitchHierarchical.type": "TWO_SIDED", + "elk.layered.nodePlacement.favorStraightEdges": "true", + "elk.layered.mergeHierarchyEdges": "false", + "elk.layered.thoroughness": "12", + "elk.randomSeed": "1" + } + } +} + +const add = (left: ChartPoint, right: ChartPoint): ChartPoint => ({ + x: left.x + right.x, + y: left.y + right.y +}) + +const compactPoints = (points: ReadonlyArray): ReadonlyArray => { + const result: Array = [] + for (const point of points) { + const previous = result.at(-1) + if (previous !== undefined && previous.x === point.x && previous.y === point.y) continue + const beforePrevious = result.at(-2) + if ( + beforePrevious !== undefined && previous !== undefined && + (beforePrevious.x === previous.x && previous.x === point.x || + beforePrevious.y === previous.y && previous.y === point.y) + ) { + result[result.length - 1] = point + } else { + result.push(point) + } + } + return result +} + +const edgePoints = (edge: ElkExtendedEdge, offset: ChartPoint): ReadonlyArray | undefined => { + const sections = edge.sections + if (sections === undefined || sections.length === 0) return undefined + const byId = new Map(sections.map((section) => [section.id, section])) + let section = + sections.find((candidate) => + candidate.incomingShape !== undefined || candidate.incomingSections === undefined || + candidate.incomingSections.length === 0 + ) ?? sections[0] + const points: Array = [] + const visited = new Set() + while (section !== undefined && !visited.has(section.id)) { + visited.add(section.id) + if (points.length === 0) points.push(section.startPoint) + points.push(...(section.bendPoints ?? []), section.endPoint) + const next = section.outgoingSections?.[0] + section = next === undefined ? undefined : byId.get(next) + } + return points.length < 2 ? undefined : compactPoints(points.map((point) => add(point, offset))) +} + +const midpoint = (points: ReadonlyArray): ChartPoint => { + const lengths = points.slice(1).map((point, index) => { + const previous = points[index]! + return Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y) + }) + const total = lengths.reduce((sum, length) => sum + length, 0) + let remaining = total / 2 + for (let index = 0; index < lengths.length; index++) { + const length = lengths[index]! + const start = points[index]! + const end = points[index + 1]! + if (remaining <= length) { + const ratio = length === 0 ? 0 : remaining / length + return { + x: start.x + (end.x - start.x) * ratio, + y: start.y + (end.y - start.y) * ratio + } + } + remaining -= length + } + return points.at(-1)! +} + +const expandTargetlessLoop = (points: ReadonlyArray): ReadonlyArray => { + const start = points[0] + const end = points.at(-1) + if (start === undefined || end === undefined) return points + const outerX = Math.max(...points.map(({ x }) => x)) + 30 + return compactPoints([ + start, + { x: outerX, y: start.y }, + { x: outerX, y: end.y }, + end + ]) +} + +const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => { + const chartNodes = new Map(model.nodes.map((node) => [node.path, node])) + const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial])) + const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target])) + const offsets = new Map([[graph.id, { x: 0, y: 0 }]]) + const nodes: Array = [] + const initials: Array = [] + const runtimeTargets: Array = [] + + const visit = (node: ElkNode, parentOffset: ChartPoint): void => { + const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 }) + offsets.set(node.id, absolute) + const chartNode = chartNodes.get(node.id) + if (chartNode !== undefined) { + const metric = nodeMetric(chartNode) + nodes.push({ + node: chartNode, + x: absolute.x, + y: absolute.y, + width: node.width ?? metric.width, + height: node.height ?? metric.height, + headerHeight: metric.headerHeight + }) + } + const initial = chartInitials.get(node.id) + if (initial !== undefined) { + initials.push({ + initial, + x: absolute.x, + y: absolute.y, + width: node.width ?? 14, + height: node.height ?? 14 + }) + } + const runtimeTarget = chartRuntimeTargets.get(node.id) + if (runtimeTarget !== undefined) { + runtimeTargets.push({ + target: runtimeTarget, + x: absolute.x, + y: absolute.y, + width: node.width ?? 118, + height: node.height ?? 34 + }) + } + for (const child of node.children ?? []) visit(child, absolute) + } + for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 }) + + const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge])) + const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial])) + const edges = (graph.edges ?? []).flatMap((edge): ReadonlyArray => { + const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 } + const points = edgePoints(edge, offset) + if (points === undefined) return [] + const chartEdge = chartEdges.get(edge.id) + if (chartEdge !== undefined) { + const metric = labelMetric(chartEdge.label) + const label = edge.labels?.[0] + const transitionPoints = chartEdge.kind === "targetless" ? expandTargetlessLoop(points) : points + return [{ + kind: "transition", + edge: chartEdge, + points: transitionPoints, + label: label?.x === undefined || label.y === undefined + ? midpoint(transitionPoints) + : add(offset, { + x: label.x + (label.width ?? metric.width) / 2, + y: label.y + (label.height ?? metric.height) / 2 + }), + labelWidth: label?.width ?? metric.width, + labelHeight: label?.height ?? metric.height + }] + } + const initial = initialEdges.get(edge.id) + return initial === undefined ? [] : [{ kind: "initial", initial, points }] + }) + + return { + width: Math.max(360, graph.width ?? 0), + height: Math.max(280, graph.height ?? 0), + nodes, + initials, + runtimeTargets, + edges + } +} + +export const layoutChart = (model: ChartModel): Effect.Effect => + Effect.tryPromise({ + try: () => elk.layout(makeGraph(model)), + catch: (cause) => new ChartLayoutError({ cause }) + }).pipe(Effect.map((graph) => collectLayout(model, graph))) diff --git a/packages/devtools/src/internal/browser/chart-model.ts b/packages/devtools/src/internal/browser/chart-model.ts new file mode 100644 index 0000000..d669df0 --- /dev/null +++ b/packages/devtools/src/internal/browser/chart-model.ts @@ -0,0 +1,234 @@ +import type { + Activity as VisualizationActivity, + Branch as VisualizationBranch, + MachineDocument as VisualizationDocument, + State as VisualizationState, + Transition as VisualizationTransition +} from "../../MachineDocument.js" +import { type InputField, projectInputSchema } from "./input-form.js" +import { stateLabel, triggerLabel } from "./visualizer-model.js" + +export interface ChartField { + readonly key: string + readonly label: string + readonly type: string + readonly required: boolean +} + +export interface ChartActivity { + readonly id: string + readonly kind: VisualizationActivity["type"] + readonly label: string +} + +export interface ChartNode { + readonly path: string + readonly label: string + readonly type: VisualizationState["type"] + readonly parent: string | null + readonly children: ReadonlyArray + readonly active: boolean + readonly initial: boolean + readonly fields: ReadonlyArray + readonly activities: ReadonlyArray +} + +export interface ChartEdge { + readonly id: string + readonly transitionId: string + readonly branchIds: ReadonlyArray + readonly kind: "target" | "targetless" | "runtime" + readonly source: string + readonly target: string | null + readonly label: string + readonly trigger: VisualizationTransition["trigger"] + readonly reenter: boolean + readonly acceptance: VisualizationTransition["acceptance"] +} + +export interface ChartRuntimeTarget { + readonly id: string + readonly edgeId: string + readonly parent: string | null + readonly label: string +} + +export interface ChartInitial { + readonly id: string + readonly target: string + readonly parent: string | null +} + +export interface ChartModel { + readonly machineId: string + readonly roots: ReadonlyArray + readonly nodes: ReadonlyArray + readonly edges: ReadonlyArray + readonly runtimeTargets: ReadonlyArray + readonly initials: ReadonlyArray +} + +const literalType = (value: string | number | boolean | null): string => + typeof value === "string" ? JSON.stringify(value) : String(value) + +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 field.values.map(literalType).join(" | ") + case "Literal": + return literalType(field.value) + case "Object": + return "object" + case "Array": + return `${fieldType(field.item)}[]` + case "Union": + return field.alternatives.map(fieldType).join(" | ") + case "Unsupported": + return "unknown" + } +} + +const stateFields = (state: VisualizationState): ReadonlyArray => { + if (state.valueSchema === null) return [] + const projected = projectInputSchema(state.valueSchema) + if (projected._tag !== "Object") { + return [{ + key: "value", + label: projected.title ?? "value", + type: fieldType(projected), + required: true + }] + } + return projected.fields + .filter(({ key }) => key !== "_tag") + .map(({ field, key, required }) => ({ + key, + label: field.title ?? key, + type: fieldType(field), + required + })) +} + +const activityLabel = (activity: VisualizationActivity): string => { + switch (activity.type) { + case "machine": + return `${activity.lifecycleId} → ${activity.child.machineId ?? activity.child.id}` + case "timer": + return `${activity.lifecycleId} · ${activity.duration}` + case "process": + case "effect": + case "stream": + return activity.lifecycleId + } +} + +const transitionLabel = (transition: VisualizationTransition, branch: VisualizationBranch): string => { + const trigger = triggerLabel(transition) + return branch.type === "branch" ? `${trigger} · ${branch.title}` : trigger +} + +interface EdgeGroup { + readonly kind: ChartEdge["kind"] + readonly target: string | null + readonly branches: Array +} + +const edgeGroup = ( + branch: VisualizationBranch, + states: ReadonlyMap +): { readonly key: string; readonly kind: ChartEdge["kind"]; readonly target: string | null } => { + if (branch.target !== null && states.has(branch.target)) { + return { key: `target:${branch.target}`, kind: "target", target: branch.target } + } + if (branch.selection.kind === "none" || branch.selection.kind === "update") { + return { key: "targetless", kind: "targetless", target: null } + } + return { key: "runtime", kind: "runtime", target: null } +} + +export const makeChartModel = (document: VisualizationDocument): ChartModel => { + const active = new Set(document.snapshot?.activePaths ?? []) + const initialPaths = new Set([document.initial.target]) + const activities = new Map(document.activities.map((activity) => [activity.id, activity])) + const states = new Map(document.states.map((state) => [state.path, state])) + + for (const state of document.states) { + if (state.initial !== null) initialPaths.add(state.initial) + } + + const nodes = document.states.map((state): ChartNode => ({ + path: state.path, + label: stateLabel(state), + type: state.type, + parent: state.parent, + children: [...state.children], + active: active.has(state.path), + initial: initialPaths.has(state.path), + fields: stateFields(state), + activities: state.activityIds.flatMap((id): ReadonlyArray => { + const activity = activities.get(id) + return activity === undefined ? [] : [{ id, kind: activity.type, label: activityLabel(activity) }] + }) + })) + + const edges = document.transitions.flatMap((transition): ReadonlyArray => { + if (!states.has(transition.source)) return [] + const groups = new Map() + for (const branch of transition.branches) { + const group = edgeGroup(branch, states) + const current = groups.get(group.key) + if (current === undefined) { + groups.set(group.key, { kind: group.kind, target: group.target, branches: [branch] }) + } else { + current.branches.push(branch) + } + } + return [...groups].map(([key, { branches, kind, target }]): ChartEdge => ({ + id: branches.length === 1 ? branches[0]!.id : `${transition.id}:${key}`, + transitionId: transition.id, + branchIds: branches.map(({ id }) => id), + kind, + source: transition.source, + target, + label: branches.length === 1 + ? transitionLabel(transition, branches[0]!) + : `${triggerLabel(transition)} · ${branches.length} branches`, + trigger: transition.trigger, + reenter: transition.reenter, + acceptance: transition.acceptance + })) + }) + + const runtimeTargets = edges.flatMap((edge): ReadonlyArray => { + if (edge.kind !== "runtime") return [] + const source = states.get(edge.source) + return source === undefined + ? [] + : [{ + id: `runtime:${edge.id}`, + edgeId: edge.id, + parent: source.parent, + label: "runtime target" + }] + }) + + const initials = [...initialPaths].flatMap((target): ReadonlyArray => { + const state = states.get(target) + return state === undefined ? [] : [{ id: `initial:${target}`, target, parent: state.parent }] + }) + + return { + machineId: document.machineId, + roots: [...document.roots], + nodes, + edges, + runtimeTargets, + initials + } +} diff --git a/packages/devtools/src/internal/browser/chart-renderer.ts b/packages/devtools/src/internal/browser/chart-renderer.ts new file mode 100644 index 0000000..b0b5e70 --- /dev/null +++ b/packages/devtools/src/internal/browser/chart-renderer.ts @@ -0,0 +1,661 @@ +import * as Effect from "effect/Effect" +import type { MachineDocument as VisualizationDocument } from "../../MachineDocument.js" +import { + type ChartLayoutError, + type ChartPoint, + type LaidOutChart, + type LaidOutChartNode, + layoutChart, + maxVisibleActivities, + maxVisibleFields +} from "./chart-layout.js" +import { makeChartModel } from "./chart-model.js" + +export interface ChartHandlers { + readonly selectState: (path: string, anchor: ChartInteractionAnchor) => void + readonly openStateDetails: (path: string) => void + readonly selectTransition: ( + transitionId: string, + branchIds: ReadonlyArray, + anchor: ChartInteractionAnchor + ) => void + readonly openTransitionDetails: (transitionId: string) => void + readonly clearSelection: () => void + readonly zoomChanged: (zoom: number) => void +} + +export interface ChartPresentation { + readonly simulationMode: boolean + readonly activePaths: ReadonlyArray + readonly selectedState: string | null + readonly selectedTransition: string | null + readonly fromPaths: ReadonlyArray + readonly toPaths: ReadonlyArray + readonly incomingTransitionIds: ReadonlyArray + readonly outgoingTransitionIds: ReadonlyArray + readonly availableBranchIds: ReadonlyArray + readonly unavailableBranchIds: ReadonlyArray +} + +export interface ChartView { + readonly update: (presentation: ChartPresentation) => void + readonly focusState: (path: string) => void + readonly revealState: (path: string) => void + readonly revealStates: (paths: ReadonlyArray) => void + readonly getZoom: () => number + readonly setZoom: (zoom: number, anchor?: ChartZoomAnchor) => number + readonly fit: () => number +} + +export interface ChartZoomAnchor { + readonly x: number + readonly y: number +} + +export interface ChartInteractionAnchor { + readonly x: number + readonly y: number +} + +export const minimumChartZoom = 0.2 +export const maximumChartZoom = 1.6 +export const chartPanThreshold = 5 + +const clampZoom = (zoom: number): number => Math.min(maximumChartZoom, Math.max(minimumChartZoom, zoom)) + +export const chartZoomScrollPosition = ( + currentZoom: number, + nextZoom: number, + scrollLeft: number, + scrollTop: number, + anchor: ChartZoomAnchor +): ChartZoomAnchor => ({ + x: Math.max(0, (scrollLeft + anchor.x) / currentZoom * nextZoom - anchor.x), + y: Math.max(0, (scrollTop + anchor.y) / currentZoom * nextZoom - anchor.y) +}) + +export const chartWheelZoom = ( + currentZoom: number, + deltaY: number, + deltaMode: number, + viewportHeight: number +): number => { + const normalizedDelta = deltaY * (deltaMode === 1 ? 16 : deltaMode === 2 ? viewportHeight : 1) + return clampZoom(currentZoom * Math.exp(-normalizedDelta * 0.0025)) +} + +export const isChartPan = ( + start: ChartZoomAnchor, + current: ChartZoomAnchor, + threshold = chartPanThreshold +): boolean => Math.hypot(current.x - start.x, current.y - start.y) >= threshold + +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 svgElement = ( + tag: Tag, + className?: string +): SVGElementTagNameMap[Tag] => { + const node = document.createElementNS("http://www.w3.org/2000/svg", tag) + if (className !== undefined) node.setAttribute("class", className) + return node +} + +const position = ( + target: HTMLElement, + bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number } +): void => { + target.style.left = `${bounds.x}px` + target.style.top = `${bounds.y}px` + target.style.width = `${bounds.width}px` + target.style.height = `${bounds.height}px` +} + +const statusLabel = (active: boolean, initial: boolean): string => { + if (active && initial) return "active, initial state" + if (active) return "active" + if (initial) return "initial state" + return "inactive" +} + +const status = (active: boolean, initial: boolean): HTMLSpanElement => { + const dot = element( + "span", + `chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}` + ) + dot.dataset.initial = String(initial) + dot.setAttribute("aria-label", statusLabel(active, initial)) + return dot +} + +const more = (count: number): HTMLElement => element("div", "chart-more", `+${count} more`) + +const stateContent = (layout: LaidOutChartNode, stateStatus: HTMLElement): DocumentFragment => { + const fragment = document.createDocumentFragment() + const heading = element("div", "chart-state-heading") + const identity = element("div", "chart-state-identity") + identity.append(stateStatus, element("strong", "chart-state-name", layout.node.label)) + heading.append(identity) + fragment.append(heading) + + if (layout.node.fields.length > 0) { + const fields = element("div", "chart-state-section") + fields.append(element("div", "chart-section-label", "value")) + layout.node.fields.slice(0, maxVisibleFields).forEach((field) => { + const row = element("div", "chart-field-row") + row.append( + element("span", "chart-field-name", `${field.label}${field.required ? "" : "?"}`), + element("span", "chart-field-type", field.type) + ) + fields.append(row) + }) + if (layout.node.fields.length > maxVisibleFields) { + fields.append(more(layout.node.fields.length - maxVisibleFields)) + } + fragment.append(fields) + } + + if (layout.node.activities.length > 0) { + const activities = element("div", "chart-state-section") + activities.append(element("div", "chart-section-label", "invokes")) + layout.node.activities.slice(0, maxVisibleActivities).forEach((activity) => { + const row = element("div", "chart-activity-row") + row.append( + element("span", "chart-activity-kind", activity.kind), + element("span", "chart-activity-name", activity.label) + ) + activities.append(row) + }) + if (layout.node.activities.length > maxVisibleActivities) { + activities.append(more(layout.node.activities.length - maxVisibleActivities)) + } + fragment.append(activities) + } + + return fragment +} + +const pathData = (points: ReadonlyArray): string => { + const first = points[0] + if (first === undefined) return "" + return points.slice(1).reduce((path, point) => `${path} L ${point.x} ${point.y}`, `M ${first.x} ${first.y}`) +} + +const setStateClass = ( + elements: ReadonlyMap>, + paths: ReadonlyArray, + className: string +): void => { + for (const path of paths) elements.get(path)?.forEach((node) => node.classList.add(className)) +} + +const render = ( + host: HTMLElement, + layout: LaidOutChart, + handlers: ChartHandlers +): ChartView => { + const viewport = element("div", "chart-viewport") + const stage = element("div", "chart-stage") + const canvas = element("div", "chart-canvas") + stage.style.width = `${layout.width}px` + stage.style.height = `${layout.height}px` + canvas.style.width = `${layout.width}px` + canvas.style.height = `${layout.height}px` + const regions = element("div", "chart-regions") + const svg = svgElement("svg", "chart-edges") + svg.setAttribute("width", String(layout.width)) + svg.setAttribute("height", String(layout.height)) + svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`) + svg.setAttribute("aria-hidden", "true") + const definitions = svgElement("defs") + const marker = svgElement("marker") + marker.id = "chart-arrow" + marker.setAttribute("viewBox", "0 0 10 10") + marker.setAttribute("refX", "9") + marker.setAttribute("refY", "5") + marker.setAttribute("markerWidth", "7") + marker.setAttribute("markerHeight", "7") + marker.setAttribute("orient", "auto-start-reverse") + const arrow = svgElement("path") + arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z") + marker.append(arrow) + definitions.append(marker) + svg.append(definitions) + const nodesLayer = element("div", "chart-nodes") + const labelsLayer = element("div", "chart-labels") + canvas.append(regions, svg, nodesLayer, labelsLayer) + stage.append(canvas) + viewport.append(stage) + host.replaceChildren(viewport) + + let zoom = 1 + let pan: { + readonly pointerId: number + readonly start: ChartZoomAnchor + readonly scrollLeft: number + readonly scrollTop: number + dragging: boolean + } | undefined + let suppressClick = false + let suppressDoubleClickUntil = 0 + + const stateElements = new Map>() + const stateControls = new Map() + const stateStatuses = new Map() + const transitionElements = new Map>() + const edgeElements = new Map>() + const transitionControls = new Map>() + const chartEdges = new Map( + layout.edges.flatMap((laidOut) => laidOut.kind === "transition" ? [[laidOut.edge.id, laidOut.edge] as const] : []) + ) + + const registerStateElement = (path: string, node: HTMLElement): void => { + const registered = stateElements.get(path) ?? [] + registered.push(node) + stateElements.set(path, registered) + } + const registerTransitionElement = (id: string, node: Element): void => { + const registered = transitionElements.get(id) ?? [] + registered.push(node) + transitionElements.set(id, registered) + } + const registerEdgeElement = (id: string, node: Element): void => { + const registered = edgeElements.get(id) ?? [] + registered.push(node) + edgeElements.set(id, registered) + } + + for (const laidOut of layout.nodes) { + if (laidOut.node.children.length > 0) { + const region = element("div", `chart-compound chart-compound-${laidOut.node.type}`) + region.dataset.statePath = laidOut.node.path + position(region, laidOut) + regions.append(region) + registerStateElement(laidOut.node.path, region) + } + + const card = element("button", `chart-state chart-state-${laidOut.node.type}`) + card.type = "button" + card.dataset.statePath = laidOut.node.path + card.setAttribute("aria-label", `${laidOut.node.label}, ${laidOut.node.type} state`) + position(card, { + x: laidOut.x, + y: laidOut.y, + width: laidOut.width, + height: laidOut.node.children.length > 0 ? laidOut.headerHeight : laidOut.height + }) + const stateStatus = status(laidOut.node.active, laidOut.node.initial) + card.append(stateContent(laidOut, stateStatus)) + card.addEventListener( + "click", + (event) => handlers.selectState(laidOut.node.path, { x: event.clientX, y: event.clientY }) + ) + card.addEventListener("dblclick", () => handlers.openStateDetails(laidOut.node.path)) + nodesLayer.append(card) + registerStateElement(laidOut.node.path, card) + stateControls.set(laidOut.node.path, card) + stateStatuses.set(laidOut.node.path, stateStatus) + } + + for (const initial of layout.initials) { + const dot = element("span", "chart-initial") + dot.setAttribute("aria-hidden", "true") + position(dot, initial) + nodesLayer.append(dot) + } + + for (const runtime of layout.runtimeTargets) { + const target = element("span", "chart-runtime-target", runtime.target.label) + target.setAttribute("aria-hidden", "true") + position(target, runtime) + nodesLayer.append(target) + } + + const hoverTransition = ( + transitionId: string, + source: string, + target: string | null, + hovered: boolean + ): void => { + transitionElements.get(transitionId)?.forEach((node) => node.classList.toggle("is-hovered", hovered)) + stateElements.get(source)?.forEach((node) => node.classList.toggle("is-hover-source", hovered)) + if (target !== null) { + stateElements.get(target)?.forEach((node) => node.classList.toggle("is-hover-target", hovered)) + } + } + + for (const laidOut of layout.edges) { + const group = svgElement( + "g", + `chart-edge-group chart-edge-${laidOut.kind}${ + laidOut.kind === "transition" ? ` chart-transition-${laidOut.edge.kind}` : "" + }` + ) + const visible = svgElement("path", "chart-edge-line") + const route = pathData(laidOut.points) + visible.setAttribute("d", route) + visible.setAttribute("marker-end", "url(#chart-arrow)") + const hit = svgElement("path", "chart-edge-hit") + hit.setAttribute("d", route) + group.append(visible, hit) + svg.append(group) + if (laidOut.kind === "initial") continue + + registerTransitionElement(laidOut.edge.transitionId, group) + registerEdgeElement(laidOut.edge.id, group) + const label = element( + "button", + `chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}`, + laidOut.edge.label + ) + label.type = "button" + label.dataset.transitionId = laidOut.edge.transitionId + position(label, { + x: laidOut.label.x - laidOut.labelWidth / 2, + y: laidOut.label.y - laidOut.labelHeight / 2, + width: laidOut.labelWidth, + height: laidOut.labelHeight + }) + label.addEventListener( + "click", + (event) => + handlers.selectTransition( + laidOut.edge.transitionId, + laidOut.edge.branchIds, + { x: event.clientX, y: event.clientY } + ) + ) + label.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId)) + label.addEventListener( + "mouseenter", + () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true) + ) + label.addEventListener( + "mouseleave", + () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false) + ) + hit.addEventListener( + "click", + (event) => + handlers.selectTransition( + laidOut.edge.transitionId, + laidOut.edge.branchIds, + { x: event.clientX, y: event.clientY } + ) + ) + hit.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId)) + hit.addEventListener( + "mouseenter", + () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true) + ) + hit.addEventListener( + "mouseleave", + () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false) + ) + labelsLayer.append(label) + registerTransitionElement(laidOut.edge.transitionId, label) + registerEdgeElement(laidOut.edge.id, label) + const controls = transitionControls.get(laidOut.edge.id) ?? [] + controls.push(label) + transitionControls.set(laidOut.edge.id, controls) + } + + viewport.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return + event.preventDefault() + handlers.clearSelection() + }) + + viewport.addEventListener("pointerdown", (event) => { + if (!event.isPrimary || event.button !== 0 || pan !== undefined) return + pan = { + pointerId: event.pointerId, + start: { x: event.clientX, y: event.clientY }, + scrollLeft: viewport.scrollLeft, + scrollTop: viewport.scrollTop, + dragging: false + } + }) + + viewport.addEventListener("pointermove", (event) => { + if (pan === undefined || event.pointerId !== pan.pointerId) return + const current = { x: event.clientX, y: event.clientY } + if (!pan.dragging) { + if (!isChartPan(pan.start, current)) return + pan.dragging = true + viewport.setPointerCapture(event.pointerId) + viewport.classList.add("is-panning") + const focused = document.activeElement + if (focused instanceof HTMLElement && viewport.contains(focused)) focused.blur() + } + event.preventDefault() + viewport.scrollTo({ + left: pan.scrollLeft - (current.x - pan.start.x), + top: pan.scrollTop - (current.y - pan.start.y) + }) + }) + + const finishPan = (event: PointerEvent, cancelled: boolean): void => { + if (pan === undefined || event.pointerId !== pan.pointerId) return + const dragged = pan.dragging + if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId) + viewport.classList.remove("is-panning") + pan = undefined + if (!dragged || cancelled) return + event.preventDefault() + suppressClick = true + suppressDoubleClickUntil = performance.now() + 400 + setTimeout(() => { + suppressClick = false + }, 0) + } + + viewport.addEventListener("pointerup", (event) => finishPan(event, false)) + viewport.addEventListener("pointercancel", (event) => finishPan(event, true)) + viewport.addEventListener("click", (event) => { + if (!suppressClick) return + suppressClick = false + event.preventDefault() + event.stopImmediatePropagation() + }, true) + viewport.addEventListener("dblclick", (event) => { + if (performance.now() > suppressDoubleClickUntil) return + event.preventDefault() + event.stopImmediatePropagation() + }, true) + + const revealState = (path: string): void => { + const control = stateControls.get(path) + if (control === undefined) return + const left = Number.parseFloat(control.style.left) * zoom + const top = Number.parseFloat(control.style.top) * zoom + const width = control.offsetWidth * zoom + const height = control.offsetHeight * zoom + viewport.scrollTo({ + left: Math.max(0, left - Math.max(28, (viewport.clientWidth - width) / 2)), + top: Math.max(0, top - Math.max(28, (viewport.clientHeight - height) / 2)) + }) + } + + const revealStates = (paths: ReadonlyArray): void => { + const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`))) + const bounds = leaves + .map((path) => layout.nodes.find(({ node }) => node.path === path)) + .filter((node): node is LaidOutChartNode => node !== undefined) + if (bounds.length === 0) return + const left = Math.min(...bounds.map(({ x }) => x)) * zoom + const top = Math.min(...bounds.map(({ y }) => y)) * zoom + const right = Math.max(...bounds.map(({ x, width }) => x + width)) * zoom + const bottom = Math.max(...bounds.map(({ y, height }) => y + height)) * zoom + const margin = 32 + const visible = left >= viewport.scrollLeft + margin && + top >= viewport.scrollTop + margin && + right <= viewport.scrollLeft + viewport.clientWidth - margin && + bottom <= viewport.scrollTop + viewport.clientHeight - margin + if (visible) return + viewport.scrollTo({ + left: Math.max(0, (left + right - viewport.clientWidth) / 2), + top: Math.max(0, (top + bottom - viewport.clientHeight) / 2) + }) + } + + const setZoom = (requested: number, anchor?: ChartZoomAnchor): number => { + const next = clampZoom(requested) + if (next === zoom) return zoom + const zoomAnchor = anchor ?? { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 } + const scroll = chartZoomScrollPosition( + zoom, + next, + viewport.scrollLeft, + viewport.scrollTop, + zoomAnchor + ) + zoom = next + canvas.style.transform = `scale(${zoom})` + stage.style.width = `${layout.width * zoom}px` + stage.style.height = `${layout.height * zoom}px` + viewport.scrollTo({ + left: scroll.x, + top: scroll.y + }) + handlers.zoomChanged(zoom) + return zoom + } + + viewport.addEventListener("wheel", (event) => { + if (!event.ctrlKey) return + event.preventDefault() + const bounds = viewport.getBoundingClientRect() + setZoom( + chartWheelZoom(zoom, event.deltaY, event.deltaMode, viewport.clientHeight), + { x: event.clientX - bounds.left, y: event.clientY - bounds.top } + ) + }, { passive: false }) + + const fit = (): number => { + const availableWidth = Math.max(1, viewport.clientWidth - 48) + const availableHeight = Math.max(1, viewport.clientHeight - 48) + const fitted = setZoom(Math.min(1, availableWidth / layout.width, availableHeight / layout.height)) + viewport.scrollTo({ left: 0, top: 0 }) + return fitted + } + + return { + update: (presentation) => { + const active = new Set(presentation.activePaths) + const availableBranches = new Set(presentation.availableBranchIds) + const unavailableBranches = new Set(presentation.unavailableBranchIds) + stateControls.forEach((control) => { + control.disabled = presentation.simulationMode + }) + transitionControls.forEach((controls, edgeId) => { + const edge = chartEdges.get(edgeId) + const interactive = edge?.branchIds.some((branchId) => + availableBranches.has(branchId) || unavailableBranches.has(branchId) + ) + controls.forEach((control) => { + control.disabled = presentation.simulationMode && !interactive + }) + }) + stateElements.forEach((elements) => + elements.forEach((node) => { + node.classList.remove( + "is-selected", + "is-related-from", + "is-related-to" + ) + }) + ) + transitionElements.forEach((elements) => + elements.forEach((node) => { + node.classList.remove( + "is-selected", + "is-incoming", + "is-outgoing", + "is-walkthrough-available", + "is-walkthrough-unavailable" + ) + }) + ) + stateStatuses.forEach((node, path) => { + const isActive = active.has(path) + const isInitial = node.dataset.initial === "true" + node.classList.toggle("is-active", isActive) + node.setAttribute("aria-label", statusLabel(isActive, isInitial)) + }) + viewport.classList.toggle("is-simulating", presentation.simulationMode) + if (presentation.selectedState !== null) { + stateElements.get(presentation.selectedState)?.forEach((node) => node.classList.add("is-selected")) + } + if (presentation.selectedTransition !== null) { + transitionElements.get(presentation.selectedTransition)?.forEach((node) => node.classList.add("is-selected")) + } + setStateClass(stateElements, presentation.fromPaths, "is-related-from") + setStateClass(stateElements, presentation.toPaths, "is-related-to") + presentation.incomingTransitionIds.forEach((id) => + transitionElements.get(id)?.forEach((node) => node.classList.add("is-incoming")) + ) + presentation.outgoingTransitionIds.forEach((id) => + transitionElements.get(id)?.forEach((node) => node.classList.add("is-outgoing")) + ) + layout.edges.forEach((laidOut) => { + if (laidOut.kind !== "transition") return + const available = laidOut.edge.branchIds.some((branchId) => availableBranches.has(branchId)) + const unavailable = laidOut.edge.branchIds.some((branchId) => unavailableBranches.has(branchId)) + const className = available + ? "is-walkthrough-available" + : unavailable + ? "is-walkthrough-unavailable" + : undefined + if (className !== undefined) { + edgeElements.get(laidOut.edge.id)?.forEach((node) => node.classList.add(className)) + } + }) + }, + focusState: (path) => { + const control = stateControls.get(path) + control?.focus({ preventScroll: true }) + revealState(path) + }, + revealState, + revealStates, + getZoom: () => zoom, + setZoom, + fit + } +} + +export const renderChart = ( + host: HTMLElement, + document: VisualizationDocument, + handlers: ChartHandlers +): Effect.Effect => { + const statesByPath = new Map(document.states.map((state) => [state.path, state])) + const visited = new Set() + let initialFocus = document.initial.target + while (!visited.has(initialFocus)) { + visited.add(initialFocus) + const state = statesByPath.get(initialFocus) + if (state === undefined) break + const next = state.type === "parallel" ? state.children[0] : state.initial + if (next === null || next === undefined) break + initialFocus = next + } + + return layoutChart(makeChartModel(document)).pipe(Effect.map((layout) => { + const view = render(host, layout, handlers) + setTimeout(() => { + if (host.isConnected) view.revealState(initialFocus) + }, 0) + return view + })) +} diff --git a/packages/devtools/src/internal/browser/example-machine.ts b/packages/devtools/src/internal/browser/example-machine.ts index 4508740..61d4e43 100644 --- a/packages/devtools/src/internal/browser/example-machine.ts +++ b/packages/devtools/src/internal/browser/example-machine.ts @@ -3,8 +3,14 @@ import { Schema } from "effect" // Shared by the live browser UI and its project-inspection fixture. -class Application extends Schema.TaggedClass("Application")("Application", {}) {} -class Workflow extends Schema.TaggedClass("Workflow")("Workflow", {}) {} +class Application extends Schema.TaggedClass("Application")("Application", { + workspace: Schema.String, + revision: Schema.Number +}) {} +class Workflow extends Schema.TaggedClass("Workflow")("Workflow", { + document: Schema.String, + unsavedChanges: Schema.Number +}) {} class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} class Running extends Schema.TaggedClass("Running")("Running", {}) {} class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {} @@ -59,11 +65,11 @@ const States = Machine.states({ export const snapshot = { path: "application" as const, - value: new Application({}), + value: new Application({ workspace: "effect-machine", revision: 7 }), states: { workflow: { path: "application.workflow" as const, - value: new Workflow({}), + value: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }), state: { path: "application.workflow.idle" as const, value: new Idle({}) } }, connection: { @@ -103,9 +109,12 @@ export const machine = Machine.make({ target.decoded( new Running({}), (running) => running.editing.decoded(new Editing({})) - ).update(owner.decoded(new Workflow({}))) + ).update(owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 3 }))) ), - Refresh: (to) => to.local.update(({ owner }) => owner.decoded(new Workflow({}))) + Refresh: (to) => + to.local.update(({ owner }) => + owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 0 })) + ) } }, running: { diff --git a/packages/devtools/src/internal/browser/input-form.ts b/packages/devtools/src/internal/browser/input-form.ts index 1b2c16a..6639e9d 100644 --- a/packages/devtools/src/internal/browser/input-form.ts +++ b/packages/devtools/src/internal/browser/input-form.ts @@ -1,4 +1,3 @@ -import type { InputIssue } from "../../DevToolsProtocol.js" import type { InputSchema } from "../../MachineDocument.js" type JsonPrimitive = string | number | boolean | null @@ -217,9 +216,7 @@ const project = ( case "null": return { _tag: "Literal", ...common, value: null } case "object": { - if (!isRecord(resolved.properties)) { - return { _tag: "Object", ...common, fields: [] } - } + 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") @@ -253,417 +250,3 @@ const project = ( } 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 - readonly clearIssues: () => void - readonly setIssues: (issues: ReadonlyArray) => void - readonly setPending: (pending: boolean) => void -} - -interface Control { - readonly element: HTMLElement - readonly interactive: boolean - readonly supported: boolean - readonly read: () => unknown -} - -interface RenderContext { - readonly issueTargets: Map -} - -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 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") - 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 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 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") - if (labelled !== null) { - const id = `machine-input-${nextControlId++}` - labelled.id = id - label.htmlFor = id - } - if (property.required) { - 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(identity, required!) - } else { - const optional = element("label", "input-optional") - included = element("input") - included.type = "checkbox" - optional.append(included, element("span", undefined, "include")) - heading.append(identity, 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 - }) - } - 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 }) - } - 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 - 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}`, 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) - refreshActions() - }) - row.append(control.element, remove) - items.append(row) - controls.push({ row, control, remove }) - refreshActions() - } - 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}`, context, path) - 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 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, 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/invoke-outcomes-example.ts b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts new file mode 100644 index 0000000..be6b527 --- /dev/null +++ b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts @@ -0,0 +1,231 @@ +import { Machine } from "@typeonce/effect-machine" +import { Effect, Schema, Stream } from "effect" + +class ChildWorking extends Schema.TaggedClass("InvokeGalleryChildWorking")("Working", { + task: Schema.String +}) {} +class ChildDone extends Schema.TaggedClass("InvokeGalleryChildDone")("Done", { + result: Schema.String +}) {} +class FinishChild extends Schema.TaggedClass("InvokeGalleryFinishChild")("FinishChild", {}) {} + +const ChildStates = Machine.states({ + Working: ChildWorking, + Done: { schema: ChildDone, type: "final", output: Schema.String } +}) + +export const invokeGalleryChildMachine = Machine.make({ + id: "invoke-gallery-child", + states: ChildStates.states, + events: Machine.events(FinishChild), + initial: (to) => to.Working().resolve(({ target }) => target.decoded(new ChildWorking({ task: "render-preview" }))) +}).handle({ + Working: { + on: { + FinishChild: (to) => + to.full.Done().resolve(({ state, target }) => + target.decoded(new ChildDone({ result: `${state.task}:complete` })) + ) + } + }, + Done: { + output: ({ state }) => state.result + } +}) + +const InvokedChild = Machine.child("preview-worker", invokeGalleryChildMachine) + +class Gallery extends Schema.TaggedClass("InvokeGallery")("Gallery", { + selectedDemo: Schema.NullOr(Schema.String) +}) {} +class Choose extends Schema.TaggedClass("InvokeGalleryChoose")("Choose", {}) {} +class LoadingDocument extends Schema.TaggedClass("InvokeGalleryLoadingDocument")( + "LoadingDocument", + { request: Schema.String } +) {} +class StreamingUpdates extends Schema.TaggedClass("InvokeGalleryStreamingUpdates")( + "StreamingUpdates", + { values: Schema.Array(Schema.Number) } +) {} +class WaitingForTimeout extends Schema.TaggedClass("InvokeGalleryWaitingForTimeout")( + "WaitingForTimeout", + { delay: Schema.Literal("2 seconds") } +) {} +class WatchingProcess extends Schema.TaggedClass("InvokeGalleryWatchingProcess")( + "WatchingProcess", + { revision: Schema.Number } +) {} +class RunningChild extends Schema.TaggedClass("InvokeGalleryRunningChild")("RunningChild", {}) {} +class Completed extends Schema.TaggedClass("InvokeGalleryCompleted")("Completed", { + source: Schema.String, + result: Schema.String +}) {} +class Failed extends Schema.TaggedClass("InvokeGalleryFailed")("Failed", { + source: Schema.String, + message: Schema.String +}) {} + +class RunEffect extends Schema.TaggedClass("InvokeGalleryRunEffect")("RunEffect", { + request: Schema.String +}) {} +class RunStream extends Schema.TaggedClass("InvokeGalleryRunStream")("RunStream", {}) {} +class RunTimer extends Schema.TaggedClass("InvokeGalleryRunTimer")("RunTimer", {}) {} +class RunProcess extends Schema.TaggedClass("InvokeGalleryRunProcess")("RunProcess", {}) {} +class RunChild extends Schema.TaggedClass("InvokeGalleryRunChild")("RunChild", {}) {} +class Reset extends Schema.TaggedClass("InvokeGalleryReset")("Reset", {}) {} +class StreamValue extends Schema.TaggedClass("InvokeGalleryStreamValue")("StreamValue", { + value: Schema.Number +}) {} + +const GalleryEvents = Machine.events(RunEffect, RunStream, RunTimer, RunProcess, RunChild, Reset) +const GalleryInternalEvents = Machine.internalEvents(StreamValue) +const processLogic = Machine.logic({ + initial: "starting" as "starting" | "ready", + run: () => Effect.fail("process stopped") +}) + +const GalleryStates = Machine.states({ + Gallery: { + schema: Gallery, + initial: "Choose", + states: { + Choose, + LoadingDocument, + StreamingUpdates, + WaitingForTimeout, + WatchingProcess, + RunningChild + } + }, + Completed, + Failed +}) + +export const invokeOutcomesMachine = Machine.make({ + id: "invoke-outcomes", + states: GalleryStates.states, + events: GalleryEvents, + internalEvents: GalleryInternalEvents, + initial: (to) => + to.Gallery.initial.resolve(({ target }) => + target.decoded( + new Gallery({ selectedDemo: null }), + (gallery) => gallery.Choose.decoded(new Choose({})) + ) + ) +}).handle({ + Gallery: { + initialize: ({ builder }) => builder.decoded(new Choose({})), + on: { + Reset: (to) => to.local.Choose().resolve(({ target }) => target.decoded(new Choose({}))) + }, + states: { + Choose: { + on: { + RunEffect: (to) => + to.local.LoadingDocument().resolve(({ event, target }) => + target.decoded(new LoadingDocument({ request: event.request })) + ), + RunStream: (to) => + to.local.StreamingUpdates().resolve(({ target }) => target.decoded(new StreamingUpdates({ values: [] }))), + RunTimer: (to) => + to.local.WaitingForTimeout().resolve(({ target }) => + target.decoded(new WaitingForTimeout({ delay: "2 seconds" })) + ), + RunProcess: (to) => + to.local.WatchingProcess().resolve(({ target }) => target.decoded(new WatchingProcess({ revision: 1 }))), + RunChild: (to) => to.local.RunningChild().resolve(({ target }) => target.decoded(new RunningChild({}))) + } + }, + LoadingDocument: { + invoke: (from) => + from.effect( + "load-document", + ({ state }) => + state.request === "fail" ? Effect.fail("document unavailable") : Effect.succeed("document loaded") + ).onDone((to) => + to.full.Completed().resolve(({ output, target }) => + target.decoded(new Completed({ source: "effect", result: output })) + ) + ).onFailure((to) => + to.full.Failed().resolve(({ error, target }) => + target.decoded(new Failed({ source: "effect", message: error })) + ) + ) + }, + StreamingUpdates: { + invoke: (from) => + from.stream( + "document-updates", + () => Stream.fromIterable([1, 2, 3]).pipe(Stream.concat(Stream.fail("stream disconnected"))) + ).onElement((to) => + to.none.resolve(({ element }, enqueue) => { + enqueue.raise(GalleryInternalEvents.StreamValue({ value: element })) + }) + ).onDone((to) => + to.full.Completed().resolve(({ state, target }) => + target.decoded(new Completed({ source: "stream", result: state.values.join(", ") })) + ) + ).onFailure((to) => + to.full.Failed().resolve(({ error, target }) => + target.decoded(new Failed({ source: "stream", message: error })) + ) + ), + on: { + StreamValue: (to) => + to.local.StreamingUpdates().resolve(({ event, state, target }) => + target.decoded(new StreamingUpdates({ values: [...state.values, event.value] })) + ) + } + }, + WaitingForTimeout: { + invoke: (from) => + from.timer("request-timeout", "2 seconds").onDone((to) => + to.full.Completed().resolve(({ target }) => + target.decoded(new Completed({ source: "timer", result: "timeout elapsed" })) + ) + ) + }, + WatchingProcess: { + invoke: (from) => + from.logic("status-worker", { + address: Machine.childAddress("status-worker"), + logic: processLogic + }).onFailure((to) => + to.full.Failed().resolve(({ error, target }) => + target.decoded(new Failed({ source: "process", message: String(error) })) + ) + ).onSnapshot((to) => + to.branches({ + ready: { title: "Worker reports ready", target: to.full.Completed() }, + waiting: { title: "Worker is still starting", target: to.none } + }).resolve(({ select, snapshot }) => + snapshot.state === "ready" + ? select.ready.decoded(new Completed({ source: "process", result: "ready" })) + : select.waiting() + ) + ) + }, + RunningChild: { + invoke: (from) => + from.child(InvokedChild).onDone((to) => + to.full.Completed().resolve(({ output, target }) => + target.decoded(new Completed({ source: "child machine", result: output })) + ) + ) + } + } + }, + Completed: { + on: { + Reset: (to) => + to.full.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null }))) + } + }, + Failed: { + on: { + Reset: (to) => + to.full.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null }))) + } + } +}) diff --git a/packages/devtools/src/internal/browser/parallel-completion-example.ts b/packages/devtools/src/internal/browser/parallel-completion-example.ts new file mode 100644 index 0000000..c70b93b --- /dev/null +++ b/packages/devtools/src/internal/browser/parallel-completion-example.ts @@ -0,0 +1,199 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +class Cart extends Schema.TaggedClass("ParallelCart")("Cart", { + items: Schema.Number +}) {} +class Order extends Schema.TaggedClass("ParallelOrder")("Order", { + orderId: Schema.String, + total: Schema.Number +}) {} +class Payment extends Schema.TaggedClass("ParallelPayment")("Payment", { + attempts: Schema.Number +}) {} +class AwaitingAuthorization extends Schema.TaggedClass("ParallelAwaitingAuthorization")( + "AwaitingAuthorization", + {} +) {} +class Authorized extends Schema.TaggedClass("ParallelAuthorized")("Authorized", { + authorizationId: Schema.String +}) {} +class Fulfillment extends Schema.TaggedClass("ParallelFulfillment")("Fulfillment", { + warehouse: Schema.String +}) {} +class WaitingForPayment extends Schema.TaggedClass("ParallelWaitingForPayment")( + "WaitingForPayment", + {} +) {} +class Packing extends Schema.TaggedClass("ParallelPacking")("Packing", { + packageCount: Schema.Number +}) {} +class Shipped extends Schema.TaggedClass("ParallelShipped")("Shipped", { + trackingCode: Schema.String +}) {} +class OrderComplete extends Schema.TaggedClass("ParallelOrderComplete")("OrderComplete", { + orderId: Schema.String +}) {} +class OrderCancelled extends Schema.TaggedClass("ParallelOrderCancelled")( + "OrderCancelled", + { reason: Schema.String } +) {} + +class Checkout extends Schema.TaggedClass("ParallelCheckout")("Checkout", { + orderId: Schema.String, + total: Schema.Number +}) {} +class Authorize extends Schema.TaggedClass("ParallelAuthorize")("Authorize", { + authorizationId: Schema.String +}) {} +class DeclinePayment extends Schema.TaggedClass("ParallelDeclinePayment")( + "DeclinePayment", + { reason: Schema.String } +) {} +class Pack extends Schema.TaggedClass("ParallelPack")("Pack", { packages: Schema.Number }) {} +class Ship extends Schema.TaggedClass("ParallelShip")("Ship", { trackingCode: Schema.String }) {} +class CompleteAll extends Schema.TaggedClass("ParallelCompleteAll")("CompleteAll", { + authorizationId: Schema.String, + trackingCode: Schema.String +}) {} +class CancelOrder extends Schema.TaggedClass("ParallelCancelOrder")("CancelOrder", { + reason: Schema.String +}) {} +class RetryOrder extends Schema.TaggedClass("ParallelRetryOrder")("RetryOrder", {}) {} +class AutoShip extends Schema.TaggedClass("ParallelAutoShip")("AutoShip", {}) {} + +const ParallelInternalEvents = Machine.internalEvents(AutoShip) +const ParallelStates = Machine.states({ + Cart, + Order: { + schema: Order, + type: "parallel", + states: { + payment: { + schema: Payment, + initial: "AwaitingAuthorization", + states: { + AwaitingAuthorization, + Authorized: { schema: Authorized, type: "final" } + } + }, + fulfillment: { + schema: Fulfillment, + initial: "WaitingForPayment", + states: { + WaitingForPayment, + Packing, + Shipped: { schema: Shipped, type: "final" } + } + } + } + }, + Complete: { schema: OrderComplete, type: "final", output: Schema.String }, + Cancelled: OrderCancelled +}) + +export const parallelCompletionMachine = Machine.make({ + id: "parallel-completion", + states: ParallelStates.states, + events: Machine.events( + Checkout, + Authorize, + DeclinePayment, + Pack, + Ship, + CompleteAll, + CancelOrder, + RetryOrder + ), + internalEvents: ParallelInternalEvents, + initial: (to) => to.Cart().resolve(({ target }) => target.decoded(new Cart({ items: 2 }))) +}).handle({ + Cart: { + on: { + Checkout: (to) => + to.full.Order.initial.resolve(({ event, target }) => + target.decoded(new Order({ orderId: event.orderId, total: event.total })) + ) + } + }, + Order: { + initialize: ({ builder }) => builder.payment.from({ attempts: 0 }).fulfillment.from({ warehouse: "north" }), + on: { + CancelOrder: (to) => + to.full.Cancelled().resolve(({ event, target }) => target.decoded(new OrderCancelled({ reason: event.reason }))) + }, + onDone: (to) => + to.full.Complete().resolve(({ target }) => target.decoded(new OrderComplete({ orderId: "completed-order" }))), + states: { + payment: { + initialize: ({ builder }) => builder.from(), + states: { + AwaitingAuthorization: { + on: { + Authorize: (to) => + to.local.Authorized().resolve(({ event, target }) => + target.decoded(new Authorized({ authorizationId: event.authorizationId })) + ), + CompleteAll: (to) => + to.local.Authorized().resolve(({ event, target }) => + target.decoded(new Authorized({ authorizationId: event.authorizationId })) + ), + DeclinePayment: (to) => + to.full.Cancelled().resolve(({ event, target }) => + target.decoded(new OrderCancelled({ reason: event.reason })) + ) + } + } + } + }, + fulfillment: { + initialize: ({ builder }) => builder.from(), + states: { + WaitingForPayment: { + on: { + Authorize: (to) => + to.local.Packing().resolve(({ target }) => target.decoded(new Packing({ packageCount: 1 }))), + Pack: (to) => + to.local.Packing().resolve(({ event, target }) => + target.decoded(new Packing({ packageCount: event.packages })) + ), + CompleteAll: (to) => + to.local.Shipped().resolve(({ event, target }) => + target.decoded(new Shipped({ trackingCode: event.trackingCode })) + ) + } + }, + Packing: { + invoke: (from) => + from.timer("packing-sla", "5 seconds").onDone((to) => + to.none.resolve((_, enqueue) => { + enqueue.raise(ParallelInternalEvents.AutoShip()) + }) + ), + on: { + AutoShip: (to) => + to.local.Shipped().resolve(({ target }) => target.decoded(new Shipped({ trackingCode: "automatic" }))), + Ship: (to) => + to.local.Shipped().resolve(({ event, target }) => + target.decoded(new Shipped({ trackingCode: event.trackingCode })) + ), + CompleteAll: (to) => + to.local.Shipped().resolve(({ event, target }) => + target.decoded(new Shipped({ trackingCode: event.trackingCode })) + ) + } + } + } + } + } + }, + Complete: { + output: ({ state }) => state.orderId + }, + Cancelled: { + on: { + RetryOrder: (to) => + to.full.Order.initial.resolve(({ target }) => target.decoded(new Order({ orderId: "retry", total: 0 }))) + } + } +}) diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts index 5add2dd..b04a5e2 100644 --- a/packages/devtools/src/internal/browser/planner-example.ts +++ b/packages/devtools/src/internal/browser/planner-example.ts @@ -1,5 +1,6 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import * as Effect from "effect/Effect" class Idle extends Schema.TaggedClass("PlannerIdle")("Idle", { owner: Schema.String }) {} class Working extends Schema.TaggedClass("PlannerWorking")("Working", { @@ -128,6 +129,7 @@ export const plannerMachine = Machine.make({ } }, Working: { + invoke: (from) => from.effect("monitor-job", () => Effect.never), on: { AutoFinish: (to) => to.full.Finished().resolve(({ state, target }) => target.decoded(new Finished({ job: state.job }))), diff --git a/packages/devtools/src/internal/browser/protocol-events-example.ts b/packages/devtools/src/internal/browser/protocol-events-example.ts new file mode 100644 index 0000000..b00cedf --- /dev/null +++ b/packages/devtools/src/internal/browser/protocol-events-example.ts @@ -0,0 +1,200 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +class ChildProgress extends Schema.TaggedClass("ProtocolChildProgress")("ChildProgress", { + percent: Schema.Number +}) {} +class ChildFinished extends Schema.TaggedClass("ProtocolChildFinished")("ChildFinished", { + result: Schema.String +}) {} +class ChildProblem extends Schema.TaggedClass("ProtocolChildProblem")("ChildProblem", { + message: Schema.String +}) {} + +const ParentEvents = Machine.events(ChildProgress, ChildFinished, ChildProblem) + +class ChildIdle extends Schema.TaggedClass("ProtocolChildIdle")("Idle", {}) {} +class ChildWorking extends Schema.TaggedClass("ProtocolChildWorking")("Working", { + job: Schema.String, + progress: Schema.Number +}) {} +class ChildDone extends Schema.TaggedClass("ProtocolChildDone")("Done", { + result: Schema.String +}) {} +class ChildCancelled extends Schema.TaggedClass("ProtocolChildCancelled")("Cancelled", { + reason: Schema.String +}) {} + +class BeginChildWork extends Schema.TaggedClass("ProtocolBeginChildWork")("BeginChildWork", { + job: Schema.String +}) {} +class CancelChildWork extends Schema.TaggedClass("ProtocolCancelChildWork")( + "CancelChildWork", + { reason: Schema.String } +) {} +class Heartbeat extends Schema.TaggedClass("ProtocolHeartbeat")("Heartbeat", { + percent: Schema.Number +}) {} +class CommitChildWork extends Schema.TaggedClass("ProtocolCommitChildWork")( + "CommitChildWork", + {} +) {} +class ChildTrace extends Schema.TaggedClass("ProtocolChildTrace")("ChildTrace", { + message: Schema.String +}) {} + +const ChildEvents = Machine.events(BeginChildWork, CancelChildWork) +const ChildInternalEvents = Machine.internalEvents(Heartbeat, CommitChildWork) +const ChildEmissions = Machine.emittedEvents(ChildTrace) +const ChildStates = Machine.states({ + Idle: ChildIdle, + Working: ChildWorking, + Done: { schema: ChildDone, type: "final", output: Schema.String }, + Cancelled: { schema: ChildCancelled, type: "final", output: Schema.String } +}) + +export const requiredParentChildMachine = Machine.make({ + id: "required-parent-child", + states: ChildStates.states, + events: ChildEvents, + internalEvents: ChildInternalEvents, + emittedEvents: ChildEmissions, + parent: Machine.parent(ParentEvents), + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ChildIdle({}))) +}).handle({ + Idle: { + on: { + BeginChildWork: (to) => + to.full.Working().resolve(({ event, target }, enqueue) => { + enqueue.raise(ChildInternalEvents.Heartbeat({ percent: 25 })) + enqueue.emit(ChildEmissions.ChildTrace({ message: `started ${event.job}` })) + return target.decoded(new ChildWorking({ job: event.job, progress: 0 })) + }) + } + }, + Working: { + invoke: (from) => + from.effect( + "report-progress-to-parent", + ({ parent, state }) => parent.send(ParentEvents.ChildProgress({ percent: state.progress })) + ).onDone((to) => to.none).onFailure((to) => + to.full.Cancelled().resolve(({ error, target }) => + target.decoded(new ChildCancelled({ reason: String(error) })) + ) + ), + on: { + Heartbeat: (to) => + to.full.Working().resolve(({ event, state, target }, enqueue) => { + enqueue.raise(ChildInternalEvents.CommitChildWork()) + return target.decoded(new ChildWorking({ job: state.job, progress: event.percent })) + }), + CommitChildWork: (to) => + to.full.Done().resolve(({ state, target }) => + target.decoded(new ChildDone({ result: `${state.job}:complete` })) + ), + CancelChildWork: (to) => + to.full.Cancelled().resolve(({ event, target }) => target.decoded(new ChildCancelled({ reason: event.reason }))) + } + }, + Done: { + output: ({ state }) => state.result + }, + Cancelled: { + output: ({ state }) => state.reason + } +}) + +const ProtocolChild = Machine.child("protocol-child", requiredParentChildMachine) + +class ParentIdle extends Schema.TaggedClass("ProtocolParentIdle")("Idle", {}) {} +class Supervising extends Schema.TaggedClass("ProtocolSupervising")("Supervising", { + latestProgress: Schema.Number +}) {} +class ParentComplete extends Schema.TaggedClass("ProtocolParentComplete")("Complete", { + result: Schema.String +}) {} +class ParentFailed extends Schema.TaggedClass("ProtocolParentFailed")("Failed", { + message: Schema.String +}) {} +class LaunchChild extends Schema.TaggedClass("ProtocolLaunchChild")("LaunchChild", {}) {} +class ResetParent extends Schema.TaggedClass("ProtocolResetParent")("ResetParent", {}) {} + +const ParentStates = Machine.states({ + Idle: ParentIdle, + Supervising, + Complete: { schema: ParentComplete, type: "final", output: Schema.String }, + Failed: ParentFailed +}) + +export const parentProtocolMachine = Machine.make({ + id: "parent-child-protocol", + states: ParentStates.states, + events: Machine.events(LaunchChild, ResetParent, ParentEvents), + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) +}).handle({ + Idle: { + on: { + LaunchChild: (to) => + to.full.Supervising().resolve(({ target }) => target.decoded(new Supervising({ latestProgress: 0 }))) + } + }, + Supervising: { + invoke: (from) => + from.child(ProtocolChild).onDone((to) => + to.full.Complete().resolve(({ output, target }) => target.decoded(new ParentComplete({ result: output }))) + ).onFailure((to) => + to.full.Failed().resolve(({ error, target }) => target.decoded(new ParentFailed({ message: String(error) }))) + ), + on: { + ChildProgress: (to) => + to.full.Supervising().resolve(({ event, target }) => + target.decoded(new Supervising({ latestProgress: event.percent })) + ), + ChildFinished: (to) => + to.full.Complete().resolve(({ event, target }) => target.decoded(new ParentComplete({ result: event.result }))), + ChildProblem: (to) => + to.full.Failed().resolve(({ event, target }) => target.decoded(new ParentFailed({ message: event.message }))), + ResetParent: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + } + }, + Complete: { + output: ({ state }) => state.result + }, + Failed: { + on: { + ResetParent: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + } + } +}) + +class Detached extends Schema.TaggedClass("OptionalParentDetached")("Detached", {}) {} +class Published extends Schema.TaggedClass("OptionalParentPublished")("Published", { + deliveredToParent: Schema.Boolean +}) {} +class PublishOutside extends Schema.TaggedClass("OptionalParentPublishOutside")( + "PublishOutside", + { result: Schema.String } +) {} + +const OptionalParentStates = Machine.states({ Detached, Published }) + +export const optionalParentMachine = Machine.make({ + id: "optional-parent-protocol", + states: OptionalParentStates.states, + events: Machine.events(PublishOutside), + parent: Machine.optionalParent(ParentEvents), + initial: (to) => to.Detached().resolve(({ target }) => target.decoded(new Detached({}))) +}).handle({ + Detached: { + on: { + PublishOutside: (to) => + to.full.Published().resolve(({ event, parent, target }, enqueue) => { + if (parent !== undefined) { + enqueue.sendTo(parent, ParentEvents.ChildFinished({ result: event.result })) + } + return target.decoded(new Published({ deliveredToParent: parent !== undefined })) + }) + } + }, + Published: {} +}) diff --git a/packages/devtools/src/internal/browser/simulation-client.ts b/packages/devtools/src/internal/browser/simulation-client.ts deleted file mode 100644 index 5ca4c39..0000000 --- a/packages/devtools/src/internal/browser/simulation-client.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 44d4a67..cb4705f 100644 --- a/packages/devtools/src/internal/browser/styles.css +++ b/packages/devtools/src/internal/browser/styles.css @@ -23,7 +23,7 @@ html, body, #app { min-width: 320px; - min-height: 100%; + height: 100%; margin: 0; } @@ -32,27 +32,40 @@ body { } .devtools-shell { - display: grid; - min-height: 100vh; - grid-template-columns: 220px minmax(0, 1fr); + display: flex; + width: 100vw; + height: 100vh; + flex-direction: column; + overflow: hidden; } .machine-index { + display: flex; + width: 100%; min-width: 0; - height: 100vh; - padding: 8px 0; - border-right: 1px solid var(--line); + min-height: 48px; + flex: 0 0 auto; + padding: 0; + border-bottom: 1px solid var(--line); background: #0d0f12; - overflow: auto; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; } .machine-row { - display: grid; - width: 100%; - grid-template-columns: 8px minmax(0, 1fr); - gap: 3px 8px; - padding: 10px 12px; + position: relative; + display: flex; + width: auto; + max-width: 280px; + min-width: 148px; + min-height: 48px; + flex: 0 0 auto; + align-items: center; + gap: 8px; + padding: 0 16px; border: 0; + border-right: 1px solid var(--line-soft); color: #c9cdd2; background: transparent; text-align: left; @@ -69,14 +82,23 @@ body { .machine-row.is-selected { color: #fff; - background: rgb(75 125 255 / 18%); + background: rgb(75 125 255 / 12%); +} + +.machine-row.is-selected::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background: var(--accent); + content: ""; } .machine-row-status { width: 6px; height: 6px; - align-self: center; - grid-row: 1; + flex: 0 0 auto; border-radius: 50%; background: #646b75; } @@ -102,24 +124,24 @@ body { } .machine-row-label { - grid-column: 2; font: 600 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .machine-row-file { - grid-column: 2; - color: #727983; - font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + display: none; } .machine-index-empty { - padding: 12px; + padding: 16px; color: #646b75; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .machine-view { min-width: 0; + min-height: 0; + flex: 1; + overflow: hidden; } .registry-empty, @@ -148,21 +170,22 @@ button { .app-shell, .workspace { - min-height: 100vh; + width: 100%; + height: 100%; + min-height: 0; } .workspace { - display: grid; - height: 100vh; - grid-template-columns: minmax(420px, 1fr) minmax(380px, 46%); + position: relative; } -.tree-panel { +.chart-panel { + position: relative; display: flex; + width: 100%; min-width: 0; - height: 100vh; + height: 100%; flex-direction: column; - border-right: 1px solid var(--line); overflow: hidden; } @@ -207,6 +230,7 @@ button { } .toolbar-actions, +.zoom-controls, .runtime-summary { display: flex; align-items: center; @@ -216,6 +240,35 @@ button { gap: 2px; } +.zoom-controls { + position: absolute; + z-index: 5; + bottom: 14px; + left: 14px; + flex: 0 0 auto; + gap: 1px; + padding: 2px; + border: 1px solid var(--line-soft); + border-radius: 4px; + background: rgb(13 15 18 / 94%); + box-shadow: 0 8px 28px rgb(0 0 0 / 32%); +} + +.chart-panel.has-walkthrough .zoom-controls { + bottom: 88px; +} + +.zoom-button { + min-width: 28px; + padding-inline: 7px; +} + +.zoom-value { + min-width: 46px; + color: #aeb4bd; + font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + .runtime-summary { gap: 7px; color: #7f8690; @@ -255,175 +308,483 @@ button { cursor: default; } -.topology-tree { +.topology-chart { + display: flex; flex: 1; min-height: 0; - padding: 23px 18px 34px; - overflow: auto; + flex-direction: column; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.machine-id { - margin: 0 8px 16px; - color: #fff; - font-weight: 700; +.topology-empty { + display: grid; + max-width: 420px; + gap: 7px; + margin: 26px 8px; + color: #7f8690; + font: 12px/1.6 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -.enabled-events { - display: flex; - min-height: 32px; - flex-wrap: wrap; - align-items: center; - gap: 6px; - margin: 0 8px 14px; - padding-bottom: 13px; - border-bottom: 1px solid var(--line-soft); +.topology-empty strong { + color: #d4d7dc; + font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.enabled-events-label, -.enabled-events-empty { - color: #6f7680; - font-size: 10px; +.chart-host, +.chart-viewport { + width: 100%; + height: 100%; } -.enabled-events-label { - margin-right: 3px; - text-transform: uppercase; +.chart-host { + flex: 1; + min-height: 0; + position: relative; } -.simulation-feedback { - margin: -5px 8px 14px; - color: #7f99c4; - font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +.chart-viewport { + overflow: auto; + overscroll-behavior: contain; + cursor: grab; + touch-action: pan-x pan-y; } -.simulation-feedback[data-status="pending"] { - color: #d9ae7e; +.chart-viewport.is-panning, +.chart-viewport.is-panning .chart-state, +.chart-viewport.is-panning .chart-edge-hit, +.chart-viewport.is-panning .chart-edge-label { + cursor: grabbing; + user-select: none; } -.simulation-feedback[data-status="error"] { - color: #d58d89; +.chart-stage { + position: relative; + min-width: 100%; + min-height: 100%; } -.enabled-events[hidden], -.simulation-feedback[hidden] { - display: none; +.chart-canvas { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; } -.event-button { - padding: 4px 7px; - border: 0; - border-radius: 3px; - color: #aeb5bf; - background: #1b1f25; - font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - cursor: pointer; +.chart-regions, +.chart-edges, +.chart-nodes, +.chart-labels { + position: absolute; + inset: 0; } -.event-button:hover, -.event-button:focus-visible, -.event-button.is-selected { - color: #d9e6ff; - background: rgb(75 125 255 / 22%); - outline: none; +.chart-regions { + z-index: 0; + pointer-events: none; } -.event-button:disabled { - color: #666d76; - cursor: wait; +.chart-edges { + z-index: 1; + overflow: visible; + pointer-events: none; } -.topology-node { - background: transparent; +.chart-nodes { + z-index: 2; + pointer-events: none; } -.topology-empty { - display: grid; - max-width: 420px; - gap: 7px; - margin: 26px 8px; - color: #7f8690; - font: 12px/1.6 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +.chart-labels { + z-index: 3; + pointer-events: none; } -.topology-empty strong { - color: #d4d7dc; - font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +.chart-compound { + position: absolute; + border: 1px solid #272c33; + border-radius: 5px; + background: rgb(18 21 25 / 72%); } -.topology-node.is-selected { - background: var(--accent-bg); +.chart-compound-parallel { + border-color: #303845; + background: rgb(20 24 30 / 74%); } -.state-row { - display: grid; - width: 100%; - min-height: 38px; - grid-template-columns: 14px 10px minmax(90px, auto) 1fr; - align-items: center; - gap: 8px; - padding: 6px 8px 6px calc(8px + var(--depth) * 22px); - border: 0; - color: #d7d9dc; - background: transparent; +.chart-state { + position: absolute; + display: block; + padding: 16px; + border: 1px solid #30363f; + border-radius: 4px; + color: #d9dce1; + background: #14181d; + box-shadow: 0 4px 14px rgb(0 0 0 / 18%); + overflow: hidden; text-align: left; cursor: pointer; + pointer-events: auto; user-select: none; } -.state-row:hover, -.state-row:focus-visible { - color: #fff; - background: #1b1e23; - outline: none; +.chart-state-compound, +.chart-state-parallel { + border-color: #353c47; + background: #171b21; } -.state-row[aria-selected="true"] { - color: #fff; +.chart-state-final { + border-color: #445066; } -.topology-node.is-selected > .state-row { - color: #fff; +.chart-state-history, +.chart-state-choice { + background: #18171d; } -.topology-node.is-related-target > .state-row { - background: rgb(75 125 255 / 15%); +.chart-state:not(:disabled):hover, +.chart-state:focus-visible { + border-color: #596475; + color: #fff; + outline: none; } -.topology-node.is-related-source > .state-row { - background: rgb(172 104 224 / 8%); +.chart-state-heading, +.chart-state-identity, +.chart-field-row, +.chart-activity-row { + display: flex; + min-width: 0; + align-items: center; } -.topology-node.is-related-update > .state-row { - background: rgb(240 163 91 / 8%); +.chart-state-heading { + justify-content: space-between; + gap: 12px; } -.state-disclosure { - color: #737a84; - font-size: 11px; +.chart-state-identity { + gap: 8px; } -.state-status { +.chart-state-status { width: 7px; height: 7px; + flex: 0 0 auto; border: 1px solid #686f78; border-radius: 50%; } -.state-status.is-active { +.chart-state-status.is-active { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 0 3px rgb(117 167 255 / 10%); } -.state-label { +.chart-state-status.is-initial { + border-color: #d9a441; + background: #d9a441; + box-shadow: 0 0 0 3px rgb(217 164 65 / 12%); +} + +.chart-state-status.is-active.is-initial { + box-shadow: 0 0 0 2px #14181d, 0 0 0 4px var(--accent); +} + +.chart-state-name, +.chart-field-name, +.chart-activity-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.state-markers, +.chart-state-name { + font-size: 12px; +} + +.chart-section-label, +.chart-field-type, +.chart-activity-kind, +.chart-more { + color: #7e8792; + font-size: 9px; + line-height: 1.3; +} + +.chart-section-label, +.chart-activity-kind { + text-transform: uppercase; +} + +.chart-state-section { + width: min(300px, 100%); + margin-top: 11px; + padding-top: 8px; + border-top: 1px solid #242a31; +} + +.chart-section-label { + margin-bottom: 4px; + letter-spacing: 0.06em; +} + +.chart-field-row, +.chart-activity-row { + display: grid; + min-height: 22px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.chart-activity-row { + grid-template-columns: auto minmax(0, 1fr); +} + +.chart-field-name, +.chart-activity-name { + color: #c4c9d0; + font-size: 10px; +} + +.chart-field-type, +.chart-activity-kind { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chart-activity-kind { + min-width: max-content; + color: #77a990; + overflow: visible; + text-overflow: clip; +} + +.chart-more { + min-height: 18px; + padding-top: 3px; +} + +.chart-initial { + position: absolute; + border-radius: 50%; + background: #9ba3ae; + box-shadow: 0 0 0 3px rgb(155 163 174 / 8%); + pointer-events: none; +} + +.chart-runtime-target { + position: absolute; + display: grid; + place-items: center; + border: 1px dashed #555d68; + border-radius: 3px; + color: #858d98; + background: #111419; + font: 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; + text-transform: uppercase; + pointer-events: none; + user-select: none; +} + +.chart-edge-line, +.chart-edge-hit { + fill: none; +} + +.chart-edge-line { + stroke: #626b77; + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} + +.chart-edge-initial .chart-edge-line { + stroke: #89929e; +} + +.chart-edge-hit { + stroke: transparent; + stroke-width: 14; + cursor: pointer; + pointer-events: stroke; +} + +#chart-arrow path { + fill: context-stroke; +} + +.chart-edge-label { + position: absolute; + padding: 4px 7px; + border: 1px solid #2c333c; + border-radius: 3px; + color: #c0c7d0; + background: #101318; + font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; + user-select: none; +} + +.chart-edge-label:not(:disabled):hover, +.chart-edge-label:focus-visible, +.chart-edge-group.is-hovered + .chart-edge-label { + border-color: #53657e; + color: #fff; + outline: none; +} + +.chart-edge-label-always, +.chart-edge-label-choice, +.chart-edge-label-done { + color: #d4b4e9; +} + +.chart-edge-group.is-hovered .chart-edge-line { + stroke: #7c9ed2; + stroke-width: 1.8; +} + +.chart-edge-group.is-walkthrough-unavailable .chart-edge-line { + stroke: #6e675b; + stroke-dasharray: 5 5; +} + +.chart-edge-group.chart-transition-runtime .chart-edge-line { + stroke-dasharray: 5 5; +} + +.chart-edge-group.is-incoming .chart-edge-line { + stroke: #ed6a70; + stroke-width: 2; +} + +.chart-edge-group.is-outgoing .chart-edge-line { + stroke: #6eb6dc; + stroke-width: 2; +} + +.chart-edge-group.is-selected .chart-edge-line { + stroke: #8ab5ff; + stroke-width: 2.5; +} + +.chart-edge-group.is-walkthrough-available .chart-edge-line { + stroke: #d5aa57; + stroke-width: 2; +} + +.chart-edge-label.is-hovered { + border-color: #526783; + color: #dbe8ff; +} + +.chart-edge-label.is-walkthrough-unavailable { + border-color: #4e4a43; + color: #8d8679; + background: #151412; +} + +.chart-edge-label.is-incoming { + border-color: #a9484e; + color: #ffd0d2; + background: #251416; +} + +.chart-edge-label.is-outgoing { + border-color: #47758d; + color: #c5e8f7; + background: #111c22; +} + +.chart-edge-label.is-selected { + border-color: #719ce0; + color: #fff; + background: #18253a; +} + +.chart-edge-label.is-walkthrough-available { + border-color: #8d713c; + color: #f0d39a; + background: #211b12; +} + +.chart-state.is-selected, +.chart-compound.is-selected { + border-color: #5f88ca; + background: rgb(44 75 122 / 42%); +} + +.chart-state.is-related-to, +.chart-compound.is-related-to, +.chart-state.is-hover-target, +.chart-compound.is-hover-target { + border-color: #4f829a; + background: rgb(48 112 140 / 24%); +} + +.chart-state.is-related-from, +.chart-compound.is-related-from, +.chart-state.is-hover-source, +.chart-compound.is-hover-source { + border-color: #a94b52; + background: rgb(151 48 57 / 26%); +} + +.chart-state.is-related-from.is-related-to, +.chart-compound.is-related-from.is-related-to { + border-color: #6e708f; + background: linear-gradient(135deg, rgb(151 48 57 / 28%) 0 50%, rgb(48 112 140 / 26%) 50% 100%); +} + +.chart-viewport.is-simulating .chart-state:disabled, +.chart-viewport.is-simulating .chart-edge-label:disabled, +.chart-viewport.is-simulating .chart-edge-hit { + cursor: default; +} + +.chart-viewport.is-simulating .chart-edge-label:disabled, +.chart-viewport.is-simulating .chart-edge-hit { + pointer-events: none; +} + +.chart-viewport.is-simulating .chart-edge-label.is-walkthrough-available, +.chart-viewport.is-simulating .chart-edge-label.is-walkthrough-unavailable, +.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-available .chart-edge-hit, +.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-unavailable .chart-edge-hit { + cursor: pointer; +} + +.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-available .chart-edge-hit, +.chart-viewport.is-simulating .chart-edge-group.is-walkthrough-unavailable .chart-edge-hit { + pointer-events: stroke; +} + +.chart-loading, +.chart-layout-error { + display: grid; + max-width: 440px; + gap: 7px; + margin: 28px; + color: #7f8690; + font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.chart-layout-error { + color: #d58d89; +} + +.chart-layout-error strong { + color: #efb0ac; +} + .inspector-eyebrow, .card-title, .card-flags, @@ -433,10 +794,6 @@ button { gap: 6px; } -.state-markers { - justify-self: end; -} - .badge { display: inline-flex; align-items: center; @@ -455,11 +812,6 @@ button { background: rgb(75 125 255 / 22%); } -.badge-initial { - color: #e9c89f; - background: rgb(240 163 91 / 13%); -} - .badge-trigger { color: #b9d2ff; background: rgb(75 125 255 / 16%); @@ -481,11 +833,51 @@ button { } .inspector { + position: absolute; + z-index: 10; + top: 14px; + right: 14px; + bottom: 14px; + width: min(460px, calc(100% - 28px)); min-width: 0; - height: 100vh; - padding: 28px; + padding: 0; + border: 1px solid #333943; + border-radius: 6px; + overflow: hidden; + background: rgb(15 17 20 / 97%); + box-shadow: 0 18px 60px rgb(0 0 0 / 48%); +} + +.inspector[hidden] { + display: none; +} + +.inspector-close { + position: absolute; + z-index: 1; + top: 11px; + right: 12px; + padding: 6px 9px; + border: 0; + border-radius: 3px; + color: #8e949e; + background: transparent; + font-size: 11px; + cursor: pointer; +} + +.inspector-content { + width: 100%; + height: 100%; + padding: 48px 24px 24px; overflow: auto; - background: var(--surface); +} + +.inspector-close:hover, +.inspector-close:focus-visible { + color: #fff; + background: #20242a; + outline: none; } .failure-shell { @@ -548,9 +940,29 @@ button { overflow-wrap: anywhere; } +.inspector-title-row, +.inspector-state-title { + display: flex; + min-width: 0; + align-items: center; +} + +.inspector-title-row { + justify-content: space-between; + gap: 16px; +} + +.inspector-state-title { + gap: 9px; +} + +.inspector-title-row h2 { + min-width: 0; + margin: 0; +} + .inspector-empty p, -.section-empty, -.empty-inline { +.section-empty { color: var(--muted); font-size: 12px; line-height: 1.6; @@ -612,7 +1024,7 @@ button { .metadata { display: grid; - grid-template-columns: minmax(74px, auto) minmax(0, 1fr); + grid-template-columns: minmax(74px, max-content) minmax(0, 1fr); gap: 8px 16px; margin: 20px 0 0; font-size: 11px; @@ -668,15 +1080,6 @@ button { padding: 10px 12px; } -.transition-source { - display: flex; - align-items: baseline; - gap: 12px; - padding: 0 12px 10px; - color: #727983; - font-size: 11px; -} - .card-title { min-width: 0; } @@ -714,6 +1117,7 @@ button { } .branch-row .metadata, +.transition-card .card-metadata, .activity-card .metadata, .incoming-card .metadata { margin-top: 10px; @@ -727,9 +1131,13 @@ button { padding: 0 12px; } +.transition-card .card-metadata { + padding: 0 12px 10px; +} + .branch-updates { display: grid; - grid-template-columns: minmax(74px, auto) minmax(0, 1fr); + grid-template-columns: minmax(74px, max-content) minmax(0, 1fr); gap: 8px 16px; margin-top: 9px; font-size: 11px; @@ -751,62 +1159,244 @@ button { padding: 0 12px; } -.simulation-composer { +.walkthrough-dock { display: grid; + min-height: 0; + max-height: 76px; + flex: 0 0 auto; + grid-template-rows: auto auto; + gap: 6px; + padding: 8px 14px 10px; + border-top: 1px solid var(--line); + background: rgb(13 15 18 / 98%); + box-shadow: 0 -12px 32px rgb(0 0 0 / 20%); + font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.walkthrough-dock[hidden] { + display: none; +} + +.walkthrough-heading, +.walkthrough-identity, +.walkthrough-timeline { + display: flex; + min-width: 0; + align-items: center; +} + +.walkthrough-heading { + justify-content: space-between; + gap: 12px; + color: #d4d7dc; +} + +.walkthrough-identity { gap: 10px; } -.simulation-composer .section-heading { - margin-bottom: 0; +.walkthrough-status, +.walkthrough-hint { + color: #777f8a; +} + +.walkthrough-hint { + font-size: 9px; } -.schema-form, -.input-object, -.input-array, -.input-union, -.input-field, -.input-array-items { +.walkthrough-timeline { + gap: 5px; + overflow-x: auto; + scrollbar-width: thin; +} + +.walkthrough-step { + flex: 0 0 auto; + border: 1px solid var(--line-soft); + border-radius: 3px; + color: #aeb4bd; + background: #14171b; + cursor: pointer; +} + +.walkthrough-step { + padding: 5px 8px; + white-space: nowrap; +} + +.walkthrough-step:hover, +.walkthrough-step:focus-visible { + border-color: #53657e; + color: #fff; + outline: none; +} + +.walkthrough-step.is-current { + border-color: #5f88ca; + color: #d8e6ff; + background: rgb(44 75 122 / 38%); +} + +.transition-picker { + position: fixed; + z-index: 20; display: grid; - gap: 10px; + width: min(380px, calc(100vw - 20px)); + max-height: min(420px, calc(100vh - 68px)); + grid-template-rows: auto minmax(0, 1fr); + border: 1px solid #4e4535; + border-radius: 5px; + background: rgb(15 17 20 / 98%); + box-shadow: 0 18px 60px rgb(0 0 0 / 52%); + overflow: hidden; } -.schema-form { - max-width: 520px; +.transition-picker[hidden] { + display: none; } -.input-object, -.input-array, -.input-union { +.transition-picker-header, +.transition-picker-choice-main { + display: flex; min-width: 0; - margin: 0; - padding: 0; - border: 0; + align-items: center; } -.input-object > legend, -.input-array > legend, -.input-union > legend { - margin-bottom: 8px; - padding: 0; - color: #d5d8dd; +.transition-picker-header { + min-height: 38px; + justify-content: space-between; + gap: 10px; + padding: 8px 10px 8px 12px; + border-bottom: 1px solid var(--line-soft); +} + +.transition-picker-header strong { + color: #d8dadd; font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transition-picker-close { + padding: 4px 6px; + border: 0; + border-radius: 3px; + color: #7f8690; + background: transparent; + font-size: 10px; + cursor: pointer; +} + +.transition-picker-close:hover, +.transition-picker-close:focus-visible { + color: #fff; + background: #20242a; + outline: none; +} + +.transition-picker-content { + display: grid; + gap: 5px; + padding: 7px; + overflow: auto; } -.input-object .input-object, -.input-object .input-array, -.input-object .input-union { - padding: 11px; +.transition-picker-choice { + display: grid; + min-width: 0; + gap: 4px; + padding: 8px 9px; border: 1px solid var(--line-soft); - background: #101216; + border-radius: 3px; + color: #aeb4bd; + background: #14171b; + font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-align: left; + cursor: pointer; } -.input-field { - gap: 6px; +.transition-picker-choice:hover, +.transition-picker-choice:focus-visible { + border-color: #8d713c; + background: #1d1912; + outline: none; +} + +.transition-picker-choice-main { + gap: 7px; +} + +.transition-picker-choice-main strong, +.transition-picker-choice-title, +.transition-picker-choice-route, +.transition-picker-choice-contract, +.transition-picker-choice-decision, +.transition-picker-choice-unavailable { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transition-picker-choice-main strong { + color: #efd18f; +} + +.transition-picker-choice-title { + color: #d9b8ef; +} + +.transition-picker-choice-route { + color: #777f8a; +} + +.transition-picker-choice-contract { + color: #9ebce9; +} + +.transition-picker-choice-decision { + color: #b69ac9; +} + +.transition-picker-choice-unavailable { + color: #a38c6f; +} + +.transition-picker-choice.is-unavailable { + opacity: 0.68; + cursor: default; +} + +.input-contract-fields, +.input-contract-field, +.input-contract-children, +.input-contract-alternatives { + display: grid; + gap: 9px; +} + +.input-contract-fields { + padding: 11px 12px; + border: 1px solid var(--line-soft); + background: var(--surface-raised); +} + +.input-contract-field + .input-contract-field { + padding-top: 9px; + border-top: 1px solid var(--line-soft); } -.input-field + .input-field { - padding-top: 10px; +.input-contract-children, +.input-contract-alternatives { + margin-top: 2px; + padding: 9px 0 0 12px; border-top: 1px solid var(--line-soft); + border-left: 1px solid var(--line-soft); +} + +.input-contract .section-empty { + margin: 0; } .input-field-heading { @@ -827,8 +1417,7 @@ button { .input-label, .input-required, -.input-optional, -.boolean-control { +.input-optional { font-size: 11px; } @@ -863,33 +1452,15 @@ button { color: #737b86; } -.input-optional, -.boolean-control { +.input-optional { display: inline-flex; align-items: center; gap: 6px; color: #9199a4; } -.input-control { - width: 100%; - min-height: 34px; - padding: 7px 9px; - border: 1px solid var(--line); - border-radius: 3px; - color: #dfe7f5; - background: #0b0d10; - font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - outline: none; -} - -.input-control:focus { - border-color: #557db8; -} - .input-description, -.input-unsupported, -.input-errors { +.input-unsupported { margin: 0; font-size: 11px; line-height: 1.55; @@ -903,104 +1474,6 @@ 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; -} - -.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 { - 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; @@ -1067,40 +1540,16 @@ button { } @media (max-width: 900px) { - .devtools-shell { - grid-template-columns: 1fr; - } - - .machine-index { - display: flex; - height: auto; - min-height: 54px; - padding: 0; - border-right: 0; - border-bottom: 1px solid var(--line); - overflow-x: auto; - } - .machine-row { - width: min(220px, 70vw); - flex: 0 0 auto; - } - - .workspace { - height: auto; - grid-template-columns: 1fr; - } - - .tree-panel { - height: 58vh; - min-height: 58vh; - border-right: 0; - border-bottom: 1px solid var(--line); + max-width: 220px; + min-width: 132px; } .inspector { - height: auto; - min-height: 42vh; + top: 8px; + right: 8px; + bottom: 8px; + width: min(440px, calc(100% - 16px)); } } @@ -1119,19 +1568,24 @@ button { padding-top: 8px; } - .topology-tree, - .inspector { - padding-inline: 14px; + .inspector-content { + padding: 48px 14px 18px; + } + + .zoom-controls { + bottom: 8px; + left: 8px; } - .state-markers .badge-count { - display: none; + .chart-panel.has-walkthrough .zoom-controls { + bottom: 88px; } } @media (prefers-reduced-motion: no-preference) { - .state-row, + .chart-state, + .chart-edge-label, .toolbar-button { - transition: color 120ms ease, background-color 120ms ease; + transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease; } } diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts new file mode 100644 index 0000000..6378c5c --- /dev/null +++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts @@ -0,0 +1,236 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +class Workspace extends Schema.TaggedClass("TransitionWorkspace")("Workspace", { + revision: Schema.Number, + preferredRoute: Schema.Literals(["draft", "review"]) +}) {} +class Draft extends Schema.TaggedClass("TransitionDraft")("Draft", { + text: Schema.String, + autosaves: Schema.Number +}) {} +class AutoSaving extends Schema.TaggedClass("TransitionAutoSaving")("AutoSaving", {}) {} +class Review extends Schema.TaggedClass("TransitionReview")("Review", { + requestedBy: Schema.String +}) {} +class Checking extends Schema.TaggedClass("TransitionChecking")("Checking", { + checks: Schema.Array(Schema.String) +}) {} +class ChangesRequested extends Schema.TaggedClass("TransitionChangesRequested")( + "ChangesRequested", + { reason: Schema.String } +) {} +class Approved extends Schema.TaggedClass("TransitionApproved")("Approved", { + reviewer: Schema.String +}) {} +class WorkspaceFinished extends Schema.TaggedClass("TransitionWorkspaceFinished")( + "Finished", + { result: Schema.String } +) {} +class Paused extends Schema.TaggedClass("TransitionPaused")("Paused", { + reason: Schema.String +}) {} +class Published extends Schema.TaggedClass("TransitionPublished")("Published", { + result: Schema.String +}) {} + +class Create extends Schema.TaggedClass("TransitionCreate")("Create", { + text: Schema.String, + route: Schema.Literals(["draft", "review"]) +}) {} +class Edit extends Schema.TaggedClass("TransitionEdit")("Edit", { text: Schema.String }) {} +class Save extends Schema.TaggedClass("TransitionSave")("Save", {}) {} +class Submit extends Schema.TaggedClass("TransitionSubmit")("Submit", { + mode: Schema.Literals(["review", "publish"]), + requestedBy: Schema.String +}) {} +class Approve extends Schema.TaggedClass("TransitionApprove")("Approve", { + reviewer: Schema.String +}) {} +class Reject extends Schema.TaggedClass("TransitionReject")("Reject", { + reason: Schema.String +}) {} +class Revise extends Schema.TaggedClass("TransitionRevise")("Revise", {}) {} +class Pause extends Schema.TaggedClass("TransitionPause")("Pause", { reason: Schema.String }) {} +class ResumeShallow extends Schema.TaggedClass("TransitionResumeShallow")( + "ResumeShallow", + {} +) {} +class ResumeDeep extends Schema.TaggedClass("TransitionResumeDeep")("ResumeDeep", {}) {} +class Restart extends Schema.TaggedClass("TransitionRestart")("Restart", {}) {} +class Refresh extends Schema.TaggedClass("TransitionRefresh")("Refresh", {}) {} +class Ignore extends Schema.TaggedClass("TransitionIgnore")("Ignore", {}) {} +class MaybeHandle extends Schema.TaggedClass("TransitionMaybeHandle")("MaybeHandle", { + accept: Schema.Boolean +}) {} +class BumpWorkspace extends Schema.TaggedClass("TransitionBumpWorkspace")( + "BumpWorkspace", + {} +) {} + +const TransitionStates = Machine.states({ + Workspace: { + schema: Workspace, + initial: "Routing", + states: { + Routing: { type: "choice" }, + Draft, + AutoSaving, + Review: { + schema: Review, + initial: "Checking", + states: { + Checking, + ChangesRequested, + Approved: { schema: Approved, type: "final" } + } + }, + Finished: { schema: WorkspaceFinished, type: "final" }, + recent: { type: "history" }, + exact: { type: "history", history: "deep" } + } + }, + Paused, + Published: { schema: Published, type: "final", output: Schema.String } +}) + +const defaultWorkspaceSnapshot = () => ({ + path: "Workspace" as const, + value: new Workspace({ revision: 0, preferredRoute: "draft" as const }), + state: { + path: "Workspace.Draft" as const, + value: new Draft({ text: "Recovered draft", autosaves: 0 }) + } +}) + +export const transitionSemanticsMachine = Machine.make({ + id: "transition-semantics", + states: TransitionStates.states, + events: Machine.events( + Create, + Edit, + Save, + Submit, + Approve, + Reject, + Revise, + Pause, + ResumeShallow, + ResumeDeep, + Restart, + Refresh, + Ignore, + MaybeHandle, + BumpWorkspace + ), + initial: (to) => to.Paused().resolve(({ target }) => target.decoded(new Paused({ reason: "not started" }))) +}).handle({ + Workspace: { + history: { + recent: { default: defaultWorkspaceSnapshot }, + exact: { default: defaultWorkspaceSnapshot } + }, + on: { + Pause: (to) => + to.full.Paused().resolve(({ event, target }) => target.decoded(new Paused({ reason: event.reason }))), + BumpWorkspace: (to) => + to.branch.Workspace.update(({ current, owner }) => + owner.decoded( + new Workspace({ + revision: current.revision + 1, + preferredRoute: current.preferredRoute + }) + ) + ) + }, + onDone: (to) => + to.full.Published().resolve(({ target }) => target.decoded(new Published({ result: "workspace published" }))), + states: { + Routing: { + choice: (to) => + to.branches({ + draft: { title: "Preferred route is draft", target: to.local.Draft() }, + review: { title: "Preferred route is review", target: to.local.Review.initial } + }).resolve(({ containingState, select }) => + containingState.preferredRoute === "review" + ? select.review.decoded(new Review({ requestedBy: "initial route" })) + : select.draft.decoded(new Draft({ text: "", autosaves: 0 })) + ) + }, + Draft: { + on: { + Edit: (to) => + to.local.Draft().resolve(({ event, state, target }) => + target.decoded(new Draft({ text: event.text, autosaves: state.autosaves })) + ), + Save: (to) => to.local.AutoSaving().resolve(({ target }) => target.decoded(new AutoSaving({}))), + Submit: (to) => + to.branches({ + review: { title: "Enter the review flow", target: to.local.Review.initial }, + publish: { title: "Publish without review", target: to.local.Finished() } + }).resolve(({ event, select }) => + event.mode === "publish" + ? select.publish.decoded(new WorkspaceFinished({ result: "published directly" })) + : select.review.decoded(new Review({ requestedBy: event.requestedBy })) + ), + Refresh: (to) => to.none.resolve(() => undefined, { reenter: true }), + Ignore: (to) => to.none, + MaybeHandle: (to) => + to.none.resolve(({ decline, event }) => event.accept ? undefined : decline(), { + declinable: true + }) + } + }, + AutoSaving: { + always: (to) => + to.local.Draft().resolve(({ target }) => target.decoded(new Draft({ text: "Autosaved draft", autosaves: 1 }))) + }, + Review: { + initialize: ({ builder }) => builder.decoded(new Checking({ checks: ["types", "tests"] })), + onDone: (to) => + to.branch.Workspace.Finished().resolve(({ target }) => + target.decoded(new WorkspaceFinished({ result: "approved review" })) + ), + states: { + Checking: { + on: { + Approve: (to) => + to.local.Approved().resolve(({ event, target }) => + target.decoded(new Approved({ reviewer: event.reviewer })) + ), + Reject: (to) => + to.local.ChangesRequested().resolve(({ event, target }) => + target.decoded(new ChangesRequested({ reason: event.reason })) + ) + } + }, + ChangesRequested: { + on: { + Revise: (to) => + to.branch.Workspace.Draft().resolve(({ target }) => + target.decoded(new Draft({ text: "Revised draft", autosaves: 0 })) + ) + } + } + } + } + } + }, + Paused: { + on: { + Create: (to) => + to.full.Workspace.initial.resolve(({ event, target }) => + target.decoded(new Workspace({ revision: 0, preferredRoute: event.route })) + ), + ResumeShallow: (to) => to.history.Workspace.recent.resolve(({ target }) => target()), + ResumeDeep: (to) => to.history.Workspace.exact.resolve(({ target }) => target()), + Restart: (to) => + to.full.Workspace.initial.resolve(({ target }) => + target.decoded(new Workspace({ revision: 0, preferredRoute: "draft" })) + ) + } + }, + Published: { + output: ({ state }) => state.result + } +}) diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index 47140bf..465e37b 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -1,24 +1,29 @@ -import { - type Diagnostic, - protocolVersion, - type SimulationFrame, - type SimulationReady, - type SimulationRequest -} from "../../DevToolsProtocol.js" +import * as Effect from "effect/Effect" +import * as Result from "effect/Result" +import type { Diagnostic } from "../../DevToolsProtocol.js" import type { Activity as VisualizationActivity, Branch as VisualizationBranch, + InputSchema, MachineDocument as VisualizationDocument, - Transition as VisualizationTransition + Transition as VisualizationTransition, + Trigger } from "../../MachineDocument.js" -import { type InputForm, renderInputForm } from "./input-form.js" -import { requestSimulation } from "./simulation-client.js" +import * as MachineWalkthrough from "../../MachineWalkthrough.js" +import { + type ChartInteractionAnchor, + type ChartPresentation, + type ChartView, + maximumChartZoom, + minimumChartZoom, + renderChart +} from "./chart-renderer.js" +import { type InputField, projectInputSchema } from "./input-form.js" import { - type EventInspection, + branchTargetApi, type IncomingTransition, makeVisualizerModel, type StateInspection, - type TopologyNode, triggerLabel } from "./visualizer-model.js" @@ -33,16 +38,33 @@ const createElement = ( return element } -const metadata = (items: ReadonlyArray): HTMLDListElement => { +type MetadataValue = string | Node | null | undefined + +const metadata = (items: ReadonlyArray): HTMLDListElement => { const list = createElement("dl", "metadata") for (const [label, value] of items) { - list.append(createElement("dt", undefined, label), createElement("dd", undefined, value)) + if (value === null || value === undefined || value === "" || value === "none") continue + const description = createElement("dd") + description.append(value) + list.append(createElement("dt", undefined, label), description) } return list } const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text) +const stateStatus = (active: boolean, initial: boolean): HTMLSpanElement => { + const status = createElement( + "span", + `chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}` + ) + status.setAttribute( + "aria-label", + active && initial ? "active, initial state" : active ? "active" : initial ? "initial state" : "inactive" + ) + return status +} + type StateNavigator = (path: string) => void const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => { @@ -52,26 +74,35 @@ const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLB return link } -const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HTMLElement => { +const renderBranch = ( + document: VisualizationDocument, + source: string, + branch: VisualizationBranch, + navigate: StateNavigator +): HTMLElement => { const row = createElement("div", "branch-row") const main = createElement("div", "branch-main") if (branch.type === "branch") main.append(badge(branch.title, "condition")) - main.append(createElement("span", "branch-arrow", "→")) if (branch.target !== null) { - main.append(stateLink(branch.target, branch.target, navigate)) + main.append(createElement("span", "branch-arrow", "→"), stateLink(branch.target, branch.target, navigate)) + } else if (branch.selection.kind === "update") { + main.append( + badge(branch.selection.scope === "local" ? "to.local.update" : "value update", "update"), + createElement("span", "branch-target", "Updates the owner value") + ) } else { - main.append(createElement("span", "branch-target", branch.updates.length > 0 ? "Remain in state" : "No target")) + main.append(createElement("span", "branch-target", "No target state")) } row.append(main) - const details: Array = [ - ["Selection", branch.selection.kind], - ["Scope", branch.selection.scope ?? "none"] - ] + const api = branchTargetApi(document, source, branch) + const details: Array = api === undefined + ? [["Selection", branch.selection.kind], ["Scope", branch.selection.scope]] + : [["API", api]] row.append(metadata(details)) if (branch.updates.length > 0) { const updates = createElement("div", "branch-updates") - updates.append(createElement("span", "branch-updates-label", "Updates")) + updates.append(createElement("span", "branch-updates-label", "Value owner")) branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate))) row.append(updates) } @@ -79,6 +110,7 @@ const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HT } const renderTransition = ( + document: VisualizationDocument, transition: VisualizationTransition, navigate: StateNavigator, showSource = false @@ -86,44 +118,52 @@ const renderTransition = ( const card = createElement("article", "inspection-card transition-card") const header = createElement("div", "card-header") const title = createElement("div", "card-title") - title.append(badge(transition.trigger.type, "trigger"), createElement("strong", undefined, triggerLabel(transition))) + title.append(createElement("strong", undefined, triggerLabel(transition))) const flags = createElement("div", "card-flags") + flags.append(badge(transition.trigger.type, "trigger")) if (transition.reenter) flags.append(badge("reenter")) if (transition.acceptance === "declinable") flags.append(badge("declinable")) header.append(title, flags) card.append(header) if (showSource) { - const source = createElement("div", "transition-source") - source.append(createElement("span", undefined, "From"), stateLink(transition.source, transition.source, navigate)) + const source = metadata([["Source", stateLink(transition.source, transition.source, navigate)]]) + source.classList.add("card-metadata") card.append(source) } - const branches = createElement("div", "branch-list") - if (transition.branches.length === 0) { - branches.append(createElement("div", "empty-inline", "No transition branches")) - } else { - transition.branches.forEach((branch) => branches.append(renderBranch(branch, navigate))) + if (transition.branches.length > 0) { + const branches = createElement("div", "branch-list") + transition.branches.forEach((branch) => + branches.append(renderBranch(document, transition.source, branch, navigate)) + ) + card.append(branches) } - card.append(branches) return card } -const renderIncomingTransition = (incoming: IncomingTransition, navigate: StateNavigator): HTMLElement => { +const renderIncomingTransition = ( + document: VisualizationDocument, + incoming: IncomingTransition, + navigate: StateNavigator +): HTMLElement => { const card = createElement("article", "inspection-card incoming-card") const header = createElement("div", "card-header") const title = createElement("div", "card-title") - title.append( - badge(incoming.transition.trigger.type, "trigger"), - createElement("strong", undefined, triggerLabel(incoming.transition)) - ) - header.append(title, stateLink(incoming.transition.source, incoming.transition.source, navigate)) + title.append(createElement("strong", undefined, triggerLabel(incoming.transition))) + const flags = createElement("div", "card-flags") + flags.append(badge(incoming.transition.trigger.type, "trigger")) + header.append(title, flags) card.append(header) - const details: Array = [ - ["Selection", incoming.branch.selection.kind], - ["Scope", incoming.branch.selection.scope ?? "none"] + const api = branchTargetApi(document, incoming.transition.source, incoming.branch) + const details: Array = [ + ["Source", stateLink(incoming.transition.source, incoming.transition.source, navigate)], + ["API", api] ] - if (incoming.branch.type === "branch") details.unshift(["Branch", incoming.branch.title]) + if (api === undefined) { + details.push(["Selection", incoming.branch.selection.kind], ["Scope", incoming.branch.selection.scope]) + } + if (incoming.branch.type === "branch") details.unshift(["Title", incoming.branch.title]) card.append(metadata(details)) return card } @@ -140,15 +180,19 @@ const activityTitle = (activity: VisualizationActivity): string => { } } -const renderActivity = (activity: VisualizationActivity): HTMLElement => { +const renderActivity = (activity: VisualizationActivity, navigate: StateNavigator): HTMLElement => { const card = createElement("article", "inspection-card activity-card") const header = createElement("div", "card-header") const title = createElement("div", "card-title") - title.append(badge(activity.type, "activity"), createElement("strong", undefined, activityTitle(activity))) - header.append(title) + title.append(createElement("strong", undefined, activityTitle(activity))) + const flags = createElement("div", "card-flags") + flags.append(badge(activity.type, "activity")) + header.append(title, flags) card.append(header) - const details: Array = [["Owner", activity.source]] + const details: Array = [ + ["Owner", stateLink(activity.source, activity.source, navigate)] + ] if (activity.type === "timer") details.push(["Duration", activity.duration]) if (activity.type === "effect") { details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure]) @@ -160,24 +204,183 @@ const renderActivity = (activity: VisualizationActivity): HTMLElement => { return card } -const inspectionSection = (title: string, count: number): HTMLElement => { +const inspectionSection = (title: string, count?: number): HTMLElement => { const header = createElement("div", "section-heading") - header.append(createElement("h3", undefined, title), createElement("span", "section-count", String(count))) + header.append(createElement("h3", undefined, title)) + if (count !== undefined) header.append(createElement("span", "section-count", String(count))) return header } -const formSection = (title: string): HTMLElement => { - const header = createElement("div", "section-heading") - header.append(createElement("h3", undefined, title)) - return header +const inputType = (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 `${inputType(field.item)}[]` + case "Union": + return "union" + case "Unsupported": + return "unknown" + } } -const prettyJson = (value: unknown): string => JSON.stringify(value, null, 2) +const inputConstraints = (field: InputField): ReadonlyArray => { + switch (field._tag) { + case "String": + return [ + field.minLength === undefined ? undefined : `min ${field.minLength}`, + field.maxLength === undefined ? undefined : `max ${field.maxLength}`, + field.pattern === undefined ? undefined : `pattern ${field.pattern}` + ].filter((value): value is string => value !== undefined) + case "Number": + return [ + field.minimum === undefined ? undefined : `≥ ${field.minimum}`, + field.maximum === undefined ? undefined : `≤ ${field.maximum}` + ].filter((value): value is string => value !== undefined) + case "Enum": + return field.values.map((value) => JSON.stringify(value)) + case "Literal": + return [JSON.stringify(field.value)] + case "Array": + return [ + field.minItems > 0 ? `min ${field.minItems}` : undefined, + field.maxItems === undefined ? undefined : `max ${field.maxItems}` + ].filter((value): value is string => value !== undefined) + default: + return [] + } +} -const jsonBlock = (value: unknown): HTMLElement => createElement("pre", "json-value", prettyJson(value)) +const renderContractField = ( + label: string, + required: boolean, + field: InputField, + omit: ReadonlySet +): HTMLElement | null => { + if (omit.has(label)) return null + const row = createElement("div", "input-contract-field") + const heading = createElement("div", "input-field-heading") + const identity = createElement("div", "input-field-identity") + identity.append( + createElement("span", "input-label", label), + createElement("span", "input-field-type", inputType(field)), + createElement("span", required ? "input-required" : "input-optional", required ? "required" : "optional") + ) + heading.append(identity) + row.append(heading) + const constraints = inputConstraints(field) + if (constraints.length > 0) { + const values = createElement("div", "input-constraints") + constraints.forEach((value) => values.append(createElement("span", undefined, value))) + row.append(values) + } + if (field.description !== undefined) row.append(createElement("p", "input-description", field.description)) + if (field._tag === "Object") { + const children = createElement("div", "input-contract-children") + field.fields.forEach(({ key, required, field }) => { + const child = renderContractField(key, required, field, omit) + if (child !== null) children.append(child) + }) + if (children.childElementCount > 0) row.append(children) + } else if (field._tag === "Array" && field.item._tag === "Object") { + const children = createElement("div", "input-contract-children") + field.item.fields.forEach(({ key, required, field }) => { + const child = renderContractField(key, required, field, omit) + if (child !== null) children.append(child) + }) + if (children.childElementCount > 0) row.append(children) + } else if (field._tag === "Union") { + const alternatives = createElement("div", "input-contract-alternatives") + field.alternatives.forEach((alternative, index) => { + const item = renderContractField(`Option ${index + 1}`, true, alternative, omit) + if (item !== null) alternatives.append(item) + }) + row.append(alternatives) + } else if (field._tag === "Unsupported") { + row.append(createElement("p", "input-unsupported", field.reason)) + } + return row +} -const eventName = (value: unknown): string => - typeof value === "object" && value !== null && "_tag" in value ? String(value._tag) : "event" +const renderInputContract = ( + title: string, + schema: InputSchema, + omit: ReadonlyArray = [] +): HTMLElement => { + const section = createElement("section", "inspector-section input-contract") + section.append(inspectionSection(title)) + const projected = projectInputSchema(schema) + const fields = createElement("div", "input-contract-fields") + const omitted = new Set(omit) + if (projected._tag === "Object") { + projected.fields.forEach(({ key, required, field }) => { + const child = renderContractField(key, required, field, omitted) + if (child !== null) fields.append(child) + }) + } else { + const child = renderContractField("value", true, projected, omitted) + if (child !== null) fields.append(child) + } + if (fields.childElementCount === 0) { + fields.append(createElement("p", "section-empty", "No additional data fields.")) + } + section.append(fields) + return section +} + +const contractSummary = (schema: InputSchema | null): string | null => { + if (schema === null) return null + const field = projectInputSchema(schema) + if (field._tag !== "Object") return inputType(field) + const fields = field.fields.filter(({ key }) => key !== "_tag") + if (fields.length === 0) return null + return fields.slice(0, 3).map(({ key, required, field }) => `${key}${required ? "" : "?"}: ${inputType(field)}`).join( + " · " + ) + (fields.length > 3 ? ` · +${fields.length - 3}` : "") +} + +const triggerName = (trigger: Trigger): string => { + switch (trigger.type) { + case "event": + return trigger.event + case "always": + return "Always" + case "done": + return "Completion" + case "choice": + return "Choice" + case "invoke": + return `${trigger.id} · ${trigger.outcome}` + } +} + +const decisionLabel = (decision: MachineWalkthrough.Decision): string => { + switch (decision) { + case "conditional-branch": + return "Choose branch" + case "declinable-transition": + return "Assume accepted" + case "automatic-trigger": + return "Advance automatic trigger" + case "invoke-outcome": + return "Choose invoke outcome" + } +} + +const unavailableLabel = (reason: MachineWalkthrough.UnavailableReason): string => + reason === "history-unavailable" + ? "No history has been recorded for this target yet" + : "The target is resolved only at runtime" const activeTopology = (paths: ReadonlyArray): string => { const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`))) @@ -186,68 +389,85 @@ const activeTopology = (paths: ReadonlyArray): string => { export const renderVisualizer = ( root: HTMLElement, - machineKey: string, + _machineKey: string, visualization: VisualizationDocument, diagnostics: ReadonlyArray = [] ): void => { const model = makeVisualizerModel(visualization) - const rows = new Map() - const nodes = new Map() - const statuses = new Map() - const eventButtons = new Map() + const transitionsById = new Map(visualization.transitions.map((transition) => [transition.id, transition])) const eventSchemas = new Map(visualization.inputs.events.map(({ event, schema }) => [event, schema])) - const relatedPaths = new Set() + const relatedFrom = new Set() + const relatedTo = new Set() + const incomingTransitions = new Set() + const outgoingTransitions = new Set() let selectedPath: string | undefined - let selectedEvent: string | undefined - let selectedFrame: SimulationFrame | undefined - let simulation: SimulationReady | undefined - let simulationPending = false - let activeInputForm: InputForm | undefined + let selectedTransition: string | undefined + let selectedFrame: MachineWalkthrough.Frame | undefined + let walkthrough: MachineWalkthrough.Session | undefined + let chartView: ChartView | undefined - const activePaths = (): ReadonlyArray => simulation?.current.activePaths ?? model.activePaths - const candidateEvents = (): ReadonlyArray => simulation?.current.candidateEvents ?? model.candidateEvents + const activePaths = (): ReadonlyArray => + walkthrough === undefined ? model.activePaths : MachineWalkthrough.current(walkthrough).after.activePaths + const availableChoices = (): ReadonlyArray => + walkthrough === undefined ? [] : MachineWalkthrough.choices(walkthrough) const shell = createElement("main", "app-shell") const workspace = createElement("section", "workspace") - const treePanel = createElement("section", "tree-panel") - treePanel.setAttribute("aria-label", `${model.machineId} topology`) + const chartPanel = createElement("section", "chart-panel") + chartPanel.setAttribute("aria-label", `${model.machineId} topology`) const inspector = createElement("aside", "inspector") inspector.setAttribute("aria-live", "polite") + inspector.setAttribute("aria-label", "Selection details") + inspector.hidden = true + const inspectorClose = createElement("button", "inspector-close", "Close") + inspectorClose.type = "button" + inspectorClose.setAttribute("aria-label", "Close details") + const inspectorContent = createElement("div", "inspector-content") + inspector.append(inspectorClose, inspectorContent) + const choicePicker = createElement("section", "transition-picker") + choicePicker.setAttribute("role", "dialog") + choicePicker.setAttribute("aria-label", "Available transitions") + choicePicker.hidden = true + const choicePickerHeader = createElement("div", "transition-picker-header") + const choicePickerTitle = createElement("strong") + const choicePickerClose = createElement("button", "transition-picker-close", "Close") + choicePickerClose.type = "button" + const choicePickerContent = createElement("div", "transition-picker-content") + choicePickerHeader.append(choicePickerTitle, choicePickerClose) + choicePicker.append(choicePickerHeader, choicePickerContent) const clearButton = createElement("button", "toolbar-button", "Clear selection") clearButton.type = "button" clearButton.disabled = true - const expandButton = createElement("button", "toolbar-button", "Expand all") - expandButton.type = "button" - const collapseButton = createElement("button", "toolbar-button", "Collapse all") - collapseButton.type = "button" + const detailsButton = createElement("button", "toolbar-button", "View details") + detailsButton.type = "button" + detailsButton.disabled = true const revealActiveButton = createElement("button", "toolbar-button", "Reveal active") revealActiveButton.type = "button" revealActiveButton.disabled = model.activePaths.length === 0 - const simulationButton = createElement("button", "toolbar-button", "Start simulation") - simulationButton.type = "button" - 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")) - summary.append(createElement("h2", undefined, visualization.machineId)) - summary.append(metadata([ - ["Source", visualization.source?.file ?? "in memory"], - ["Export", visualization.source?.exportName ?? "none"], - ["Initial", visualization.initial.target], - ["Selection", visualization.initial.selection.kind], - ["Revision", String(visualization.revision)] - ])) - summary.append(createElement("p", undefined, "Select a state to inspect its transitions and activities.")) - inspector.append(summary) + const walkthroughButton = createElement("button", "toolbar-button", "Start simulation") + walkthroughButton.type = "button" + walkthroughButton.disabled = model.roots.length === 0 + + const hideInspector = (): void => { + inspectorContent.replaceChildren() + inspector.hidden = true + detailsButton.textContent = "View details" + } + + const showInspector = (): void => { + inspector.hidden = false + detailsButton.textContent = "Hide details" + } + + const closeChoicePicker = (): void => { + choicePicker.hidden = true + choicePickerContent.replaceChildren() } const renderInspection = (inspection: StateInspection): void => { - activeInputForm = undefined - inspector.replaceChildren() + inspectorContent.replaceChildren() + showInspector() const header = createElement("header", "inspector-header") const breadcrumbs = createElement("nav", "breadcrumbs") breadcrumbs.setAttribute("aria-label", "State path") @@ -255,17 +475,20 @@ export const renderVisualizer = ( if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/")) breadcrumbs.append(stateLink(item.path, item.label, navigateToState)) }) - const eyebrow = createElement("div", "inspector-eyebrow") - eyebrow.append(badge(inspection.state.type, "state")) - if (activePaths().includes(inspection.state.path)) eyebrow.append(badge("active", "active")) - if (inspection.initial) eyebrow.append(badge("initial", "initial")) - header.append(breadcrumbs, eyebrow, createElement("h2", undefined, inspection.label)) + const titleRow = createElement("div", "inspector-title-row") + const title = createElement("div", "inspector-state-title") + title.append( + stateStatus(activePaths().includes(inspection.state.path), inspection.initial), + createElement("h2", undefined, inspection.label) + ) + titleRow.append(title, badge(inspection.state.type, "state")) + header.append(breadcrumbs, titleRow) header.append(metadata([ ["Path", inspection.state.path], - ["Parent", inspection.state.parent ?? "root"], - ["Children", String(inspection.state.children.length)], - ["Initial child", inspection.state.initial ?? "none"], - ["History", inspection.state.history ?? "none"] + ["Parent", inspection.state.parent], + ["Children", inspection.state.children.length > 0 ? String(inspection.state.children.length) : null], + ["Initial child", inspection.state.initial], + ["History", inspection.state.history] ])) if (inspection.state.description !== null || inspection.state.documentation !== null) { const annotations = createElement("div", "state-annotations") @@ -277,401 +500,200 @@ export const renderVisualizer = ( } header.append(annotations) } - inspector.append(header) + inspectorContent.append(header) - const transitions = createElement("section", "inspector-section") - transitions.append(inspectionSection("Transitions", inspection.outgoing.length)) - if (inspection.outgoing.length === 0) { - transitions.append(createElement("p", "section-empty", "No transitions leave this state.")) - } else { - inspection.outgoing.forEach((transition) => transitions.append(renderTransition(transition, navigateToState))) + if (inspection.outgoing.length > 0) { + const transitions = createElement("section", "inspector-section") + transitions.append(inspectionSection("Transitions", inspection.outgoing.length)) + inspection.outgoing.forEach((transition) => + transitions.append(renderTransition(visualization, transition, navigateToState)) + ) + inspectorContent.append(transitions) } - inspector.append(transitions) - - const incoming = createElement("section", "inspector-section") - incoming.append(inspectionSection("Entered by", inspection.incoming.length)) - if (inspection.incoming.length === 0) { - incoming.append(createElement("p", "section-empty", "No transitions target this state.")) - } else { + if (inspection.incoming.length > 0) { + const incoming = createElement("section", "inspector-section") + incoming.append(inspectionSection("Entered by", inspection.incoming.length)) inspection.incoming.forEach((transition) => - incoming.append(renderIncomingTransition(transition, navigateToState)) + incoming.append(renderIncomingTransition(visualization, transition, navigateToState)) ) + inspectorContent.append(incoming) } - inspector.append(incoming) - if (inspection.activities.length > 0) { const activities = createElement("section", "inspector-section") - activities.append(inspectionSection("Activities", inspection.activities.length)) - inspection.activities.forEach((activity) => activities.append(renderActivity(activity))) - inspector.append(activities) + activities.append(inspectionSection("Invoked", inspection.activities.length)) + inspection.activities.forEach((activity) => activities.append(renderActivity(activity, navigateToState))) + inspectorContent.append(activities) } } - const renderEventInspection = (inspection: EventInspection): void => { - activeInputForm = undefined - inspector.replaceChildren() + const renderTransitionInspection = (transition: VisualizationTransition): void => { + inspectorContent.replaceChildren() + showInspector() const header = createElement("header", "inspector-header") - const eyebrow = createElement("div", "inspector-eyebrow") - eyebrow.append(badge("event", "trigger")) - const candidate = candidateEvents().includes(inspection.event) - if (candidate) eyebrow.append(badge("enabled", "active")) - header.append(eyebrow, createElement("h2", undefined, inspection.event)) + const titleRow = createElement("div", "inspector-title-row") + const flags = createElement("div", "card-flags") + flags.append(badge(transition.trigger.type, "trigger")) + if (transition.reenter) flags.append(badge("reenter")) + if (transition.acceptance === "declinable") flags.append(badge("declinable")) + titleRow.append(createElement("h2", undefined, triggerLabel(transition)), flags) + header.append(titleRow) header.append(metadata([ - ["Status", candidate ? "enabled" : "not enabled"], - ["Registrations", String(inspection.transitions.length)] + ["Source", transition.source], + ["Branches", transition.branches.length > 1 ? String(transition.branches.length) : null], + ["Acceptance", transition.acceptance === "declinable" ? transition.acceptance : null], + ["Reenter", transition.reenter ? "yes" : null] ])) - 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"] - }) - activeInputForm = input - 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) + inspectorContent.append(header) + if (transition.trigger.type === "event") { + const schema = eventSchemas.get(transition.trigger.event) + if (schema !== undefined) inspectorContent.append(renderInputContract("Data contract", schema, ["_tag"])) + } + if (transition.branches.length > 0) { + const details = createElement("section", "inspector-section") + details.append(inspectionSection("Branches", transition.branches.length)) + const branches = createElement("div", "inspection-card branch-list") + transition.branches.forEach((branch) => + branches.append(renderBranch(visualization, transition.source, branch, navigateToState)) + ) + details.append(branches) + inspectorContent.append(details) } - - 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) - } - - const renderSimulationFailure = (failureDiagnostics: ReadonlyArray): void => { - activeInputForm = undefined - 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))) + if (paths.length === 0) values.append(createElement("span", "section-empty", "none")) 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 => { - activeInputForm = undefined - inspector.replaceChildren() + const renderWalkthroughTrace = (frame: MachineWalkthrough.Frame): void => { + inspectorContent.replaceChildren() + showInspector() 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)}` + eyebrow.append(badge("walkthrough", "active")) + const title = frame.choice === null + ? "Initial configuration" + : frame.choice.title ?? triggerName(frame.choice.trigger) 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)] + [ + "Result", + frame.changed + ? `${activeTopology(frame.before.activePaths)} → ${activeTopology(frame.after.activePaths)}` + : "Topology unchanged" + ] ])) - const received = frame.trigger._tag === "Initial" ? frame.trigger.input : frame.trigger.event - if (received !== undefined) header.append(jsonBlock(received)) - inspector.append(header) + inspectorContent.append(header) const topology = createElement("section", "inspector-section trace-topology") - topology.append(inspectionSection("Topology change", frame.microsteps.length)) topology.append( + inspectionSection("Topology"), renderPathGroup("Before", frame.before.activePaths), + renderPathGroup("Exit", frame.exitPaths), + renderPathGroup("Entry", frame.entryPaths), 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) - } - } + inspectorContent.append(topology) - const renderStartSimulation = (): void => { - activeInputForm = undefined - 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(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" }) - activeInputForm = form - 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 result = form.read() - if (!result.ok) return - const request: SimulationRequest = { - _tag: "StartSimulation", - protocolVersion, - key: machineKey, - revision: visualization.revision, - source, - input: result.value as never - } - void runSimulation(request) - }) - form.element.append(start) - composer.append( - form.element, - createElement( - "p", - "simulation-note", - "Initialization and synchronous callbacks run in isolation. Runtime activities and planned commands are not started." - ) - ) - inspector.append(composer) - } - - 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 - simulationFeedback.textContent = result.diagnostics[0]?.message ?? "Simulation failed" - simulationFeedback.dataset.status = "error" - renderSimulationFailure(result.diagnostics) - return - } - simulation = result - activeInputForm = undefined - 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 - const before = activeTopology(result.frame.before.activePaths) - const after = activeTopology(result.frame.after.activePaths) - simulationFeedback.textContent = result.frame.trigger._tag === "Initial" - ? `Started in ${after}` - : result.frame.microsteps.length === 0 - ? `${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 - 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 + if (frame.choice === null) { + if (visualization.inputs.machine !== null) { + inspectorContent.append(renderInputContract("Machine input contract", visualization.inputs.machine)) } - simulationFeedback.textContent = failure.message - simulationFeedback.dataset.status = "error" - renderSimulationFailure([failure]) - } finally { - simulationPending = false - activeInputForm?.setPending(false) - updateSimulationUi() + return } + + const choice = frame.choice + const transition = transitionsById.get(choice.transitionId) + const branch = transition?.branches.find(({ id }) => id === choice.branchId) + const api = branch === undefined ? undefined : branchTargetApi(visualization, choice.source, branch) + const selection = createElement("section", "inspector-section") + selection.append(inspectionSection("Selected branch")) + const card = createElement("article", "inspection-card trace-card") + const cardHeader = createElement("div", "card-header") + const cardTitle = createElement("div", "card-title") + cardTitle.append(createElement("strong", undefined, triggerName(choice.trigger))) + const flags = createElement("div", "card-flags") + choice.decisions.forEach((decision) => flags.append(badge(decisionLabel(decision), "condition"))) + cardHeader.append(cardTitle, flags) + card.append(cardHeader) + card.append(metadata([ + ["Title", choice.title], + ["Source", stateLink(choice.source, choice.source, navigateToState)], + ["Target", choice.target === null ? "No target" : stateLink(choice.target, choice.target, navigateToState)], + ["API", api] + ])) + if (choice.updates.length > 0) card.append(renderPathGroup("Updates", choice.updates)) + selection.append(card) + inspectorContent.append(selection) + if (choice.input !== null) inspectorContent.append(renderInputContract("Data contract", choice.input, ["_tag"])) } const clearRelations = (): void => { - for (const path of relatedPaths) { - nodes.get(path)?.classList.remove("is-related-source", "is-related-target", "is-related-update") + relatedFrom.clear() + relatedTo.clear() + incomingTransitions.clear() + outgoingTransitions.clear() + } + + const markFrame = (frame: MachineWalkthrough.Frame): void => { + clearRelations() + if (frame.choice === null) return + relatedFrom.add(frame.choice.source) + outgoingTransitions.add(frame.choice.transitionId) + if (frame.choice.target !== null) relatedTo.add(frame.choice.target) + } + + const chartPresentation = (): ChartPresentation => { + const choices = availableChoices() + const usable = choices.filter(({ unavailableReason }) => unavailableReason === null) + return { + simulationMode: walkthrough !== undefined, + activePaths: activePaths(), + selectedState: selectedPath ?? null, + selectedTransition: selectedTransition ?? null, + fromPaths: [...relatedFrom], + toPaths: [...relatedTo], + incomingTransitionIds: [...incomingTransitions], + outgoingTransitionIds: [...outgoingTransitions], + availableBranchIds: usable.map(({ branchId }) => branchId), + unavailableBranchIds: choices.filter(({ unavailableReason }) => unavailableReason !== null).map(( + { branchId } + ) => branchId) } - relatedPaths.clear() } + const updateChartPresentation = (): void => chartView?.update(chartPresentation()) + const clearSelection = (): void => { - if (selectedPath !== undefined) { - nodes.get(selectedPath)?.classList.remove("is-selected") - rows.get(selectedPath)?.setAttribute("aria-selected", "false") + if (walkthrough !== undefined) { + closeChoicePicker() + return } - if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") clearRelations() selectedPath = undefined - selectedEvent = undefined + selectedTransition = undefined selectedFrame = undefined clearButton.disabled = true - renderEmptyInspector() + detailsButton.disabled = true + hideInspector() + updateChartPresentation() } - const markTransitions = (transitions: ReadonlyArray): void => { + const markTransitions = ( + transitions: ReadonlyArray, + includeSource = true + ): void => { for (const transition of transitions) { - relatedPaths.add(transition.source) - nodes.get(transition.source)?.classList.add("is-related-source") + if (includeSource) relatedFrom.add(transition.source) + outgoingTransitions.add(transition.id) for (const branch of transition.branches) { - if (branch.target !== null) { - relatedPaths.add(branch.target) - nodes.get(branch.target)?.classList.add("is-related-target") - } - for (const update of branch.updates) { - relatedPaths.add(update) - nodes.get(update)?.classList.add("is-related-update") - } + if (branch.target !== null) relatedTo.add(branch.target) } } } @@ -679,160 +701,269 @@ export const renderVisualizer = ( const markRelatedStates = (inspection: StateInspection): void => { clearRelations() for (const incoming of inspection.incoming) { - relatedPaths.add(incoming.transition.source) - nodes.get(incoming.transition.source)?.classList.add("is-related-source") - } - markTransitions(inspection.outgoing) - } - - const expandAncestors = (inspection: StateInspection): void => { - for (const ancestor of inspection.breadcrumbs.slice(0, -1)) { - const row = rows.get(ancestor.path) - const children = nodes.get(ancestor.path)?.querySelector(":scope > .topology-children") - if (row === undefined || children === null || children === undefined) continue - row.setAttribute("aria-expanded", "true") - children.hidden = false - const disclosure = row.querySelector(".state-disclosure") - if (disclosure !== null) disclosure.textContent = "▾" + relatedFrom.add(incoming.transition.source) + incomingTransitions.add(incoming.transition.id) } + markTransitions(inspection.outgoing, false) } const selectState = (path: string, focus: boolean): void => { + if (walkthrough !== undefined) return const inspection = model.inspectState(path) if (inspection === undefined) return - expandAncestors(inspection) - 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 = path - selectedEvent = undefined + selectedTransition = undefined selectedFrame = undefined - nodes.get(path)?.classList.add("is-selected") - rows.get(path)?.setAttribute("aria-selected", "true") markRelatedStates(inspection) clearButton.disabled = false - renderInspection(inspection) - if (focus) { - rows.get(path)?.focus({ preventScroll: true }) - rows.get(path)?.scrollIntoView({ block: "nearest" }) - } + detailsButton.disabled = false + if (!inspector.hidden) renderInspection(inspection) + updateChartPresentation() + if (focus) chartView?.focusState(path) } function navigateToState(path: string): void { selectState(path, true) } - const selectEvent = (event: string): void => { - const inspection = model.inspectEvent(event) - 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") + const selectTransition = (transitionId: string): void => { + if (walkthrough !== undefined) return + const transition = transitionsById.get(transitionId) + if (transition === undefined) return selectedPath = undefined - selectedEvent = event + selectedTransition = transitionId selectedFrame = undefined - eventButtons.get(event)?.classList.add("is-selected") clearRelations() - markTransitions(inspection.transitions) + markTransitions([transition]) 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 - } - } + detailsButton.disabled = false + if (!inspector.hidden) renderTransitionInspection(transition) + updateChartPresentation() + } + + const openStateDetails = (path: string): void => { + if (walkthrough !== undefined) return + selectState(path, false) + const inspection = model.inspectState(path) + if (inspection !== undefined) renderInspection(inspection) + } + + const openTransitionDetails = (transitionId: string): void => { + if (walkthrough !== undefined) return + selectTransition(transitionId) + const transition = transitionsById.get(transitionId) + if (transition !== undefined) renderTransitionInspection(transition) + } + + const walkthroughDock = createElement("section", "walkthrough-dock") + walkthroughDock.hidden = true + const walkthroughHeading = createElement("div", "walkthrough-heading") + const walkthroughIdentity = createElement("div", "walkthrough-identity") + const walkthroughHint = createElement("span", "walkthrough-hint") + const walkthroughStatus = createElement("span", "walkthrough-status") + const walkthroughTimeline = createElement("div", "walkthrough-timeline") + walkthroughTimeline.setAttribute("aria-label", "Simulation timeline") + walkthroughIdentity.append(createElement("strong", undefined, "Simulation"), walkthroughHint) + walkthroughHeading.append(walkthroughIdentity, walkthroughStatus) + walkthroughDock.append(walkthroughHeading, walkthroughTimeline) + + const selectFrame = (frame: MachineWalkthrough.Frame): void => { + selectedPath = undefined + selectedTransition = undefined + selectedFrame = frame + markFrame(frame) + clearButton.disabled = false + detailsButton.disabled = false + } + + const choiceRoute = (choice: MachineWalkthrough.Choice): string => + `${choice.source} → ${choice.target ?? "no target"}` + + const takeChoice = (choice: MachineWalkthrough.Choice): void => { + if (walkthrough === undefined || choice.unavailableReason !== null) return + const result = MachineWalkthrough.take(walkthrough, choice.id) + if (Result.isFailure(result)) return + walkthrough = result.success + selectFrame(MachineWalkthrough.current(walkthrough)) + closeChoicePicker() + updateWalkthroughUi() + chartView?.revealStates(activePaths()) + } + + const renderPickerChoice = (choice: MachineWalkthrough.Choice): HTMLButtonElement => { + const button = createElement( + "button", + `transition-picker-choice${choice.unavailableReason === null ? "" : " is-unavailable"}` + ) + button.type = "button" + button.disabled = choice.unavailableReason !== null + const main = createElement("span", "transition-picker-choice-main") + main.append(createElement("strong", undefined, triggerName(choice.trigger))) + if (choice.title !== null) main.append(createElement("span", "transition-picker-choice-title", choice.title)) + button.append(main, createElement("span", "transition-picker-choice-route", choiceRoute(choice))) + const contract = contractSummary(choice.input) + if (contract !== null) button.append(createElement("span", "transition-picker-choice-contract", contract)) + if (choice.decisions.length > 0) { + button.append(createElement( + "span", + "transition-picker-choice-decision", + choice.decisions.map(decisionLabel).join(" · ") + )) } - renderEventInspection(inspection) - } - - const setExpanded = (row: HTMLElement, expanded: boolean): void => { - const node = row.closest(".topology-node") - const children = node?.querySelector(":scope > .topology-children") - if (children === null || children === undefined) return - row.setAttribute("aria-expanded", String(expanded)) - children.hidden = !expanded - const disclosure = row.querySelector(".state-disclosure") - if (disclosure !== null) disclosure.textContent = expanded ? "▾" : "▸" - } - - const renderNode = (node: TopologyNode, depth: number): HTMLElement => { - const container = createElement("div", "topology-node") - container.dataset.statePath = node.path - nodes.set(node.path, container) - - const row = createElement("button", "state-row") - row.type = "button" - row.tabIndex = -1 - row.setAttribute("role", "treeitem") - row.setAttribute("aria-level", String(depth + 1)) - row.setAttribute("aria-selected", "false") - row.style.setProperty("--depth", String(depth)) - row.dataset.statePath = node.path - rows.set(node.path, row) - - const disclosure = createElement("span", "state-disclosure", node.children.length === 0 ? "" : "▾") - const status = createElement("span", `state-status${node.active ? " is-active" : ""}`) - status.setAttribute("aria-label", node.active ? "active" : "inactive") - statuses.set(node.path, status) - const label = createElement("span", "state-label", node.label) - const markers = createElement("span", "state-markers") - if (node.initial) markers.append(badge("initial", "initial")) - if (node.type !== "atomic") markers.append(badge(node.type, "state")) - if (node.transitionCount > 0) markers.append(badge(`${node.transitionCount}t`, "count")) - if (node.activityCount > 0) markers.append(badge(`${node.activityCount}a`, "count")) - row.append(disclosure, status, label, markers) - container.append(row) - row.addEventListener("focus", () => { - rows.forEach((candidate) => candidate.tabIndex = candidate === row ? 0 : -1) + if (choice.unavailableReason !== null) { + button.title = unavailableLabel(choice.unavailableReason) + button.append(createElement( + "span", + "transition-picker-choice-unavailable", + unavailableLabel(choice.unavailableReason) + )) + } else { + button.title = choiceRoute(choice) + button.addEventListener("click", () => takeChoice(choice)) + } + return button + } + + const showChoicePicker = ( + title: string, + choices: ReadonlyArray, + anchor: ChartInteractionAnchor + ): void => { + if (choices.length === 1 && choices[0]?.unavailableReason === null) { + takeChoice(choices[0]) + return + } + if (choices.length === 0) { + closeChoicePicker() + return + } + choicePickerTitle.textContent = title + choicePickerContent.replaceChildren(...choices.map(renderPickerChoice)) + choicePicker.style.left = `${anchor.x + 12}px` + choicePicker.style.top = `${anchor.y + 12}px` + choicePicker.hidden = false + requestAnimationFrame(() => { + const bounds = choicePicker.getBoundingClientRect() + choicePicker.style.left = `${Math.max(10, Math.min(anchor.x + 12, window.innerWidth - bounds.width - 10))}px` + choicePicker.style.top = `${Math.max(58, Math.min(anchor.y + 12, window.innerHeight - bounds.height - 10))}px` }) + } - if (node.children.length > 0) { - const children = createElement("div", "topology-children") - children.setAttribute("role", "group") - node.children.forEach((child) => children.append(renderNode(child, depth + 1))) - container.append(children) - row.setAttribute("aria-expanded", "true") - row.addEventListener("click", () => { - const expanded = row.getAttribute("aria-expanded") === "true" - setExpanded(row, !expanded) - selectState(node.path, false) - }) - } else { - row.addEventListener("click", () => selectState(node.path, false)) + const handleStateClick = (path: string, _anchor: ChartInteractionAnchor): void => { + if (walkthrough === undefined) { + selectState(path, false) + return + } + closeChoicePicker() + } + + const handleTransitionClick = ( + transitionId: string, + branchIds: ReadonlyArray, + anchor: ChartInteractionAnchor + ): void => { + if (walkthrough === undefined) { + selectTransition(transitionId) + return } - return container + const transition = transitionsById.get(transitionId) + if (transition === undefined) return + showChoicePicker( + triggerLabel(transition), + MachineWalkthrough.choices(walkthrough).filter((choice) => branchIds.includes(choice.branchId)), + anchor + ) } - const setAllExpanded = (expanded: boolean): void => { - treePanel.querySelectorAll(".state-row[aria-expanded]").forEach((row) => { - setExpanded(row, expanded) + const renderWalkthroughDock = (): void => { + walkthroughDock.hidden = walkthrough === undefined + chartPanel.classList.toggle("has-walkthrough", walkthrough !== undefined) + if (walkthrough === undefined) { + walkthroughTimeline.replaceChildren() + return + } + const cursor = MachineWalkthrough.cursor(walkthrough) + const timeline = MachineWalkthrough.timeline(walkthrough) + const choices = MachineWalkthrough.choices(walkthrough) + walkthroughStatus.textContent = `Step ${cursor} · ${activeTopology(activePaths())}` + walkthroughHint.textContent = choices.length === 0 + ? "No outgoing transitions" + : "Click a highlighted transition to advance" + walkthroughTimeline.replaceChildren() + timeline.forEach((frame) => { + const label = frame.choice === null ? "Initial" : frame.choice.title ?? triggerName(frame.choice.trigger) + const button = createElement( + "button", + `walkthrough-step${frame.step === cursor ? " is-current" : ""}`, + `${frame.step} · ${label}` + ) + button.type = "button" + button.addEventListener("click", () => { + if (walkthrough === undefined) return + const result = MachineWalkthrough.seek(walkthrough, frame.step) + if (Result.isFailure(result)) return + walkthrough = result.success + selectFrame(MachineWalkthrough.current(walkthrough)) + closeChoicePicker() + updateWalkthroughUi() + chartView?.revealStates(activePaths()) + }) + walkthroughTimeline.append(button) }) } clearButton.addEventListener("click", clearSelection) - expandButton.addEventListener("click", () => setAllExpanded(true)) - collapseButton.addEventListener("click", () => setAllExpanded(false)) - revealActiveButton.addEventListener("click", () => { - const deepest = [...activePaths()].sort((left, right) => right.split(".").length - left.split(".").length)[0] - if (deepest !== undefined) navigateToState(deepest) + inspectorClose.addEventListener("click", hideInspector) + choicePickerClose.addEventListener("click", closeChoicePicker) + detailsButton.addEventListener("click", () => { + if (!inspector.hidden) { + hideInspector() + } else if (selectedFrame !== undefined) { + renderWalkthroughTrace(selectedFrame) + } else if (selectedPath !== undefined) { + const inspection = model.inspectState(selectedPath) + if (inspection !== undefined) renderInspection(inspection) + } else if (selectedTransition !== undefined) { + const transition = transitionsById.get(selectedTransition) + if (transition !== undefined) renderTransitionInspection(transition) + } }) + revealActiveButton.addEventListener("click", () => chartView?.revealStates(activePaths())) + + const zoomOutButton = createElement("button", "toolbar-button zoom-button", "−") + zoomOutButton.type = "button" + zoomOutButton.setAttribute("aria-label", "Zoom out") + const zoomResetButton = createElement("button", "toolbar-button zoom-value", "100%") + zoomResetButton.type = "button" + zoomResetButton.setAttribute("aria-label", "Reset zoom") + const zoomInButton = createElement("button", "toolbar-button zoom-button", "+") + zoomInButton.type = "button" + zoomInButton.setAttribute("aria-label", "Zoom in") + const zoomFitButton = createElement("button", "toolbar-button zoom-button", "Fit") + zoomFitButton.type = "button" + zoomFitButton.setAttribute("aria-label", "Fit chart") + const zoomButtons = [zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton] + zoomButtons.forEach((button) => button.disabled = true) + + const updateZoomControls = (): void => { + const zoom = chartView?.getZoom() + zoomResetButton.textContent = `${Math.round((zoom ?? 1) * 100)}%` + zoomOutButton.disabled = zoom === undefined || zoom <= minimumChartZoom + zoomResetButton.disabled = zoom === undefined || zoom === 1 + zoomInButton.disabled = zoom === undefined || zoom >= maximumChartZoom + zoomFitButton.disabled = zoom === undefined + } + + const changeZoom = (next: (view: ChartView) => number): void => { + if (chartView === undefined) return + next(chartView) + updateZoomControls() + } + + zoomOutButton.addEventListener("click", () => changeZoom((view) => view.setZoom(view.getZoom() - 0.1))) + zoomResetButton.addEventListener("click", () => changeZoom((view) => view.setZoom(1))) + zoomInButton.addEventListener("click", () => changeZoom((view) => view.setZoom(view.getZoom() + 0.1))) + zoomFitButton.addEventListener("click", () => changeZoom((view) => view.fit())) const toolbar = createElement("div", "toolbar") const runtime = createElement("div", "runtime-summary") @@ -840,164 +971,69 @@ export const renderVisualizer = ( const runtimeText = createElement("span") runtime.append(runtimeDot, runtimeText) const toolbarActions = createElement("div", "toolbar-actions") - toolbarActions.append(clearButton, simulationButton, revealActiveButton, expandButton, collapseButton) + toolbarActions.append(clearButton, detailsButton, walkthroughButton, revealActiveButton) + const zoomControls = createElement("div", "zoom-controls") + zoomControls.setAttribute("role", "group") + zoomControls.setAttribute("aria-label", "Chart zoom") + zoomControls.append(zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton) toolbar.append(runtime, toolbarActions) - const tree = createElement("div", "topology-tree") - tree.setAttribute("role", "tree") - tree.setAttribute("aria-label", `${model.machineId} states`) - tree.append(createElement("div", "machine-id", model.machineId)) - const events = createElement("div", "enabled-events") - const simulationFeedback = createElement("div", "simulation-feedback") - simulationFeedback.setAttribute("role", "status") - tree.append(events, simulationFeedback) - - const renderEventButtons = (): void => { - events.replaceChildren(createElement("span", "enabled-events-label", "Enabled")) - eventButtons.clear() - const candidates = candidateEvents() - if (candidates.length === 0) { - events.append(createElement("span", "enabled-events-empty", "none")) - return - } - candidates.forEach((event) => { - const button = createElement("button", `event-button${event === selectedEvent ? " is-selected" : ""}`, event) - button.type = "button" - button.disabled = simulationPending - button.addEventListener("click", () => selectEvent(event)) - eventButtons.set(event, button) - events.append(button) - }) - } + const chart = createElement("div", "topology-chart") + chart.setAttribute("role", "region") + chart.setAttribute("aria-label", `${model.machineId} states`) + const chartHost = createElement("div", "chart-host") + chartHost.append(createElement("div", "chart-loading", "Computing layout…")) + chart.append(chartHost) - const updateSimulationUi = (): void => { + const updateWalkthroughUi = (): void => { const active = new Set(activePaths()) - statuses.forEach((status, path) => { - const isActive = active.has(path) - status.classList.toggle("is-active", isActive) - status.setAttribute("aria-label", isActive ? "active" : "inactive") - }) - const hasRuntimeState = simulation !== undefined || model.hasSnapshot + const simulating = walkthrough !== undefined + updateChartPresentation() + const hasRuntimeState = simulating || model.hasSnapshot runtimeDot.classList.toggle("has-snapshot", hasRuntimeState) - runtimeText.textContent = simulation !== undefined - ? `${active.size} active · step ${simulation.step}` + runtimeText.textContent = walkthrough !== undefined + ? `Simulation · ${active.size} active · step ${MachineWalkthrough.cursor(walkthrough)}` : 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 + walkthroughButton.textContent = simulating ? "Exit simulation" : "Start simulation" + walkthroughButton.disabled = model.roots.length === 0 revealActiveButton.disabled = active.size === 0 - events.hidden = !hasRuntimeState - simulationFeedback.hidden = simulation === undefined && !simulationPending && simulationFeedback.textContent === "" - renderEventButtons() - if (selectedFrame !== undefined) { - renderSimulationTrace(selectedFrame) - } else if (selectedPath !== undefined) { - const inspection = model.inspectState(selectedPath) - if (inspection !== undefined) renderInspection(inspection) - } else if (selectedEvent !== undefined && activeInputForm === undefined) { - renderEventInspection(model.inspectEvent(selectedEvent)) + clearButton.hidden = simulating + detailsButton.hidden = simulating + chartPanel.classList.toggle("is-simulating", simulating) + if (simulating) hideInspector() + renderWalkthroughDock() + if (!simulating && !inspector.hidden) { + if (selectedFrame !== undefined) { + renderWalkthroughTrace(selectedFrame) + } else if (selectedPath !== undefined) { + const inspection = model.inspectState(selectedPath) + if (inspection !== undefined) renderInspection(inspection) + } else if (selectedTransition !== undefined) { + const transition = transitionsById.get(selectedTransition) + if (transition !== undefined) renderTransitionInspection(transition) + } } } - simulationButton.addEventListener("click", () => { - if (simulation === undefined) { - selectedPath = undefined - selectedEvent = undefined - selectedFrame = undefined - clearRelations() - clearButton.disabled = false - if (visualization.inputs.machine === null && visualization.source !== null) { - void runSimulation({ - _tag: "StartSimulation", - protocolVersion, - key: machineKey, - revision: visualization.revision, - source: visualization.source - }) - } else { - renderStartSimulation() - } + walkthroughButton.addEventListener("click", () => { + if (walkthrough === undefined) { + walkthrough = MachineWalkthrough.start(visualization) + selectFrame(MachineWalkthrough.current(walkthrough)) + hideInspector() + closeChoicePicker() + requestAnimationFrame(() => chartView?.revealStates(activePaths())) } else { - simulation = undefined - selectedFrame = undefined - simulationFeedback.textContent = "" - delete simulationFeedback.dataset.status + walkthrough = undefined + closeChoicePicker() clearSelection() } - updateSimulationUi() - }) - if (model.roots.length === 0) { - const empty = createElement("div", "topology-empty") - empty.append( - createElement("strong", undefined, "No states yet"), - createElement("span", undefined, "The topology will appear as the machine definition becomes available.") - ) - tree.append(empty) - } else { - model.roots.forEach((node) => tree.append(renderNode(node, 0))) - } - updateSimulationUi() - rows.values().next().value?.setAttribute("tabindex", "0") - tree.addEventListener("keydown", (event) => { - const current = event.target instanceof HTMLElement ? event.target.closest(".state-row") : null - if (current === null) return - const visible = [...rows.values()].filter((row) => row.getClientRects().length > 0) - const index = visible.indexOf(current) - const focus = (row: HTMLElement | undefined): void => { - if (row === undefined) return - event.preventDefault() - row.focus() - } - switch (event.key) { - case "ArrowDown": - focus(visible[index + 1]) - break - case "ArrowUp": - focus(visible[index - 1]) - break - case "Home": - focus(visible[0]) - break - case "End": - focus(visible.at(-1)) - break - case "ArrowRight": { - if (current.getAttribute("aria-expanded") === "false") { - event.preventDefault() - setExpanded(current, true) - } else { - const child = current.closest(".topology-node") - ?.querySelector(":scope > .topology-children > .topology-node > .state-row") - focus(child ?? undefined) - } - break - } - case "ArrowLeft": { - if (current.getAttribute("aria-expanded") === "true") { - event.preventDefault() - setExpanded(current, false) - } else { - const parent = current.closest(".topology-children") - ?.closest(".topology-node") - ?.querySelector(":scope > .state-row") - focus(parent ?? undefined) - } - break - } - case "Enter": - case " ": - event.preventDefault() - current.click() - break - case "Escape": - event.preventDefault() - clearSelection() - break - } + updateWalkthroughUi() }) - treePanel.append(toolbar) + + chartPanel.append(toolbar) if (diagnostics.length > 0) { const diagnosticList = createElement("div", "diagnostics") diagnosticList.setAttribute("role", "status") @@ -1009,12 +1045,44 @@ export const renderVisualizer = ( } diagnosticList.append(item) }) - treePanel.append(diagnosticList) + chartPanel.append(diagnosticList) } - treePanel.append(tree) - - renderEmptyInspector() - workspace.append(treePanel, inspector) + chartPanel.append(chart, walkthroughDock, zoomControls) + hideInspector() + workspace.append(chartPanel, inspector, choicePicker) shell.append(workspace) root.replaceChildren(shell) + + if (model.roots.length === 0) { + const empty = createElement("div", "topology-empty") + empty.append( + createElement("strong", undefined, "No states yet"), + createElement("span", undefined, "The topology will appear as the machine definition becomes available.") + ) + chartHost.replaceChildren(empty) + } else { + void Effect.runPromise(renderChart(chartHost, visualization, { + selectState: handleStateClick, + openStateDetails, + selectTransition: handleTransitionClick, + openTransitionDetails, + clearSelection, + zoomChanged: updateZoomControls + })).then( + (view) => { + chartView = view + updateChartPresentation() + updateZoomControls() + }, + (cause) => { + const failure = createElement("div", "chart-layout-error") + failure.append( + createElement("strong", undefined, "The chart could not be laid out"), + createElement("span", undefined, cause instanceof Error ? cause.message : String(cause)) + ) + chartHost.replaceChildren(failure) + } + ) + } + updateWalkthroughUi() } diff --git a/packages/devtools/src/internal/browser/visualizer-model.ts b/packages/devtools/src/internal/browser/visualizer-model.ts index 088154b..d3f2e49 100644 --- a/packages/devtools/src/internal/browser/visualizer-model.ts +++ b/packages/devtools/src/internal/browser/visualizer-model.ts @@ -72,6 +72,110 @@ export const triggerLabel = (transition: VisualizationTransition): string => { } } +const propertyAccess = (key: string): string => /^[$A-Z_a-z][$\w]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]` + +const pathAccess = (path: string): string => path.split(".").map(propertyAccess).join("") + +const nearestCompoundScope = ( + document: VisualizationDocument, + source: string +): string | undefined => { + const states = new Map(document.states.map((state) => [state.path, state])) + let current = states.get(source) + while (current !== undefined) { + if (current.type === "compound") return current.path + current = current.parent === null ? undefined : states.get(current.parent) + } + return undefined +} + +const localPathAccess = ( + document: VisualizationDocument, + source: string, + path: string +): string | undefined => { + const scope = nearestCompoundScope(document, source) + if (scope === undefined) return undefined + if (path === scope) return "" + const prefix = `${scope}.` + return path.startsWith(prefix) ? pathAccess(path.slice(prefix.length)) : undefined +} + +/** Canonical Effect Machine selector represented by one retained transition branch. */ +export const branchTargetApi = ( + document: VisualizationDocument, + source: string, + branch: VisualizationBranch +): string | undefined => { + const { selection } = branch + if (selection.kind === "none") return "to.none" + const path = selection.path + if (path === null || selection.scope === null) return undefined + + let api: string | undefined + if (selection.kind === "history" && selection.scope === "full") { + api = `to.history${pathAccess(path)}` + } else if (selection.scope === "local") { + const local = localPathAccess(document, source, path) + if (local === undefined) return undefined + switch (selection.kind) { + case "state": + api = local === "" ? "to.local.with" : `to.local${local}()` + break + case "choice": + api = local === "" ? undefined : `to.local${local}()` + break + case "initial": + api = local === "" ? undefined : `to.local${local}.initial` + break + case "update": + api = local === "" ? "to.local.update" : undefined + break + case "history": + break + } + } else if (selection.scope === "branch") { + switch (selection.kind) { + case "state": + case "choice": + api = `to.branch${pathAccess(path)}()` + break + case "initial": + api = `to.branch${pathAccess(path)}.initial` + break + case "update": + api = `to.branch${pathAccess(path)}.update` + break + case "history": + break + } + } else if (selection.scope === "full") { + switch (selection.kind) { + case "state": + case "choice": + api = `to.full${pathAccess(path)}()` + break + case "initial": + api = `to.full${pathAccess(path)}.initial` + break + case "history": + api = `to.history${pathAccess(path)}` + break + case "update": + break + } + } else if (selection.scope === "initial") { + if (selection.kind === "state") api = `to${pathAccess(path)}()` + if (selection.kind === "initial") api = `to${pathAccess(path)}.initial` + } + + if (api === undefined || selection.kind === "update") return api + return branch.updates.reduce( + (expression, owner) => `${expression}.updating(to.branch${pathAccess(owner)})`, + api + ) +} + const buildInitialPaths = (document: VisualizationDocument): ReadonlySet => { const initial = new Set([document.initial.target]) for (const state of document.states) { diff --git a/packages/devtools/src/internal/devServer.ts b/packages/devtools/src/internal/devServer.ts index 0a68e8c..96a1a8c 100644 --- a/packages/devtools/src/internal/devServer.ts +++ b/packages/devtools/src/internal/devServer.ts @@ -1,13 +1,11 @@ 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" @@ -51,51 +49,8 @@ const writeJson = (response: ServerResponse, value: unknown, status = 200): void 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, - inputIssues: [], - diagnostics: [{ - severity: "error", - code: "simulation-stale", - message, - location: { file: request.source.file, line: null, column: null }, - statePath: null - }] -}) - const apiPlugin = ( - root: string, - registry: MachineRegistry.MachineRegistry["Service"], - inspector: ProjectInspector.ProjectInspector["Service"] + registry: MachineRegistry.MachineRegistry["Service"] ): Plugin => ({ name: "effect-machine-devtools-api", configureServer(server) { @@ -110,37 +65,6 @@ 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 @@ -167,8 +91,7 @@ const apiPlugin = ( const acquire = ( ErrorType: DevServerErrorConstructor, options: DevServer.Options, - registry: MachineRegistry.MachineRegistry["Service"], - inspector: ProjectInspector.ProjectInspector["Service"] + registry: MachineRegistry.MachineRegistry["Service"] ): Effect.Effect => Effect.tryPromise({ try: async () => { @@ -176,7 +99,7 @@ const acquire = ( root: packageRoot, appType: "spa", logLevel: "error", - plugins: [apiPlugin(options.root, registry, inspector)], + plugins: [apiPlugin(registry)], server: { host: options.host, port: options.port, @@ -239,9 +162,8 @@ export const run = ( > => Effect.gen(function*() { const registry = yield* MachineRegistry.MachineRegistry - const inspector = yield* ProjectInspector.ProjectInspector const server = yield* Effect.acquireRelease( - acquire(ErrorType, options, registry, inspector), + acquire(ErrorType, options, registry), (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 42e88b4..055fed5 100644 --- a/packages/devtools/src/internal/evaluationWorker.ts +++ b/packages/devtools/src/internal/evaluationWorker.ts @@ -2,9 +2,8 @@ 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 { resolve } from "node:path" import { pathToFileURL } from "node:url" import type { ViteDevServer } from "vite" import * as DevToolsProtocol from "../DevToolsProtocol.js" @@ -23,14 +22,8 @@ interface EvaluationResponse { readonly results: ReadonlyArray } -interface SimulationWorkerRequest { - readonly _tag: "Simulate" - readonly root: string - readonly request: DevToolsProtocol.SimulationRequest -} - -type WorkerRequest = EvaluationRequest | SimulationWorkerRequest -type WorkerResponse = EvaluationResponse | DevToolsProtocol.SimulationResult +type WorkerRequest = EvaluationRequest +type WorkerResponse = EvaluationResponse const isMachine = (value: unknown): value is Machine.Machine.Any => typeof value === "object" && @@ -68,23 +61,7 @@ const messageOf = (cause: unknown): string => { // 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 + return rendered.length > 0 ? rendered : "The evaluator failed without a diagnostic message" } const failed = ( @@ -168,353 +145,12 @@ const handle = (server: ViteDevServer, request: EvaluationRequest): Effect.Effec 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 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 -) => 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 - 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 - return constructor(payload) -} - -const simulationSnapshot = ( - machine: Machine.Machine.Any, - snapshot: unknown -): 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) { - 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, - 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) - 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") { - 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(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))) - ) -} - -export const run = (server: ViteDevServer): Promise => { - return Effect.gen(function*() { +export const run = (server: ViteDevServer): Promise => + Effect.gen(function*() { const platform = yield* WorkerRunner.WorkerRunnerPlatform 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)) - }) + yield* runner.run((_portId, request) => Effect.flatMap(handle(server, request), (value) => runner.send(0, value))) }).pipe( Effect.provide(NodeWorkerRunner.layer), Effect.runPromise ) -} diff --git a/packages/devtools/src/internal/machineDocument.ts b/packages/devtools/src/internal/machineDocument.ts index c0b7158..f7c2063 100644 --- a/packages/devtools/src/internal/machineDocument.ts +++ b/packages/devtools/src/internal/machineDocument.ts @@ -169,7 +169,7 @@ export const make = ( const initial = Machine.initialDefinition(machine) return { - schemaVersion: 2, + schemaVersion: 3, revision: options.revision ?? 0, source: options.source ?? null, machineId: machine.id ?? "Machine", @@ -190,6 +190,8 @@ export const make = ( parent: node.parent ?? null, children: [...childPaths.get(node.path) ?? []], initial: node.initial ?? null, + valueSchema: node.schema === undefined ? null : inputSchema(node.schema), + outputSchema: node.output === undefined ? null : inputSchema(node.output), transitionIds: [...transitionIds.get(node.path) ?? []], activityIds: [...activityIds.get(node.path) ?? []] })), diff --git a/packages/devtools/src/internal/machineSimulator.ts b/packages/devtools/src/internal/machineSimulator.ts deleted file mode 100644 index c9c9499..0000000 --- a/packages/devtools/src/internal/machineSimulator.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type * as MachineDocument from "../MachineDocument.js" -import type * as MachineSimulator from "../MachineSimulator.js" - -type State = MachineDocument.State -type Transition = MachineDocument.Transition - -interface Model { - readonly states: ReadonlyMap - readonly order: ReadonlyMap -} - -const makeModel = (document: MachineDocument.MachineDocument): Model => ({ - states: new Map(document.states.map((state) => [state.path, state])), - order: new Map(document.states.map((state) => [state.path, state.order])) -}) - -const ordered = (model: Model, paths: Iterable): ReadonlyArray => - [...new Set(paths)].sort((left, right) => (model.order.get(left) ?? 0) - (model.order.get(right) ?? 0)) - -const enter = (model: Model, path: string, active: Set): void => { - const state = model.states.get(path) - if (state === undefined) return - active.add(path) - if (state.type === "parallel") { - state.children.forEach((child) => enter(model, child, active)) - } else if (state.type === "compound" && state.initial !== null) { - enter(model, state.initial, active) - } -} - -const ancestors = (model: Model, path: string): ReadonlyArray => { - const result: Array = [] - let current = model.states.get(path) - while (current !== undefined) { - result.unshift(current.path) - current = current.parent === null ? undefined : model.states.get(current.parent) - } - return result -} - -const leastCommonAncestor = (model: Model, left: string, right: string): string | null => { - const leftAncestors = ancestors(model, left) - const rightAncestors = ancestors(model, right) - let result: string | null = null - const length = Math.min(leftAncestors.length, rightAncestors.length) - for (let index = 0; index < length; index++) { - if (leftAncestors[index] !== rightAncestors[index]) break - result = leftAncestors[index] ?? null - } - return result -} - -const isDescendant = (model: Model, path: string, ancestor: string): boolean => - path !== ancestor && ancestors(model, path).includes(ancestor) - -const candidateEvents = ( - document: MachineDocument.MachineDocument, - activePaths: ReadonlyArray -): ReadonlyArray => { - const active = new Set(activePaths) - return [ - ...new Set( - document.transitions.flatMap((transition): ReadonlyArray => - transition.trigger.type === "event" && active.has(transition.source) ? [transition.trigger.event] : [] - ) - ) - ].sort() -} - -const snapshot = ( - document: MachineDocument.MachineDocument, - model: Model, - step: number, - activePaths: Iterable -): MachineSimulator.Snapshot => { - const active = ordered(model, activePaths) - return { - step, - activePaths: active, - candidateEvents: candidateEvents(document, active) - } -} - -export const start = (document: MachineDocument.MachineDocument): MachineSimulator.Session => { - const model = makeModel(document) - const active = new Set() - if (document.snapshot === null) { - const targetAncestors = ancestors(model, document.initial.target) - targetAncestors.forEach((path) => active.add(path)) - enter(model, document.initial.target, active) - } else { - document.snapshot.activePaths.forEach((path) => active.add(path)) - } - return { document, snapshot: snapshot(document, model, 0, active) } -} - -const indeterminate = ( - session: MachineSimulator.Session, - event: string, - transitions: ReadonlyArray, - reason: MachineSimulator.Indeterminate["reason"] -): MachineSimulator.Indeterminate => ({ - _tag: "Indeterminate", - event, - transitionIds: transitions.map((transition) => transition.id), - session: session.snapshot, - reason -}) - -const notesFor = ( - document: MachineDocument.MachineDocument, - transition: Transition, - nextActive: ReadonlyArray -): ReadonlyArray => { - const notes = new Set(["runtime-effects-skipped"]) - if (transition.reenter) notes.add("reentry-lifecycles-skipped") - if (transition.branches.some((branch) => branch.updates.length > 0)) notes.add("state-updates-skipped") - const active = new Set(nextActive) - if ( - document.transitions.some((candidate) => - active.has(candidate.source) && - (candidate.trigger.type === "always" || candidate.trigger.type === "choice" || candidate.trigger.type === "done") - ) - ) { - notes.add("automatic-transitions-skipped") - } - return [...notes] -} - -const nextConfiguration = ( - session: MachineSimulator.Session, - transition: Transition, - target: string | null -): MachineSimulator.Session | undefined => { - const document = session.document - const model = makeModel(document) - if (target === null) { - return { - document, - snapshot: snapshot(document, model, session.snapshot.step + 1, session.snapshot.activePaths) - } - } - if (!model.states.has(target)) return undefined - - const naturalBoundary = leastCommonAncestor(model, transition.source, target) - const sourceParent = model.states.get(transition.source)?.parent ?? null - const boundary = transition.reenter - ? sourceParent === null || naturalBoundary === null - ? null - : ancestors(model, naturalBoundary).length <= ancestors(model, sourceParent).length - ? naturalBoundary - : sourceParent - : naturalBoundary - const active = new Set(session.snapshot.activePaths) - for (const path of active) { - if (boundary === null || isDescendant(model, path, boundary)) active.delete(path) - } - ancestors(model, target).forEach((path) => { - if (boundary === null || path === boundary || isDescendant(model, path, boundary)) active.add(path) - }) - enter(model, target, active) - return { document, snapshot: snapshot(document, model, session.snapshot.step + 1, active) } -} - -export const send = (session: MachineSimulator.Session, event: string): MachineSimulator.StepResult => { - const active = new Set(session.snapshot.activePaths) - const transitions = session.document.transitions.filter((transition) => - transition.trigger.type === "event" && transition.trigger.event === event && active.has(transition.source) - ) - if (transitions.length === 0) { - return { - _tag: "Blocked", - event, - transitionIds: [], - session: session.snapshot, - reason: "event-not-enabled" - } - } - if (transitions.length > 1) return indeterminate(session, event, transitions, "multiple-transitions") - const transition = transitions[0]! - if (transition.acceptance === "declinable") { - return indeterminate(session, event, transitions, "declinable-transition") - } - if (transition.branches.length !== 1 || transition.branches[0]?.type === "branch") { - return indeterminate(session, event, transitions, "conditional-branches") - } - const branch = transition.branches[0]! - if (branch.selection.kind === "history") return indeterminate(session, event, transitions, "history-target") - if (branch.selection.kind === "choice") return indeterminate(session, event, transitions, "choice-target") - const next = nextConfiguration(session, transition, branch.target) - if (next === undefined) return indeterminate(session, event, transitions, "missing-target") - return { - _tag: "Applied", - event, - transitionIds: [transition.id], - session: next.snapshot, - notes: notesFor(session.document, transition, next.snapshot.activePaths) - } -} diff --git a/packages/devtools/src/internal/machineWalkthrough.ts b/packages/devtools/src/internal/machineWalkthrough.ts new file mode 100644 index 0000000..4d28958 --- /dev/null +++ b/packages/devtools/src/internal/machineWalkthrough.ts @@ -0,0 +1,355 @@ +import * as Result from "effect/Result" +import type * as MachineDocument from "../MachineDocument.js" +import type * as MachineWalkthrough from "../MachineWalkthrough.js" + +/** @internal */ +export const SessionTypeId = "@typeonce/effect-machine-devtools/MachineWalkthrough/Session" as const + +interface HistoryRecord { + readonly mode: "shallow" | "deep" + readonly paths: ReadonlyArray +} + +interface InternalSnapshot { + readonly activePaths: ReadonlyArray + readonly history: ReadonlyMap +} + +interface InternalFrame { + readonly public: MachineWalkthrough.Frame + readonly snapshot: InternalSnapshot +} + +interface SessionImpl extends MachineWalkthrough.Session { + readonly document: MachineDocument.MachineDocument + readonly cursor: number + readonly frames: ReadonlyArray +} + +interface Model { + readonly states: ReadonlyMap + readonly order: ReadonlyMap +} + +interface PublicApi { + readonly ChoiceNotFound: typeof MachineWalkthrough.ChoiceNotFound + readonly ChoiceUnavailable: typeof MachineWalkthrough.ChoiceUnavailable + readonly StepNotFound: typeof MachineWalkthrough.StepNotFound +} + +const makeModel = (document: MachineDocument.MachineDocument): Model => ({ + states: new Map(document.states.map((state) => [state.path, state])), + order: new Map(document.states.map((state) => [state.path, state.order])) +}) + +const ordered = (model: Model, paths: Iterable): ReadonlyArray => + [...new Set(paths)].sort((left, right) => (model.order.get(left) ?? 0) - (model.order.get(right) ?? 0)) + +const ancestors = (model: Model, path: string): ReadonlyArray => { + const result: Array = [] + let current = model.states.get(path) + while (current !== undefined) { + result.unshift(current.path) + current = current.parent === null ? undefined : model.states.get(current.parent) + } + return result +} + +const isDescendant = (model: Model, path: string, ancestor: string): boolean => + path !== ancestor && ancestors(model, path).includes(ancestor) + +const leastCommonAncestor = (model: Model, left: string, right: string): string | null => { + const leftAncestors = ancestors(model, left) + const rightAncestors = ancestors(model, right) + let result: string | null = null + const length = Math.min(leftAncestors.length, rightAncestors.length) + for (let index = 0; index < length; index++) { + if (leftAncestors[index] !== rightAncestors[index]) break + result = leftAncestors[index] ?? null + } + return result +} + +const add = (active: Set, entered: Set, path: string): void => { + if (!active.has(path)) entered.add(path) + active.add(path) +} + +const enter = ( + model: Model, + path: string, + active: Set, + entered: Set, + history: ReadonlyMap +): void => { + const state = model.states.get(path) + if (state === undefined) return + if (state.type === "history") { + const record = history.get(path) + if (record === undefined) { + add(active, entered, path) + return + } + if (record.mode === "deep") { + record.paths.forEach((recorded) => add(active, entered, recorded)) + } else { + record.paths.forEach((recorded) => enter(model, recorded, active, entered, history)) + } + return + } + add(active, entered, path) + if (state.type === "parallel") { + state.children.forEach((child) => enter(model, child, active, entered, history)) + } else if (state.type === "compound" && state.initial !== null) { + enter(model, state.initial, active, entered, history) + } +} + +const completeParallelRegions = ( + model: Model, + active: Set, + entered: Set, + history: ReadonlyMap +): void => { + for (const state of model.states.values()) { + if (state.type !== "parallel" || !active.has(state.path)) continue + state.children.forEach((child) => { + if (!active.has(child)) enter(model, child, active, entered, history) + }) + } +} + +const snapshot = ( + model: Model, + activePaths: Iterable, + history: ReadonlyMap +): InternalSnapshot => ({ + activePaths: ordered(model, activePaths), + history: new Map(history) +}) + +const publicSnapshot = (value: InternalSnapshot): MachineWalkthrough.Snapshot => ({ + activePaths: value.activePaths +}) + +const makeSession = ( + document: MachineDocument.MachineDocument, + cursor: number, + frames: ReadonlyArray +): MachineWalkthrough.Session => + ({ + [SessionTypeId]: SessionTypeId, + document, + cursor, + frames + }) as SessionImpl + +const session = (self: MachineWalkthrough.Session): SessionImpl => self as SessionImpl + +const initialSnapshot = (document: MachineDocument.MachineDocument, model: Model): InternalSnapshot => { + if (document.snapshot !== null) return snapshot(model, document.snapshot.activePaths, new Map()) + const active = new Set() + const entered = new Set() + const targetAncestors = ancestors(model, document.initial.target) + targetAncestors.slice(0, -1).forEach((path) => add(active, entered, path)) + enter(model, document.initial.target, active, entered, new Map()) + return snapshot(model, active, new Map()) +} + +/** @internal */ +export const start = (document: MachineDocument.MachineDocument): MachineWalkthrough.Session => { + const model = makeModel(document) + const after = initialSnapshot(document, model) + const frame: MachineWalkthrough.Frame = { + step: 0, + choice: null, + before: { activePaths: [] }, + after: publicSnapshot(after), + exitPaths: [], + entryPaths: after.activePaths, + changed: after.activePaths.length > 0 + } + return makeSession(document, 0, [{ public: frame, snapshot: after }]) +} + +/** @internal */ +export const current = (self: MachineWalkthrough.Session): MachineWalkthrough.Frame => { + const value = session(self) + return value.frames[value.cursor]!.public +} + +/** @internal */ +export const timeline = (self: MachineWalkthrough.Session): ReadonlyArray => + session(self).frames.map((frame) => frame.public) + +/** @internal */ +export const cursor = (self: MachineWalkthrough.Session): number => session(self).cursor + +const decisions = ( + transition: MachineDocument.Transition, + branch: MachineDocument.Branch +): ReadonlyArray => { + const values = new Set() + if (branch.type === "branch") values.add("conditional-branch") + if (transition.acceptance === "declinable") values.add("declinable-transition") + if (transition.trigger.type === "invoke") values.add("invoke-outcome") + else if (transition.trigger.type !== "event") values.add("automatic-trigger") + return [...values] +} + +const unavailableReason = ( + branch: MachineDocument.Branch, + history: ReadonlyMap +): MachineWalkthrough.UnavailableReason | null => { + if (branch.selection.kind === "history" && (branch.target === null || !history.has(branch.target))) { + return "history-unavailable" + } + if ( + branch.target === null && + branch.selection.kind !== "none" && + branch.selection.kind !== "update" + ) return "runtime-target" + return null +} + +/** @internal */ +export const choices = (self: MachineWalkthrough.Session): ReadonlyArray => { + const value = session(self) + const frame = value.frames[value.cursor]! + const active = new Set(frame.snapshot.activePaths) + const eventInputs = new Map(value.document.inputs.events.map(({ event, schema }) => [event, schema])) + return value.document.transitions.flatMap((transition): ReadonlyArray => { + if (!active.has(transition.source)) return [] + return transition.branches.map((branch, branchIndex) => ({ + id: branch.id, + transitionId: transition.id, + branchId: branch.id, + branchIndex, + branchKey: branch.type === "branch" ? branch.key : null, + title: branch.type === "branch" ? branch.title : null, + source: transition.source, + trigger: transition.trigger, + target: branch.target, + selection: branch.selection, + updates: branch.updates, + decisions: decisions(transition, branch), + unavailableReason: unavailableReason(branch, frame.snapshot.history), + input: transition.trigger.type === "event" ? eventInputs.get(transition.trigger.event) ?? null : null + })) + }) +} + +const recordHistory = ( + document: MachineDocument.MachineDocument, + model: Model, + before: ReadonlySet, + exited: ReadonlySet, + history: Map +): void => { + for (const state of document.states) { + if (state.type !== "history" || state.parent === null || !exited.has(state.parent)) continue + const paths = state.history === "deep" + ? [...before].filter((path) => { + const node = model.states.get(path) + return isDescendant(model, path, state.parent!) && node?.type !== "history" && node?.type !== "choice" + }) + : [...before].filter((path) => model.states.get(path)?.parent === state.parent) + if (paths.length > 0) { + history.set(state.path, { + mode: state.history === "deep" ? "deep" : "shallow", + paths: ordered(model, paths) + }) + } + } +} + +const advance = ( + value: SessionImpl, + choice: MachineWalkthrough.Choice +): { + readonly snapshot: InternalSnapshot + readonly exited: ReadonlyArray + readonly entered: ReadonlyArray +} => { + const frame = value.frames[value.cursor]! + const model = makeModel(value.document) + if (choice.target === null) { + return { snapshot: frame.snapshot, exited: [], entered: [] } + } + const transition = value.document.transitions.find(({ id }) => id === choice.transitionId)! + const naturalBoundary = leastCommonAncestor(model, transition.source, choice.target) + const sourceParent = model.states.get(transition.source)?.parent ?? null + const boundary = transition.reenter + ? sourceParent === null || naturalBoundary === null + ? null + : ancestors(model, naturalBoundary).length <= ancestors(model, sourceParent).length + ? naturalBoundary + : sourceParent + : naturalBoundary + const before = new Set(frame.snapshot.activePaths) + const active = new Set(frame.snapshot.activePaths) + const exited = new Set( + frame.snapshot.activePaths.filter((path) => boundary === null || isDescendant(model, path, boundary)) + ) + const history = new Map(frame.snapshot.history) + recordHistory(value.document, model, before, exited, history) + exited.forEach((path) => active.delete(path)) + + const entered = new Set() + const targetAncestors = ancestors(model, choice.target) + targetAncestors.slice(0, -1).forEach((path) => { + if (boundary === null || path === boundary || isDescendant(model, path, boundary)) add(active, entered, path) + }) + enter(model, choice.target, active, entered, history) + completeParallelRegions(model, active, entered, history) + return { + snapshot: snapshot(model, active, history), + exited: ordered(model, exited), + entered: ordered(model, entered) + } +} + +/** @internal */ +export const take = (api: PublicApi) => +( + self: MachineWalkthrough.Session, + choiceId: string +): Result.Result< + MachineWalkthrough.Session, + MachineWalkthrough.ChoiceNotFound | MachineWalkthrough.ChoiceUnavailable +> => { + const value = session(self) + const choice = choices(self).find(({ id }) => id === choiceId) + if (choice === undefined) return Result.fail(new api.ChoiceNotFound({ choiceId })) + if (choice.unavailableReason !== null) { + return Result.fail(new api.ChoiceUnavailable({ choiceId, reason: choice.unavailableReason })) + } + const beforeFrame = value.frames[value.cursor]! + const next = advance(value, choice) + const before = publicSnapshot(beforeFrame.snapshot) + const after = publicSnapshot(next.snapshot) + const frame: MachineWalkthrough.Frame = { + step: value.cursor + 1, + choice, + before, + after, + exitPaths: next.exited, + entryPaths: next.entered, + changed: next.exited.length > 0 || next.entered.length > 0 + } + const frames = [...value.frames.slice(0, value.cursor + 1), { public: frame, snapshot: next.snapshot }] + return Result.succeed(makeSession(value.document, frames.length - 1, frames)) +} + +/** @internal */ +export const seek = (api: PublicApi) => +( + self: MachineWalkthrough.Session, + step: number +): Result.Result => { + const value = session(self) + if (!Number.isInteger(step) || step < 0 || step >= value.frames.length) { + return Result.fail(new api.StepNotFound({ step })) + } + return Result.succeed(makeSession(value.document, step, value.frames)) +} diff --git a/packages/devtools/src/internal/projectInspector.ts b/packages/devtools/src/internal/projectInspector.ts index 53bcee0..c890ccd 100644 --- a/packages/devtools/src/internal/projectInspector.ts +++ b/packages/devtools/src/internal/projectInspector.ts @@ -39,14 +39,8 @@ interface EvaluationResponse { readonly results: ReadonlyArray } -interface SimulationWorkerRequest { - readonly _tag: "Simulate" - readonly root: string - readonly request: DevToolsProtocol.SimulationRequest -} - -type WorkerRequest = EvaluationRequest | SimulationWorkerRequest -type WorkerResponse = EvaluationResponse | DevToolsProtocol.SimulationResult +type WorkerRequest = EvaluationRequest +type WorkerResponse = EvaluationResponse const scriptKind = (file: string): ts.ScriptKind => { if (file.endsWith(".tsx")) return ts.ScriptKind.TSX @@ -229,27 +223,6 @@ 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) @@ -258,8 +231,7 @@ const make = (api: PublicApi) => return api.ProjectInspector.of({ discover, evaluate: (candidates, options) => evaluate(api, candidates, options), - inspect, - simulate: (request, options) => simulate(api, request, options) + inspect }) }) diff --git a/packages/devtools/test/MachineDocument.test.ts b/packages/devtools/test/MachineDocument.test.ts index d269008..3a34533 100644 --- a/packages/devtools/test/MachineDocument.test.ts +++ b/packages/devtools/test/MachineDocument.test.ts @@ -13,7 +13,7 @@ describe("MachineDocument", () => { snapshot }) - assert.strictEqual(document.schemaVersion, 2) + assert.strictEqual(document.schemaVersion, 3) assert.strictEqual(document.revision, 3) assert.deepStrictEqual(document.source, { file: "/project/src/workflow.ts", @@ -42,6 +42,12 @@ describe("MachineDocument", () => { 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"]) + const working = document.states.find((state) => state.path === "Working") + const workingSchema = working?.valueSchema?.schema as { readonly $ref?: string } | undefined + const finished = document.states.find((state) => state.path === "Finished") + + assert.strictEqual(workingSchema?.$ref, "#/$defs/PlannerWorkingEncoded") + assert.notStrictEqual(finished?.outputSchema, null) }) it("validates ready, partial, and failed evaluation results", () => { diff --git a/packages/devtools/test/MachineSimulator.test.ts b/packages/devtools/test/MachineSimulator.test.ts deleted file mode 100644 index b2f153e..0000000 --- a/packages/devtools/test/MachineSimulator.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { assert, describe, it } from "@effect/vitest" -import * as Schema from "effect/Schema" -import { machine } from "../src/internal/browser/example-machine.js" -import * as MachineDocument from "../src/MachineDocument.js" -import * as MachineSimulator from "../src/MachineSimulator.js" - -describe("MachineSimulator", () => { - const document = MachineDocument.make(machine) - - it("enters compound and parallel initial states without running the machine", () => { - const session = MachineSimulator.start(document) - assert.deepStrictEqual(session.snapshot.activePaths, [ - "application", - "application.workflow", - "application.workflow.idle", - "application.connection", - "application.connection.online" - ]) - assert.deepStrictEqual(session.snapshot.candidateEvents, ["Disconnect", "Refresh", "Start"]) - }) - - it("applies a statically direct transition and preserves the parallel region", () => { - const started = MachineSimulator.start(document) - const result = MachineSimulator.send(started, "Start") - assert.strictEqual(result._tag, "Applied") - if (result._tag === "Applied") { - assert.deepStrictEqual(result.session.activePaths, [ - "application", - "application.workflow", - "application.workflow.running", - "application.workflow.running.editing", - "application.connection", - "application.connection.online" - ]) - assert.deepStrictEqual(result.session.candidateEvents, ["Disconnect", "Finish"]) - assert.deepStrictEqual(Schema.decodeUnknownSync(MachineSimulator.StepResult)(result), result) - } - }) - - it("does not guess whether a declinable transition accepts an event", () => { - const transition = document.transitions.find((transition) => transition.trigger.type === "event")! - const guarded = { - ...document, - transitions: document.transitions.map((candidate) => - candidate.id === transition.id ? { ...candidate, acceptance: "declinable" as const } : candidate - ) - } - const result = MachineSimulator.send(MachineSimulator.start(guarded), "Start") - assert.strictEqual(result._tag, "Indeterminate") - if (result._tag === "Indeterminate") assert.strictEqual(result.reason, "declinable-transition") - }) - - it("blocks events that are not registered in the active configuration", () => { - const result = MachineSimulator.send(MachineSimulator.start(document), "Finish") - assert.strictEqual(result._tag, "Blocked") - }) -}) diff --git a/packages/devtools/test/MachineWalkthrough.test.ts b/packages/devtools/test/MachineWalkthrough.test.ts new file mode 100644 index 0000000..761086d --- /dev/null +++ b/packages/devtools/test/MachineWalkthrough.test.ts @@ -0,0 +1,281 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Result from "effect/Result" +import { machine } from "../src/internal/browser/example-machine.js" +import { plannerMachine } from "../src/internal/browser/planner-example.js" +import * as MachineDocument from "../src/MachineDocument.js" +import * as MachineWalkthrough from "../src/MachineWalkthrough.js" + +const takeEvent = ( + session: MachineWalkthrough.Session, + event: string +): MachineWalkthrough.Session => { + const choice = MachineWalkthrough.choices(session).find((choice) => + choice.trigger.type === "event" && choice.trigger.event === event + ) + assert.isDefined(choice) + const result = MachineWalkthrough.take(session, choice.id) + assert.isTrue(Result.isSuccess(result)) + if (Result.isFailure(result)) throw result.failure + return result.success +} + +const historyDocument = (initial: "Workspace" | "Paused" = "Workspace"): MachineDocument.MachineDocument => ({ + schemaVersion: MachineDocument.schemaVersion, + revision: 0, + source: null, + machineId: "history-walkthrough", + initial: { + target: initial, + selection: { path: initial, kind: "state", scope: "full" } + }, + roots: ["Workspace", "Paused"], + states: [ + { + path: "Workspace", + key: "Workspace", + order: 0, + title: null, + description: null, + documentation: null, + type: "compound", + history: null, + parent: null, + children: ["Workspace.One", "Workspace.Two", "Workspace.recent"], + initial: "Workspace.One", + valueSchema: null, + outputSchema: null, + transitionIds: [], + activityIds: [] + }, + { + path: "Workspace.One", + key: "One", + order: 1, + title: null, + description: null, + documentation: null, + type: "atomic", + history: null, + parent: "Workspace", + children: [], + initial: null, + valueSchema: null, + outputSchema: null, + transitionIds: ["next"], + activityIds: [] + }, + { + path: "Workspace.Two", + key: "Two", + order: 2, + title: null, + description: null, + documentation: null, + type: "atomic", + history: null, + parent: "Workspace", + children: [], + initial: null, + valueSchema: null, + outputSchema: null, + transitionIds: ["pause"], + activityIds: [] + }, + { + path: "Workspace.recent", + key: "recent", + order: 3, + title: null, + description: null, + documentation: null, + type: "history", + history: "shallow", + parent: "Workspace", + children: [], + initial: null, + valueSchema: null, + outputSchema: null, + transitionIds: [], + activityIds: [] + }, + { + path: "Paused", + key: "Paused", + order: 4, + title: null, + description: null, + documentation: null, + type: "atomic", + history: null, + parent: null, + children: [], + initial: null, + valueSchema: null, + outputSchema: null, + transitionIds: ["resume"], + activityIds: [] + } + ], + transitions: [ + { + id: "next", + source: "Workspace.One", + trigger: { type: "event", event: "Next" }, + reenter: false, + acceptance: "required", + branches: [{ + id: "next:branch:0", + type: "direct", + target: "Workspace.Two", + selection: { path: "Workspace.Two", kind: "state", scope: "full" }, + updates: [] + }] + }, + { + id: "pause", + source: "Workspace.Two", + trigger: { type: "event", event: "Pause" }, + reenter: false, + acceptance: "required", + branches: [{ + id: "pause:branch:0", + type: "direct", + target: "Paused", + selection: { path: "Paused", kind: "state", scope: "full" }, + updates: [] + }] + }, + { + id: "resume", + source: "Paused", + trigger: { type: "event", event: "Resume" }, + reenter: false, + acceptance: "required", + branches: [{ + id: "resume:branch:0", + type: "direct", + target: "Workspace.recent", + selection: { path: "Workspace.recent", kind: "history", scope: "full" }, + updates: [] + }] + } + ], + activities: [], + inputs: { machine: null, events: [] }, + snapshot: null +}) + +describe("MachineWalkthrough", () => { + it("starts from compound and parallel initial topology", () => { + const session = MachineWalkthrough.start(MachineDocument.make(machine)) + assert.deepStrictEqual(MachineWalkthrough.current(session).after.activePaths, [ + "application", + "application.workflow", + "application.workflow.idle", + "application.connection", + "application.connection.online" + ]) + assert.deepStrictEqual( + MachineWalkthrough.choices(session) + .flatMap((choice) => choice.trigger.type === "event" ? [choice.trigger.event] : []) + .sort(), + ["Disconnect", "Refresh", "Start"] + ) + }) + + it("advances direct topology while preserving a parallel sibling", () => { + const started = MachineWalkthrough.start(MachineDocument.make(machine)) + const advanced = takeEvent(started, "Start") + assert.deepStrictEqual(MachineWalkthrough.current(advanced).after.activePaths, [ + "application", + "application.workflow", + "application.workflow.running", + "application.workflow.running.editing", + "application.connection", + "application.connection.online" + ]) + }) + + it("re-enters every parallel region when a transition crosses regions", () => { + const document = MachineDocument.make(machine) + const crossed = MachineWalkthrough.take( + MachineWalkthrough.start({ + ...document, + transitions: [...document.transitions, { + id: "cross-region", + source: "application.workflow.idle", + trigger: { type: "event", event: "CrossRegion" }, + reenter: false, + acceptance: "required", + branches: [{ + id: "cross-region:branch:0", + type: "direct", + target: "application.connection.offline", + selection: { path: "application.connection.offline", kind: "state", scope: "full" }, + updates: [] + }] + }] + }), + "cross-region:branch:0" + ) + assert.isTrue(Result.isSuccess(crossed)) + if (Result.isFailure(crossed)) throw crossed.failure + assert.deepStrictEqual(MachineWalkthrough.current(crossed.success).after.activePaths, [ + "application", + "application.workflow", + "application.workflow.idle", + "application.connection", + "application.connection.offline" + ]) + }) + + it("exposes branch decisions and event contracts without asking for values", () => { + const session = MachineWalkthrough.start(MachineDocument.make(plannerMachine)) + const begin = MachineWalkthrough.choices(session).filter((choice) => + choice.trigger.type === "event" && choice.trigger.event === "Begin" + ) + assert.strictEqual(begin.length, 2) + assert.deepStrictEqual(begin.map(({ title }) => title), ["Finish immediately", "Wait in working"]) + assert.isTrue(begin.every(({ decisions }) => decisions.includes("conditional-branch"))) + assert.isTrue(begin.every(({ input }) => input !== null)) + }) + + it("retains history and restores it when the history branch is chosen", () => { + const started = MachineWalkthrough.start(historyDocument()) + const moved = takeEvent(started, "Next") + const paused = takeEvent(moved, "Pause") + const resume = MachineWalkthrough.choices(paused).find(({ id }) => id === "resume:branch:0")! + assert.strictEqual(resume.unavailableReason, null) + const resumed = MachineWalkthrough.take(paused, resume.id) + assert.isTrue(Result.isSuccess(resumed)) + if (Result.isFailure(resumed)) return + assert.deepStrictEqual(MachineWalkthrough.current(resumed.success).after.activePaths, [ + "Workspace", + "Workspace.Two" + ]) + }) + + it("does not invent a first-use history fallback", () => { + const session = MachineWalkthrough.start(historyDocument("Paused")) + const resume = MachineWalkthrough.choices(session).find(({ id }) => id === "resume:branch:0")! + assert.strictEqual(resume.unavailableReason, "history-unavailable") + const result = MachineWalkthrough.take(session, resume.id) + assert.isTrue(Result.isFailure(result)) + if (Result.isFailure(result)) assert.strictEqual(result.failure._tag, "ChoiceUnavailable") + }) + + it("seeks through retained frames and truncates the future when branching", () => { + const started = MachineWalkthrough.start(MachineDocument.make(machine)) + const advanced = takeEvent(started, "Start") + const past = MachineWalkthrough.seek(advanced, 0) + assert.isTrue(Result.isSuccess(past)) + if (Result.isFailure(past)) return + assert.strictEqual(MachineWalkthrough.timeline(past.success).length, 2) + const branched = takeEvent(past.success, "Disconnect") + assert.strictEqual(MachineWalkthrough.cursor(branched), 1) + assert.strictEqual(MachineWalkthrough.timeline(branched).length, 2) + const choice = MachineWalkthrough.current(branched).choice + assert.strictEqual(choice?.trigger.type, "event") + if (choice?.trigger.type === "event") assert.strictEqual(choice.trigger.event, "Disconnect") + }) +}) diff --git a/packages/devtools/test/ProjectInspector.test.ts b/packages/devtools/test/ProjectInspector.test.ts index ddee0e1..c6d8fba 100644 --- a/packages/devtools/test/ProjectInspector.test.ts +++ b/packages/devtools/test/ProjectInspector.test.ts @@ -1,10 +1,8 @@ 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" @@ -64,196 +62,6 @@ 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") - 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 - 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", - attempts: 2, - notifications: false, - mode: "guided" - } - }, { 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", - estimate: 13, - approved: true - } - }, { 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-"))), diff --git a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts index a11f392..35b2e84 100644 --- a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts +++ b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts @@ -6,7 +6,7 @@ import { machine, snapshot } from "../../../src/internal/browser/example-machine 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 { branchTargetApi, makeVisualizerModel } from "../../../src/internal/browser/visualizer-model.js" import * as MachineDocument from "../../../src/MachineDocument.js" const renderText = makeTextRenderer(Machine) @@ -98,6 +98,87 @@ describe("Interactive text visualization", () => { ) }) + it("renders retained transition selections as canonical Effect Machine APIs", () => { + const document = buildDocument() + const plannerDocument = MachineDocument.make(plannerMachine) + const api = ( + source: string, + selection: MachineDocument.Selection, + updates: ReadonlyArray = [] + ): string | undefined => + branchTargetApi(document, source, { + id: "test-branch", + type: "direct", + target: selection.path, + selection, + updates + }) + const start = document.transitions.find((transition) => + transition.trigger.type === "event" && transition.trigger.event === "Start" + ) + const refresh = document.transitions.find((transition) => + transition.trigger.type === "event" && transition.trigger.event === "Refresh" + ) + const begin = plannerDocument.transitions.find((transition) => + transition.trigger.type === "event" && transition.trigger.event === "Begin" + ) + + assert.deepStrictEqual( + begin?.branches.map((branch) => branchTargetApi(plannerDocument, begin.source, branch)), + ["to.full.Working()", "to.full.Working()"] + ) + + assert.strictEqual( + start === undefined ? undefined : branchTargetApi(document, start.source, start.branches[0]!), + "to.local.running().updating(to.branch.application.workflow)" + ) + assert.strictEqual( + refresh === undefined ? undefined : branchTargetApi(document, refresh.source, refresh.branches[0]!), + "to.local.update" + ) + assert.strictEqual( + api("application.workflow.running.editing", { + path: "application.workflow.running", + kind: "state", + scope: "local" + }), + "to.local.with" + ) + assert.strictEqual( + api("application.workflow.idle", { + path: "application.workflow.running", + kind: "initial", + scope: "local" + }), + "to.local.running.initial" + ) + assert.strictEqual( + api("application.workflow.idle", { + path: "application.workflow", + kind: "update", + scope: "branch" + }), + "to.branch.application.workflow.update" + ) + assert.strictEqual( + api("application.workflow.idle", { + path: "application.workflow.recent", + kind: "history", + scope: "full" + }), + "to.history.application.workflow.recent" + ) + assert.strictEqual( + api("application.workflow.idle", { + path: null, + kind: "none", + scope: "local" + }), + "to.none" + ) + assert.strictEqual(api("application", document.initial.selection), "to.application.initial") + }) + it("accepts an empty partial topology", () => { const document = buildDocument() const model = makeVisualizerModel({ diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts new file mode 100644 index 0000000..25fe9aa --- /dev/null +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -0,0 +1,140 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import { layoutChart } from "../../../src/internal/browser/chart-layout.js" +import { makeChartModel } from "../../../src/internal/browser/chart-model.js" +import { + chartWheelZoom, + chartZoomScrollPosition, + isChartPan, + maximumChartZoom, + minimumChartZoom +} from "../../../src/internal/browser/chart-renderer.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("Static chart", () => { + it("projects state fields, invocation metadata, and transition branches", () => { + const document = MachineDocument.make(plannerMachine) + const model = makeChartModel(document) + const idle = model.nodes.find((node) => node.path === "Idle") + const working = model.nodes.find((node) => node.path === "Working") + + assert.deepStrictEqual(idle?.fields, [{ + key: "owner", + label: "owner", + type: "string", + required: true + }]) + assert.deepStrictEqual(working?.fields.map(({ key, type }) => ({ key, type })), [ + { key: "owner", type: "string" }, + { key: "job", type: "string" } + ]) + assert.deepStrictEqual(working?.activities.map(({ kind, label }) => ({ kind, label })), [ + { kind: "effect", label: "monitor-job" } + ]) + assert.deepStrictEqual( + model.edges.filter((edge) => edge.transitionId === "Idle:transition:0").map((edge) => edge.label), + ["Begin · 2 branches"] + ) + assert.deepStrictEqual( + model.edges.find((edge) => edge.transitionId === "Idle:transition:0")?.branchIds, + ["Idle:transition:0:branch:0", "Idle:transition:0:branch:1"] + ) + assert.deepStrictEqual(model.initials.map(({ target }) => target), ["Idle"]) + }) + + it("computes nested node coordinates and orthogonal transition routes", async () => { + const model = makeChartModel(MachineDocument.make(machine, { snapshot })) + const layout = await Effect.runPromise(layoutChart(model)) + const application = layout.nodes.find(({ node }) => node.path === "application") + const idle = layout.nodes.find(({ node }) => node.path === "application.workflow.idle") + const transitionEdges = layout.edges.filter((edge) => edge.kind === "transition") + + assert.strictEqual(layout.nodes.length, model.nodes.length) + assert.isAbove(layout.width, 0) + assert.isAbove(layout.height, 0) + assert.isAbove(application?.width ?? 0, idle?.width ?? 0) + assert.strictEqual(transitionEdges.length, model.edges.length) + assert.isTrue(transitionEdges.every((edge) => edge.points.length >= 2)) + }) + + it("lays out targetless transitions as self-loops", async () => { + const model = makeChartModel(MachineDocument.make(machine, { snapshot })) + const refresh = model.edges.find((edge) => edge.label === "Refresh") + + assert.strictEqual(refresh?.kind, "targetless") + assert.strictEqual(refresh?.target, null) + assert.deepStrictEqual(refresh?.branchIds, ["application.workflow.idle:transition:1:branch:0"]) + + const layout = await Effect.runPromise(layoutChart(model)) + const laidOut = layout.edges.find((edge) => edge.kind === "transition" && edge.edge.id === refresh?.id) + assert.strictEqual(laidOut?.kind, "transition") + if (laidOut?.kind === "transition") { + assert.strictEqual(laidOut.edge.source, "application.workflow.idle") + assert.strictEqual(laidOut.edge.target, null) + assert.isAtLeast(laidOut.points.length, 3) + assert.isAtLeast( + Math.max(...laidOut.points.map(({ x }) => x)) - Math.min(...laidOut.points.map(({ x }) => x)), + 30 + ) + } + }) + + it("lays out runtime-resolved targets as explicit stubs", async () => { + const document = MachineDocument.make(plannerMachine) + const source = document.states.find(({ path }) => path === "Idle")! + const runtimeTransition: MachineDocument.Transition = { + id: "Idle:transition:runtime", + source: source.path, + trigger: { type: "event", event: "ResolveTarget" }, + reenter: false, + acceptance: "required", + branches: [{ + id: "Idle:transition:runtime:branch:0", + type: "direct", + target: null, + selection: { path: null, kind: "state", scope: "full" }, + updates: [] + }] + } + const model = makeChartModel({ + ...document, + transitions: [...document.transitions, runtimeTransition] + }) + const runtime = model.edges.find(({ transitionId }) => transitionId === runtimeTransition.id) + if (runtime === undefined) assert.fail("Expected a runtime transition edge") + + assert.strictEqual(runtime.kind, "runtime") + assert.strictEqual(runtime.target, null) + assert.deepStrictEqual(model.runtimeTargets, [{ + id: `runtime:${runtime.id}`, + edgeId: runtime.id, + parent: null, + label: "runtime target" + }]) + + const layout = await Effect.runPromise(layoutChart(model)) + assert.strictEqual(layout.runtimeTargets.length, 1) + assert.isTrue(layout.edges.some((edge) => edge.kind === "transition" && edge.edge.id === runtime.id)) + }) + + it("keeps the point below the pointer fixed while zooming", () => { + assert.deepStrictEqual( + chartZoomScrollPosition(1, 2, 200, 100, { x: 100, y: 50 }), + { x: 500, y: 250 } + ) + }) + + it("maps wheel gestures to bounded zoom levels", () => { + assert.isAbove(chartWheelZoom(1, -100, 0, 800), 1) + assert.isBelow(chartWheelZoom(1, 100, 0, 800), 1) + assert.strictEqual(chartWheelZoom(1, -10_000, 0, 800), maximumChartZoom) + assert.strictEqual(chartWheelZoom(1, 10_000, 0, 800), minimumChartZoom) + }) + + it("only treats pointer movement at or beyond the threshold as panning", () => { + assert.isFalse(isChartPan({ x: 0, y: 0 }, { x: 2, y: 2 })) + assert.isTrue(isChartPan({ x: 0, y: 0 }, { x: 3, y: 4 })) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92175ea..766b23c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: chokidar: specifier: 4.0.3 version: 4.0.3 + elkjs: + specifier: 0.11.1 + version: 0.11.1 typescript: specifier: 6.0.3 version: 6.0.3 @@ -832,6 +835,9 @@ packages: effect@4.0.0-rc.111: resolution: {integrity: sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -2094,6 +2100,8 @@ snapshots: fast-check: 4.9.0 msgpackr: 2.0.5 + elkjs@0.11.1: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 diff --git a/scripts/check-devtools-architecture.mjs b/scripts/check-devtools-architecture.mjs index f975d3c..bd06583 100644 --- a/scripts/check-devtools-architecture.mjs +++ b/scripts/check-devtools-architecture.mjs @@ -34,7 +34,7 @@ const publicExports = [ ".", "./DevToolsProtocol", "./MachineDocument", - "./MachineSimulator", + "./MachineWalkthrough", "./package.json", "./internal/*" ] diff --git a/scripts/devtools-pack-check.mjs b/scripts/devtools-pack-check.mjs index a78dbcf..0e6fc92 100644 --- a/scripts/devtools-pack-check.mjs +++ b/scripts/devtools-pack-check.mjs @@ -134,7 +134,7 @@ try { `await import("@typeonce/effect-machine-devtools"); await import("@typeonce/effect-machine-devtools/DevToolsProtocol"); await import("@typeonce/effect-machine-devtools/MachineDocument"); - await import("@typeonce/effect-machine-devtools/MachineSimulator");` + await import("@typeonce/effect-machine-devtools/MachineWalkthrough");` ], { cwd: consumer }) const privateImport = spawnSync(process.execPath, [ "--input-type=module", @@ -146,7 +146,15 @@ try { } const port = await availablePort() - child = spawn(binary, ["--root", consumer, "--host", "127.0.0.1", "--port", String(port)], { + child = spawn(binary, [ + "--root", + consumer, + "--host", + "127.0.0.1", + "--port", + String(port), + "--watch-polling" + ], { cwd: consumer, env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] @@ -163,7 +171,7 @@ try { return result?._tag === "Ready" && result.document?.machineId === "packed-fixture" ? snapshot : undefined }) const page = await fetch(endpoint) - if (!page.ok || !(await page.text()).includes("Effect Machine · Text visualizer")) { + if (!page.ok || !(await page.text()).includes("Effect Machine · Statechart")) { throw new Error("The installed package did not serve the browser application") }