Skip to content

Commit 2bb532a

Browse files
Add live machine inspection
1 parent 4daab43 commit 2bb532a

17 files changed

Lines changed: 1484 additions & 116 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Add live, root-scoped machine inspection through `Machine.prepare(machine).inspection` and `AtomMachine.inspection(machineAtom)`.
6+
7+
The hot Effect `Stream` observes ordered creation, initialization, mailbox delivery and processing, state changes, emissions, Effect and timer activities, and termination for a prepared root and all locally owned descendants:
8+
9+
```ts
10+
const prepared = yield * Machine.prepare(machine)
11+
12+
yield * prepared.inspection.pipe(
13+
Stream.runForEach((event) => Console.log(event.sequence, event.subject.id, event._tag)),
14+
Effect.forkScoped({ startImmediately: true })
15+
)
16+
17+
const ref = yield * prepared.start
18+
```
19+
20+
Inspection is non-replayed, never fails, and completes with the root. Its session ids and ordering are local to one prepared ownership tree; distributed identity and delivery remain an Effect Cluster concern.

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,56 @@ const ref = yield * prepared.start
227227
not observe startup emissions. Preparation does not retain or replay an
228228
emission: the observer is simply subscribed before initialization begins.
229229

230+
### Inspect a live machine tree
231+
232+
`Machine.prepare(machine).inspection` is the operational counterpart to the
233+
domain-facing `changes` and `emissions` streams. It observes the prepared root
234+
and every locally owned child, `Logic` process, Effect, and timer in one total
235+
publication order:
236+
237+
```ts
238+
const prepared = yield * Machine.prepare(checkout)
239+
240+
yield * prepared.inspection.pipe(
241+
Stream.runForEach((record) => Console.log(record.sequence, record.subject.id, record._tag)),
242+
Effect.forkScoped({ startImmediately: true })
243+
)
244+
245+
const checkoutRef = yield * prepared.start
246+
```
247+
248+
For a handled input, the stream may expose values such as:
249+
250+
```ts
251+
{ _tag: "EventSent", sequence: 2, deliveryId: 0,
252+
subject: { id: "checkout", sessionId: "machine:0", kind: "Machine" },
253+
source: undefined, target: { id: "checkout", sessionId: "machine:0" },
254+
event: CheckoutEvents.Submit(), causedBy: undefined }
255+
256+
{ _tag: "EventProcessed", sequence: 4, macrostepId: 0,
257+
deliveryId: 0, handled: true, configurationChanged: true,
258+
before: { status: "active", state: /* ... */ },
259+
after: { status: "active", state: /* ... */ }, microsteps: [/* ... */] }
260+
```
261+
262+
The closed `Machine.Inspection.Event` union also reports creation,
263+
initialization and startup failure, direct `Logic` state updates, outward
264+
emissions, Effect/timer activity lifecycles, and termination. Records erase
265+
unrelated child protocols to `unknown`; application-level observation remains
266+
typed through each reference's `changes` and `emissions`.
267+
268+
The stream is hot, non-replayed, never fails, and completes after the root
269+
terminates. Subscribe before `prepared.start` to capture initialization. Local
270+
session ids are unique only inside that prepared ownership tree: `machine:0`
271+
is the root and later ids identify its descendants. They are intentionally not
272+
distributed identities. Cluster placement, routing, and correlation continue
273+
to use Cluster entity, runner, and request identities at the integration
274+
boundary.
275+
276+
`AtomMachine.inspection(machineAtom)` provides the same root-scoped stream and
277+
starts a fresh atom-backed machine only after its inspection subscription is
278+
installed.
279+
230280
Invalid event and emission constructions fail the machine with a typed
231281
`MachineSchemaDecodeError`; they do not throw from the constructor call.
232282

docs/agent-guide.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,51 @@ yield* prepared.emissions.pipe(
548548
const ref = yield* prepared.start
549549
```
550550

551+
`prepared.inspection` is a third, operational stream. It covers the root and
552+
its complete local ownership tree rather than one machine protocol. Subscribe
553+
before `prepared.start` when creation and initialization records matter:
554+
555+
```ts
556+
const prepared = yield* Machine.prepare(machine)
557+
yield* prepared.inspection.pipe(
558+
Stream.runForEach((event) => Console.log(event.sequence, event.subject.id, event._tag)),
559+
Effect.forkScoped({ startImmediately: true })
560+
)
561+
const ref = yield* prepared.start
562+
```
563+
564+
`Machine.Inspection.Event` is a closed union:
565+
566+
- `Created`, `Initialized`, and `StartFailed` describe process startup;
567+
- `EventSent` records accepted mailbox delivery and `EventProcessed` records
568+
the committed macrostep, including retained transitions, raised events,
569+
emissions, commands, and entry/exit paths for each microstep;
570+
- `StateChanged` describes direct updates made by generic `Logic`;
571+
- `Emitted` records actual outward notification publication;
572+
- `ActivityStarted` and `ActivityStopped` describe Effect and timer invokes;
573+
- `Terminated` carries the final `done`, `error`, or `stopped` snapshot.
574+
575+
Every record has a root-local `sequence`, `rootSessionId`, and `subject`.
576+
`deliveryId` correlates acceptance with processing; `macrostepId` correlates
577+
work caused by one statechart input. `source` is present for sends originating
578+
inside the inspected tree. `origin` distinguishes a root, state-owned invoke,
579+
and explicit spawn. Child machine and generic process protocols are erased to
580+
`unknown` because one stream can contain unrelated types.
581+
582+
Inspection is hot, non-replayed, never fails, and completes with the prepared
583+
root. It is not a replacement for `changes`, which retains the latest lifecycle
584+
snapshot, or `emissions`, which remains the typed domain-notification channel.
585+
Invalid decoded inputs or emissions still fail the owning machine through its
586+
typed `MachineSchemaDecodeError`; inspection never turns validation into a
587+
throw or a stream failure.
588+
589+
Session ids are deterministic and unique only inside one prepared local tree
590+
(`machine:0`, `machine:1`, ...). Do not persist them as globally unique actor
591+
ids. Distributed identity, placement, delivery, and request correlation belong
592+
to Effect Cluster and its entity, runner, shard, and request identifiers. A
593+
Cluster adapter may translate local inspection records into telemetry, but the
594+
core machine stream does not claim cross-node identity or ordering.
595+
551596
For child-to-parent input, export a public builder protocol and reuse it at both
552597
composition boundaries:
553598

@@ -590,6 +635,11 @@ Atom-backed machines retain the same transient semantics. Use
590635
return streams requiring the corresponding `AtomRegistry`; emissions are not
591636
stored as atom state.
592637

638+
Use `AtomMachine.inspection(machineAtom)` for root-scoped operational records.
639+
It installs the subscription before a fresh bridge starts, so initialization,
640+
owned children, and activities are visible without storing inspection records
641+
in atom state.
642+
593643
For asynchronous validation or persistence, invoke an Effect or child machine
594644
from the state and handle its typed success or failure event in a later
595645
transition. This keeps `(state, event) => [nextState, commands]` synchronous.

src/Machine.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type * as Cause from "effect/Cause"
88
import type * as Duration from "effect/Duration"
99
import type * as Effect from "effect/Effect"
10+
import type * as Exit from "effect/Exit"
1011
import type * as Option from "effect/Option"
1112
import type { Pipeable } from "effect/Pipeable"
1213
import { hasProperty } from "effect/Predicate"
@@ -1664,6 +1665,187 @@ export type RuntimeSnapshot<State, Error = never, Output = never> =
16641665
readonly state: State
16651666
}
16661667

1668+
/**
1669+
* Live, root-scoped records describing one prepared machine tree.
1670+
*
1671+
* Inspection records deliberately erase machine-specific values to `unknown`:
1672+
* one stream may contain unrelated root, child-machine, and `Logic` protocols.
1673+
* The record structure remains a closed discriminated union, while typed
1674+
* application observation continues through `changes` and `emissions`.
1675+
*
1676+
* @category models
1677+
* @since 0.13.0
1678+
*/
1679+
export declare namespace Inspection {
1680+
/** Read-only identity of a local machine endpoint. */
1681+
export interface Endpoint {
1682+
readonly id: string
1683+
readonly sessionId: string
1684+
}
1685+
1686+
/** Read-only identity of a runtime represented in the inspection tree. */
1687+
export interface Subject extends Endpoint {
1688+
readonly kind: "Machine" | "Logic"
1689+
}
1690+
1691+
/** Causal origin of an inspected runtime. */
1692+
export type Origin =
1693+
| { readonly _tag: "Root" }
1694+
| {
1695+
readonly _tag: "Invoke"
1696+
readonly ownerPath: string
1697+
readonly invokeId: string
1698+
}
1699+
| {
1700+
readonly _tag: "Spawn"
1701+
readonly address: string | undefined
1702+
}
1703+
1704+
/** Causal owner of a send or emission. */
1705+
export type Causation =
1706+
| { readonly _tag: "Initialization" }
1707+
| { readonly _tag: "Macrostep"; readonly macrostepId: number }
1708+
| { readonly _tag: "Activity"; readonly activitySessionId: string }
1709+
1710+
/** Closed command projection safe for observation. */
1711+
export type Command =
1712+
| {
1713+
readonly _tag: "SendTo"
1714+
readonly target: Endpoint | { readonly id: string }
1715+
readonly event: unknown
1716+
}
1717+
| {
1718+
readonly _tag: "Stop"
1719+
readonly target: Endpoint | { readonly id: string }
1720+
}
1721+
1722+
/** One statechart microstep in a committed macrostep. */
1723+
export interface Microstep {
1724+
readonly event: unknown
1725+
readonly transitions: ReadonlyArray<Machine.RetainedTransition>
1726+
readonly raisedEvents: ReadonlyArray<unknown>
1727+
readonly emittedEvents: ReadonlyArray<unknown>
1728+
readonly commands: ReadonlyArray<Command>
1729+
readonly exitPaths: ReadonlyArray<string>
1730+
readonly entryPaths: ReadonlyArray<string>
1731+
readonly changed: boolean
1732+
}
1733+
1734+
/** Common ordering and identity fields for every record. */
1735+
export interface Base {
1736+
/** Total publication order within this prepared root. */
1737+
readonly sequence: number
1738+
/** Session id of the prepared root that owns this inspection stream. */
1739+
readonly rootSessionId: string
1740+
/** Runtime instance described by this record. */
1741+
readonly subject: Subject
1742+
}
1743+
1744+
/** Announces allocation of a root or owned process identity. */
1745+
export interface Created extends Base {
1746+
readonly _tag: "Created"
1747+
readonly parent: Subject | undefined
1748+
readonly origin: Origin
1749+
/** Compiled statechart definition; absent for generic `Logic`. */
1750+
readonly definition: Machine.Any | undefined
1751+
}
1752+
1753+
/** Announces successful initialization. */
1754+
export interface Initialized extends Base {
1755+
readonly _tag: "Initialized"
1756+
readonly snapshot: RuntimeSnapshot<unknown, unknown, unknown>
1757+
readonly initialEntryPaths: ReadonlyArray<string>
1758+
readonly microsteps: ReadonlyArray<Microstep>
1759+
}
1760+
1761+
/** Announces failure before an initial runtime snapshot exists. */
1762+
export interface StartFailed extends Base {
1763+
readonly _tag: "StartFailed"
1764+
readonly cause: Cause.Cause<unknown>
1765+
}
1766+
1767+
/** Announces acceptance of an event by a local mailbox. */
1768+
export interface EventSent extends Base {
1769+
readonly _tag: "EventSent"
1770+
readonly deliveryId: number
1771+
readonly source: Subject | undefined
1772+
readonly target: Endpoint
1773+
readonly event: unknown
1774+
readonly causedBy: Causation | undefined
1775+
}
1776+
1777+
/** Announces complete processing of one statechart mailbox event. */
1778+
export interface EventProcessed extends Base {
1779+
readonly _tag: "EventProcessed"
1780+
readonly macrostepId: number
1781+
readonly deliveryId: number
1782+
readonly source: Subject | undefined
1783+
readonly event: unknown
1784+
readonly before: RuntimeSnapshot<unknown, unknown, unknown>
1785+
readonly after: RuntimeSnapshot<unknown, unknown, unknown>
1786+
readonly handled: boolean
1787+
readonly configurationChanged: boolean
1788+
readonly microsteps: ReadonlyArray<Microstep>
1789+
}
1790+
1791+
/** Announces a direct state update made by generic `Logic`. */
1792+
export interface StateChanged extends Base {
1793+
readonly _tag: "StateChanged"
1794+
readonly before: unknown
1795+
readonly after: unknown
1796+
readonly causedByDeliveryId: number | undefined
1797+
}
1798+
1799+
/** Announces actual publication on a machine's domain emission stream. */
1800+
export interface Emitted extends Base {
1801+
readonly _tag: "Emitted"
1802+
readonly emission: unknown
1803+
readonly causedBy: Causation | undefined
1804+
}
1805+
1806+
/** Identity of one Effect or timer invocation run. */
1807+
export interface Activity {
1808+
readonly id: string
1809+
readonly sessionId: string
1810+
readonly owner: Subject
1811+
readonly ownerPath: string
1812+
readonly kind: "Effect" | "Timer"
1813+
}
1814+
1815+
/** Announces an Effect or timer invocation starting. */
1816+
export interface ActivityStarted extends Base {
1817+
readonly _tag: "ActivityStarted"
1818+
readonly activity: Activity
1819+
}
1820+
1821+
/** Announces an Effect or timer invocation outcome. */
1822+
export interface ActivityStopped extends Base {
1823+
readonly _tag: "ActivityStopped"
1824+
readonly activity: Activity
1825+
/** Success or failure from the invoke; interruption means its owner stopped it. */
1826+
readonly exit: Exit.Exit<unknown, unknown>
1827+
}
1828+
1829+
/** Announces a terminal local runtime snapshot. */
1830+
export interface Terminated extends Base {
1831+
readonly _tag: "Terminated"
1832+
readonly snapshot: RuntimeSnapshot<unknown, unknown, unknown>
1833+
}
1834+
1835+
/** Complete live inspection protocol. */
1836+
export type Event =
1837+
| Created
1838+
| Initialized
1839+
| StartFailed
1840+
| EventSent
1841+
| EventProcessed
1842+
| StateChanged
1843+
| Emitted
1844+
| ActivityStarted
1845+
| ActivityStopped
1846+
| Terminated
1847+
}
1848+
16671849
/**
16681850
* Represents a classified terminal outcome derived from a runtime snapshot.
16691851
*
@@ -1726,6 +1908,14 @@ export interface Prepared<out State, in Event, out Error, out Output, out Emitte
17261908
/** Streams ephemeral notifications published after subscription. */
17271909
readonly emissions: Stream.Stream<Emitted>
17281910

1911+
/**
1912+
* Streams ordered operational records for this root and every locally owned
1913+
* descendant. The stream is hot, non-replayed, never fails, and completes
1914+
* after the prepared root terminates. Subscribe before evaluating `start` to
1915+
* observe initialization.
1916+
*/
1917+
readonly inspection: Stream.Stream<Inspection.Event>
1918+
17291919
/** Initializes this machine once and returns its running reference. */
17301920
readonly start: Effect.Effect<MachineRef<State, Event, Error, Output, Emitted>, StartError, StartRequirements>
17311921
}

src/internal/machine/atom.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,32 @@ export const emissions = <State, Event, Error, Output, StartError, Emitted>(
139139
)
140140
}
141141

142+
export const inspection = <State, Event, Error, Output, StartError, Emitted>(
143+
self: MachineAtom<State, Event, Error, Output, StartError, Emitted>
144+
): Stream.Stream<Machine.Inspection.Event, StartError, AtomRegistry.AtomRegistry> => {
145+
const prepared = preparedByMachineAtom.get(self as object)
146+
if (prepared === undefined) return Stream.empty
147+
return Stream.unwrap(
148+
Effect.gen(function*() {
149+
const registry = yield* AtomRegistry.AtomRegistry
150+
const releasePrepared = yield* Effect.sync(() => registry.mount(prepared))
151+
yield* Effect.addFinalizer(() => Effect.sync(releasePrepared))
152+
const machine = yield* Atom.getResult(prepared)
153+
const pull = yield* Stream.toPull(machine.inspection)
154+
const firstPull = yield* pull.pipe(Effect.forkScoped({ startImmediately: true }))
155+
const releaseRef = yield* Effect.sync(() => registry.mount(self.ref))
156+
yield* Effect.addFinalizer(() => Effect.sync(releaseRef))
157+
yield* Atom.getResult(self.ref)
158+
let first = true
159+
return Stream.fromPull(Effect.succeed(Effect.suspend(() => {
160+
if (!first) return pull
161+
first = false
162+
return Fiber.join(firstPull)
163+
})))
164+
})
165+
)
166+
}
167+
142168
export const childEmissions = <Child extends Machine.ChildMachine.Any, StartError>(
143169
self: ChildMachineAtom<Child, StartError>
144170
): Stream.Stream<RefEmitted<Machine.ChildMachine.Ref<Child>>, StartError, AtomRegistry.AtomRegistry> =>

0 commit comments

Comments
 (0)