Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/calm-planners-trace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@typeonce/effect-machine": minor
"@typeonce/effect-machine-devtools": minor
---

Add planner-backed simulation sessions to the web visualizer. Machine and event inputs are rendered as fields from their Effect schemas, including type and constraint metadata, nested objects, arrays, unions, enums, literals, booleans, strings, and numbers. Browser constraints provide immediate feedback, while authoritative Effect Schema failures are mapped back to their fields. Each isolated step uses the real Effect Machine planner and shows selected branches, concrete topology changes, raised and emitted events, planned commands, completion, and output as a structured trace.

Expose `Machine.inputEventSchemas` so inspection tools can describe or construct valid public events without reaching into the opaque event protocol. Planning evaluates synchronous statechart callbacks but does not commit commands or start runtime activities. Schema and planning failures remain visible beside the machine topology.
14 changes: 10 additions & 4 deletions packages/devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,22 @@ Run the devtools only against code you trust. The server has no authentication a

The visualizer shows topology, active initial paths, state annotations, events, transitions, branches, state updates, activities, source metadata, and diagnostics. The tree supports pointer and keyboard navigation, subtree expansion, related-state highlighting, and structured detail inspection.

Simulation works from the serialized machine document and never runs project code. It advances only when an event has one required direct transition whose target is statically known.
Simulation uses the same `Machine.planInitial` and `Machine.plan` semantics as the core package. 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.

Declinable transitions, conditional branches, parallel transitions, history, and choices return an indeterminate result. Deterministic steps report skipped state updates, runtime effects, raised events, reentry lifecycles, and automatic stabilization instead of pretending to execute them.
Start a session, send an enabled event, and inspect the resulting macrostep as structured microsteps. Events without payload fields run when clicked; events with input open a form first. The trace includes selected branches, before/after topology, exits, entries, state updates, raised events, emitted events, planned commands, completion, and output.

Each plan loads the exported machine in a fresh worker, decodes the portable session snapshot, and evaluates synchronous statechart callbacks. This supports conditional branches, parallel transitions, history, choices, state updates, reentry, and automatic stabilization. It also means synchronous code inside initial, transition, entry, exit, choice, history, and output callbacks runs during planning.

The planner does not commit commands, start activities, invoke children, deliver `sendTo` events, or run returned Effects. Commands and emissions are shown in the trace instead. A worker is discarded after every request and a planning request is limited to ten seconds, but the devtools are still intended only for trusted projects.

Simulation sessions use encoded snapshots and are tied to one source revision. A file change remounts the latest document; restart the simulation to use the new definition. Schema decoding and planning failures remain visible as diagnostics without discarding the topology.

## Programmatic modules

The first release publishes three programmatic modules:

- `DevToolsProtocol` defines the versioned worker and browser messages.
- `DevToolsProtocol` defines the versioned worker, browser, and planner-session messages.
- `MachineDocument` defines and constructs the serializable inspection document.
- `MachineSimulator` provides the side-effect-free document simulator.
- `MachineSimulator` provides the conservative, document-only simulator for consumers that cannot load project code.

The project inspector, registry, worker, and local server remain implementation modules. Their interfaces can change without becoming package-level compatibility commitments.
8 changes: 6 additions & 2 deletions packages/devtools/src/DevServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"
import * as Schema from "effect/Schema"
import * as internal from "./internal/devServer.js"
import type * as MachineRegistry from "./MachineRegistry.js"
import type * as ProjectInspector from "./ProjectInspector.js"

/**
* @category models
Expand Down Expand Up @@ -41,5 +42,8 @@ export class DevServerError extends Schema.Error<DevServerError>(
* @category constructors
* @since 0.23.0
*/
export const run = (options: Options): Effect.Effect<never, DevServerError, MachineRegistry.MachineRegistry> =>
internal.run(DevServerError, options)
export const run = (options: Options): Effect.Effect<
never,
DevServerError,
MachineRegistry.MachineRegistry | ProjectInspector.ProjectInspector
> => internal.run(DevServerError, options)
287 changes: 286 additions & 1 deletion packages/devtools/src/DevToolsProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as MachineDocument from "./MachineDocument.js"
* @category models
* @since 0.23.0
*/
export const protocolVersion = 1 as const
export const protocolVersion = 2 as const

/**
* @category schemas
Expand Down Expand Up @@ -144,3 +144,288 @@ export const RegistrySnapshot = Schema.Struct({
* @since 0.23.0
*/
export type RegistrySnapshot = Schema.Schema.Type<typeof RegistrySnapshot>

/**
* 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<typeof EncodedSnapshot>

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<typeof StartSimulation>

/**
* 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<typeof SendSimulationEvent>

/**
* 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<typeof SimulationRequest>

/**
* 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<typeof SimulationSnapshot>

/**
* 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<typeof PlannedTransition>

/**
* 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<typeof PlannedCommand>

/**
* 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<typeof SimulationMicrostep>

/**
* 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<typeof SimulationFrame>

/**
* 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<typeof SimulationReady>

/**
* 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<typeof InputIssue>

/**
* 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<typeof SimulationFailed>

/**
* 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<typeof SimulationResult>
Loading