From 41d00a1348f65d76b39a3bf57d031c3042571e5d Mon Sep 17 00:00:00 2001 From: cevr Date: Mon, 31 Aug 2026 00:11:16 -0400 Subject: [PATCH] feat: replace inspector hub with spawn inspection --- .changeset/bright-actors-inspect.md | 5 ++ README.md | 3 +- docs/inspection.md | 35 ++++++--- examples/core/src/inspection.ts | 16 ++-- src/actor.ts | 113 +++++++++++++++++----------- src/index.ts | 2 - src/inspection.ts | 62 +-------------- src/internal/inspection.ts | 59 ++++++++++++--- src/internal/utils.ts | 1 + src/machine.ts | 4 + test/inspection.test.ts | 99 ++++++++++++++++++++---- test/type-constraints.test.ts | 20 ++++- 12 files changed, 267 insertions(+), 152 deletions(-) create mode 100644 .changeset/bright-actors-inspect.md diff --git a/.changeset/bright-actors-inspect.md b/.changeset/bright-actors-inspect.md new file mode 100644 index 0000000..10d830c --- /dev/null +++ b/.changeset/bright-actors-inspect.md @@ -0,0 +1,5 @@ +--- +"effect-machine": minor +--- + +Replace the Inspector Hub with typed `inspect` spawn options and late system-wide registration through `actor.system.inspect`. diff --git a/README.md b/README.md index 142dbcc..f25a7ea 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,8 @@ Read [Atom and UI integration](./docs/atom-and-ui.md) and browse [all examples]( - Durability saves committed transitions. - Supervision restarts defects within an Effect `Schedule` budget. - Inspection reports events, named transition operations, transitions, named guards, tasks, Effects, errors, stops, and actor generations. -- Dynamic Inspector hubs let lazy tools observe existing actors without actor restart or state duplication. +- Typed `inspect` spawn options observe one actor. +- `actor.system.inspect` lets a late tool observe all actors in one system. Read [Persistence and supervision](./docs/persistence-and-supervision.md) and [Inspection](./docs/inspection.md). diff --git a/docs/inspection.md b/docs/inspection.md index d95bc65..b496a01 100644 --- a/docs/inspection.md +++ b/docs/inspection.md @@ -1,12 +1,17 @@ # Inspection -Provide `InspectorService` when you allocate an actor. The actor captures it with the rest of its Effect context. +Pass `inspect` when you spawn one actor. The inspector uses the machine state and event types. ```ts const actor = - yield * Machine.spawn(machine).pipe(Effect.provideService(InspectorService, consoleInspector())); + yield * + Machine.spawn(machine, { + inspect: consoleInspector(), + }); ``` +You can also provide `InspectorService` as an ambient Effect service. The spawn option replaces the ambient inspector for that actor. + Inspection events cover: - actor spawn @@ -42,22 +47,34 @@ machine.on(State.Accepted, Event.Submit, function submitOrder({ state }) { Each inspection event includes the actor generation. Generation zero is the first run. The value increases after each supervised restart. -The console inspector logs readable machine events with Effect logging. The tracing inspector emits spans and events. The collecting inspector stores typed events for tests. `combineInspectors` isolates an inspector failure from the other inspectors. +The console inspector logs readable machine events with Effect logging. The tracing inspector emits spans and events. The collecting inspector stores typed events for tests. `combineInspectors` runs inspectors in order. It isolates each inspector failure. -Use `makeInspectorHub` when inspection consumers load after actors start. Provide the hub Inspector before actor startup. Register and unregister sinks later without restarting actors or duplicating actor state. +Use `actor.system.inspect` when an inspection consumer loads after an actor starts. The system inspector receives events from all actors in that system. ```ts -const hub = makeInspectorHub(); -const actor = - yield * Machine.spawn(machine).pipe(Effect.provideService(InspectorService, hub.inspector)); +const actor = yield * Machine.spawn(machine); yield * actor.start; -const unregister = hub.register(collectingInspector(events)); +const unregister = actor.system.inspect( + makeInspector((event) => { + events.push(event); + }), +); yield * actor.send(Event.Refresh); unregister(); ``` -The late sink receives future inspection events. It does not receive events emitted before registration. The hub isolates each sink failure from the actor and the other sinks. +The late inspector receives future events. It does not receive prior events. A system inspector is heterogeneous. Use `makeInspector()` without machine-specific type arguments. The runtime runs the actor inspector first. It then runs system inspectors in registration order. It waits for each inspector. It isolates each failure. + +Effect code can scope a late registration with `Effect.acquireRelease`. + +```ts +yield * + Effect.acquireRelease( + Effect.sync(() => actor.system.inspect(consoleInspector())), + (unregister) => Effect.sync(unregister), + ); +``` Do not log secrets in state, events, or tracing attributes. diff --git a/examples/core/src/inspection.ts b/examples/core/src/inspection.ts index ab31364..491284b 100644 --- a/examples/core/src/inspection.ts +++ b/examples/core/src/inspection.ts @@ -1,12 +1,5 @@ import { Effect, Schema } from "effect"; -import { - collectingInspector, - Event, - InspectorService, - Machine, - State, - type InspectionEvent, -} from "effect-machine"; +import { collectingInspector, Event, Machine, State, type InspectionEvent } from "effect-machine"; const AccessState = State({ Locked: { attempts: Schema.Finite }, @@ -41,9 +34,10 @@ const accessMachine = Machine.make({ export const inspectionProgram = Effect.gen(function* () { const events: Array> = []; - const actor = yield* Machine.spawn(accessMachine, { id: "access" }).pipe( - Effect.provideService(InspectorService, collectingInspector(events)), - ); + const actor = yield* Machine.spawn(accessMachine, { + id: "access", + inspect: collectingInspector(events), + }); yield* actor.start; yield* actor.send(AccessEvent.EnterCode({ code: "0000" })); yield* actor.send(AccessEvent.EnterCode({ code: "1234" })); diff --git a/src/actor.ts b/src/actor.ts index 12f214b..65fe78b 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -33,7 +33,11 @@ import type { InspectorService } from "./inspection.js"; import { Inspector as InspectorTag } from "./inspection.js"; import { resolveTransition, resolveTransitionEffect } from "./internal/transition.js"; import type { ProcessEventHooks, ProcessEventResult } from "./internal/transition.js"; -import { emitWithTimestamp, makeInspectionHooks } from "./internal/inspection.js"; +import { + emitWithTimestamp, + makeInspectionDispatcher, + makeInspectionHooks, +} from "./internal/inspection.js"; import type { NoReplyError } from "./errors.js"; import { DuplicateActorError, ActorStoppedError } from "./errors.js"; import { @@ -354,14 +358,28 @@ export interface ActorSystemService { * Returns an unsubscribe function. */ readonly subscribe: (fn: SystemEventListener) => () => void; + + /** + * Inspect all actors in this system. + * + * Registration affects future events only. The returned function removes the inspector. + */ + readonly inspect: ( + inspector: InspectorService<{ readonly _tag: string }, { readonly _tag: string }>, + ) => () => void; } export type SystemSpawnOptions = { readonly supervision?: Supervision.Policy; readonly lifecycle?: Lifecycle; readonly hydrate?: S; + readonly inspect?: InspectorService; } & ([Input] extends [void] ? { readonly input?: never } : { readonly input: Input }); +type SystemInspector = InspectorService; + +const systemInspectorsBySystem = new WeakMap>(); + /** * ActorSystem service tag */ @@ -792,12 +810,13 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < hydrated?: boolean; supervision?: Supervision.Policy; lifecycle?: Lifecycle; + inspect?: InspectorService; /** @internal Called by system after each restart — emits ActorRestarted system event */ onRestart?: (generation: number, exit: ActorExit) => Effect.Effect; }, ) { const lifecycle: Lifecycle | undefined = options.lifecycle; - const serviceContext = yield* Effect.context(); + const capturedContext = yield* Effect.context(); // Spawn is cold. The caller has already resolved machine input and hydration. // Recovery runs during start, not allocate. @@ -807,10 +826,13 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < const { system, implicitSystemScope } = yield* resolveActorSystem(); - // Get optional inspector from context - const inspectorValue = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as + const ambientInspector = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as | InspectorService | undefined; + const localInspector = options.inspect ?? ambientInspector; + const systemInspectors = systemInspectorsBySystem.get(system) ?? new Set(); + const inspectorValue = makeInspectionDispatcher(localInspector, systemInspectors); + const serviceContext = Context.add(capturedContext, InspectorTag, inspectorValue); // Actor-specific state const childrenMap = new Map>(); @@ -820,10 +842,8 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < // Generation counter. Recovery and inspection use the same value. const generation = { current: 0 }; - const inspectionHooks = (runtimeGeneration: number): ProcessEventHooks | undefined => { - if (inspectorValue === undefined) return undefined; - return makeInspectionHooks(id, inspectorValue, () => runtimeGeneration); - }; + const inspectionHooks = (runtimeGeneration: number): ProcessEventHooks => + makeInspectionHooks(id, inspectorValue, () => runtimeGeneration); // Cell-owned resources: stable across generations (supervision) const stateRef = yield* SubscriptionRef.make(initial); @@ -868,44 +888,35 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < /** Build lifecycle hooks for a generation */ const buildRuntimeLifecycle = (runtimeGeneration: number): RuntimeLifecycleHooks => { let stopEmitted = false; - let onEvent: RuntimeLifecycleHooks["onEvent"] = undefined; - if (inspectorValue !== undefined) { - onEvent = (state: S, event: E) => - emitWithTimestamp(inspectorValue, (timestamp) => ({ - type: "@machine.event", - actorId: id, - generation: runtimeGeneration, - state, - event, - timestamp, - })); - } - let onFinal: RuntimeLifecycleHooks["onFinal"] = undefined; - if (inspectorValue !== undefined) { - onFinal = (state: S) => - Effect.gen(function* () { - stopEmitted = true; - yield* emitWithTimestamp(inspectorValue, (timestamp) => ({ - type: "@machine.stop", - actorId: id, - generation: runtimeGeneration, - finalState: state, - timestamp, - })); - }); - } - let onInitialSpawnEffects: RuntimeLifecycleHooks["onInitialSpawnEffects"] = undefined; - if (inspectorValue !== undefined) { - onInitialSpawnEffects = (state: S) => - emitWithTimestamp(inspectorValue, (timestamp) => ({ - type: "@machine.effect", + const onEvent: RuntimeLifecycleHooks["onEvent"] = (state, event) => + emitWithTimestamp(inspectorValue, (timestamp) => ({ + type: "@machine.event", + actorId: id, + generation: runtimeGeneration, + state, + event, + timestamp, + })); + const onFinal: RuntimeLifecycleHooks["onFinal"] = (state) => + Effect.gen(function* () { + stopEmitted = true; + yield* emitWithTimestamp(inspectorValue, (timestamp) => ({ + type: "@machine.stop", actorId: id, generation: runtimeGeneration, - effectType: "spawn", - state, + finalState: state, timestamp, })); - } + }); + const onInitialSpawnEffects: RuntimeLifecycleHooks["onInitialSpawnEffects"] = (state) => + emitWithTimestamp(inspectorValue, (timestamp) => ({ + type: "@machine.effect", + actorId: id, + generation: runtimeGeneration, + effectType: "spawn", + state, + timestamp, + })); return { onEvent, onStateChange: (result, event) => @@ -1129,6 +1140,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { // Observable infrastructure const eventPubSub = yield* PubSub.unbounded(); const eventListeners = new Set(); + const systemInspectors = new Set(); const emitSystemEvent = (event: SystemEvent): Effect.Effect => Effect.sync(() => notifySystemListeners(eventListeners, event)).pipe( @@ -1143,7 +1155,11 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { MutableHashMap.forEach(actorsMap, (actor) => { stops.push(actor.stop); }); - return Effect.all(stops).pipe(Effect.andThen(PubSub.shutdown(eventPubSub)), Effect.asVoid); + return Effect.all(stops).pipe( + Effect.andThen(Effect.sync(() => systemInspectors.clear())), + Effect.andThen(PubSub.shutdown(eventPubSub)), + Effect.asVoid, + ); }); /** Check for duplicate ID, register actor, attach scope cleanup if available */ @@ -1244,6 +1260,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { hydrated: spawnOptions?.hydrate !== undefined, supervision: spawnOptions?.supervision, lifecycle: spawnOptions?.lifecycle, + inspect: spawnOptions?.inspect, onRestart, }); actorRef = actor as unknown as ActorRef; @@ -1339,7 +1356,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { return true; }); - return ActorSystem.of({ + const system = ActorSystem.of({ spawn, get, watch, @@ -1358,7 +1375,15 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { eventListeners.delete(fn); }; }, + inspect: (inspector) => { + systemInspectors.add(inspector); + return () => { + systemInspectors.delete(inspector); + }; + }, }); + systemInspectorsBySystem.set(system, systemInspectors); + return system; }); /** diff --git a/src/index.ts b/src/index.ts index d8195f7..cbe7350 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,6 @@ export type { EventReceivedEvent, InspectionEvent, InspectorService as Inspector, - InspectorHub, InspectorHandler, OperationEvent, SpawnEvent, @@ -106,6 +105,5 @@ export { Inspector as InspectorService, makeInspector, makeInspectorEffect, - makeInspectorHub, tracingInspector, } from "./inspection.js"; diff --git a/src/inspection.ts b/src/inspection.ts index e299ceb..8e02beb 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -1,5 +1,7 @@ import { Effect, Option, Context, type Schema } from "effect"; +import { runInspectors } from "./internal/inspection.js"; + // ============================================================================ // Type-level helpers // ============================================================================ @@ -158,14 +160,6 @@ export interface InspectorService { readonly onInspect: InspectorHandler; } -/** - * Dynamic Inspector fan-out for applications that load inspection consumers after actors start. - */ -export interface InspectorHub { - readonly inspector: InspectorService; - readonly register: (inspector: InspectorService) => () => void; -} - /** * Inspector service tag - optional service for machine introspection * Uses `any` types to allow variance flexibility when providing the service @@ -191,62 +185,12 @@ export const makeInspectorEffect = , ResolveType>) => Effect.Effect, ): InspectorService, ResolveType> => ({ onInspect }); -const inspectionEffect = ( - inspector: InspectorService, - event: InspectionEvent, -): Effect.Effect => - Effect.suspend(() => { - const result = inspector.onInspect(event); - if (Effect.isEffect(result)) return result; - return Effect.void; - }); - export const combineInspectors = ( ...inspectors: ReadonlyArray> ): InspectorService => ({ - onInspect: (event) => - Effect.forEach( - inspectors, - (inspector) => inspectionEffect(inspector, event).pipe(Effect.ignoreCause), - { concurrency: 16, discard: true }, - ), + onInspect: (event) => runInspectors(inspectors, event), }); -/** - * Create an Inspector that accepts sinks throughout its lifetime. - * - * Provide `hub.inspector` before actor startup. Consumers can then register and unregister sinks - * without restarting the actor. A sink failure does not affect the actor or other sinks. - */ -export const makeInspectorHub = < - S = { readonly _tag: string }, - E = { readonly _tag: string }, ->(): InspectorHub, ResolveType> => { - type State = ResolveType; - type Event = ResolveType; - - const inspectorByRegistration = new Map>(); - const inspector: InspectorService = { - onInspect: (event) => - Effect.forEach( - Array.from(inspectorByRegistration.values()), - (registeredInspector) => - inspectionEffect(registeredInspector, event).pipe(Effect.ignoreCause), - { concurrency: 16, discard: true }, - ), - }; - - const register = (registeredInspector: InspectorService): (() => void) => { - const registration = Symbol(); - inspectorByRegistration.set(registration, registeredInspector); - return () => { - inspectorByRegistration.delete(registration); - }; - }; - - return { inspector, register }; -}; - export interface TracingInspectorOptions { readonly spanName?: string | ((event: InspectionEvent) => string); readonly attributes?: ( diff --git a/src/internal/inspection.ts b/src/internal/inspection.ts index 71d892c..79d38de 100644 --- a/src/internal/inspection.ts +++ b/src/internal/inspection.ts @@ -3,6 +3,54 @@ import { Cause, Clock, Effect } from "effect"; import type { InspectionEvent, InspectorService } from "../inspection.js"; import type { ProcessEventHooks } from "./transition.js"; +type Tagged = { readonly _tag: string }; + +const inspectorActivity = new WeakMap boolean>(); + +/** Run one inspector without letting its failure affect the machine. */ +export const runInspector = Effect.fn("effect-machine.runInspector")(function* ( + inspector: InspectorService, + event: InspectionEvent, +) { + const result = yield* Effect.try(() => inspector.onInspect(event)).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (Effect.isEffect(result)) { + yield* result.pipe(Effect.ignoreCause); + } +}); + +/** Run inspectors in order and isolate each failure. */ +export const runInspectors = Effect.fn("effect-machine.runInspectors")(function* ( + inspectors: Iterable>, + event: InspectionEvent, +) { + for (const inspector of inspectors) { + yield* runInspector(inspector, event); + } +}); + +/** Build the actor-owned dispatcher for local and system inspection. */ +// @effect-diagnostics missingPipeableSignature:off -- Internal fixed-arity constructor. +export const makeInspectionDispatcher = ( + localInspector: InspectorService | undefined, + systemInspectors: ReadonlySet>, +): InspectorService => { + const inspector: InspectorService = { + onInspect: (event) => + Effect.gen(function* () { + if (localInspector !== undefined) { + yield* runInspector(localInspector, event); + } + for (const systemInspector of systemInspectors) { + yield* runInspector(systemInspector, event); + } + }), + }; + inspectorActivity.set(inspector, () => localInspector !== undefined || systemInspectors.size > 0); + return inspector; +}; + /** * Emit an inspection event with timestamp from Clock. * @internal @@ -11,19 +59,12 @@ export const emitWithTimestamp = Effect.fn("effect-machine.emitWithTimestamp")(f inspector: InspectorService | undefined, makeEvent: (timestamp: number) => InspectionEvent, ) { - if (inspector === undefined) { + if (inspector === undefined || inspectorActivity.get(inspector)?.() === false) { return; } const timestamp = yield* Clock.currentTimeMillis; const event = makeEvent(timestamp); - // onInspect is user-supplied and may throw; a failing inspector must never - // break the machine it observes. - const result = yield* Effect.try(() => inspector.onInspect(event)).pipe( - Effect.orElseSucceed(() => undefined), - ); - if (Effect.isEffect(result)) { - yield* result.pipe(Effect.ignoreCause); - } + yield* runInspector(inspector, event); }); /** Adapt the Inspector service to the transition kernel. */ diff --git a/src/internal/utils.ts b/src/internal/utils.ts index 8572f6a..806dbfc 100644 --- a/src/internal/utils.ts +++ b/src/internal/utils.ts @@ -145,4 +145,5 @@ export const stubSystem: ActorSystemService = { return new Map(); }, subscribe: () => () => {}, + inspect: () => () => {}, }; diff --git a/src/machine.ts b/src/machine.ts index 8da19b1..70b0322 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -58,6 +58,7 @@ import { import type { BackgroundEffect, SpawnEffect, Transition } from "./internal/machine-definition.js"; import type { ActorRef, ActorSystemService, TransitionInfo } from "./actor.js"; import { Inspector as InspectorTag } from "./inspection.js"; +import type { InspectorService } from "./inspection.js"; // ============================================================================ // Core types @@ -1361,6 +1362,7 @@ export type SpawnOptions = { readonly hydrate?: S; readonly supervision?: Supervision.Policy; readonly lifecycle?: Lifecycle; + readonly inspect?: InspectorService; } & ([Input] extends [void] ? { readonly input?: never } : { readonly input: Input }); const spawnImpl = Effect.fn("effect-machine.spawn")(function* < @@ -1385,6 +1387,7 @@ const spawnImpl = Effect.fn("effect-machine.spawn")(function* < hydrated: opts?.hydrate !== undefined, supervision: opts?.supervision, lifecycle: opts?.lifecycle, + inspect: opts?.inspect, }); // If an ActorScope exists in context, attach cleanup automatically @@ -1405,6 +1408,7 @@ const spawnImpl = Effect.fn("effect-machine.spawn")(function* < * * // With lifecycle (recovery + durability) * const actor = yield* Machine.spawn(machine, { + * inspect: consoleInspector(), * lifecycle: { * recovery: { resolve: (ctx) => storage.get("actor-state") }, * durability: { save: (commit) => storage.set("actor-state", commit.nextState) }, diff --git a/test/inspection.test.ts b/test/inspection.test.ts index cf3d7c9..ed56d28 100644 --- a/test/inspection.test.ts +++ b/test/inspection.test.ts @@ -4,12 +4,12 @@ import { Data, Duration, Effect, Schema } from "effect"; import { ActorSystemDefault, ActorSystemService, + type AnyInspectionEvent, combineInspectors, collectingInspector, type InspectionEvent, makeInspector, makeInspectorEffect, - makeInspectorHub, InspectorService, Machine, State, @@ -65,6 +65,33 @@ describe("Inspection", () => { ); }); + it.scopedLive("uses the spawn inspector instead of the ambient inspector", () => { + const ambientEvents: InspectionEvent[] = []; + const spawnEvents: InspectionEvent[] = []; + + return Effect.gen(function* () { + const machine = Machine.make({ + state: TestState, + event: TestEvent, + initial: TestState.Idle, + }).on(TestState.Idle, TestEvent.Fetch, ({ event }) => TestState.Loading({ url: event.url })); + + const system = yield* ActorSystemService; + const actor = yield* system.spawn("spawn-inspector", machine, { + inspect: collectingInspector(spawnEvents), + }); + yield* actor.send(TestEvent.Fetch({ url: "https://example.com" })); + yield* yieldFibers; + + expect(spawnEvents.some((event) => event.type === "@machine.spawn")).toBe(true); + expect(spawnEvents.some((event) => event.type === "@machine.transition")).toBe(true); + expect(ambientEvents).toHaveLength(0); + }).pipe( + Effect.provide(ActorSystemDefault), + Effect.provideService(InspectorService, collectingInspector(ambientEvents)), + ); + }); + it.scopedLive("emits event received on send", () => { const events: InspectionEvent[] = []; @@ -161,6 +188,7 @@ describe("Inspection", () => { const actor = yield* Machine.spawn(machine, { id: "supervised-inspection", supervision: Supervision.restart({ maxRestarts: 1 }), + inspect: collectingInspector(events), }); yield* actor.start; yield* actor.send(TestEvent.Fetch({ url: "https://example.com" })); @@ -172,7 +200,7 @@ describe("Inspection", () => { (event) => event.type === "@machine.event" && event.event._tag === "Reset", ); expect(resetEvent?.generation).toBe(1); - }).pipe(Effect.provideService(InspectorService, collectingInspector(events))); + }); }); it.scopedLive("emits spawn effect events", () => { @@ -394,9 +422,8 @@ describe("Inspection", () => { ); }); - it.scopedLive("registers and unregisters Inspector sinks after actor startup", () => { - const events: InspectionEvent[] = []; - const hub = makeInspectorHub(); + it.scopedLive("registers and unregisters system inspectors after actor startup", () => { + const events: AnyInspectionEvent[] = []; return Effect.gen(function* () { const machine = Machine.make({ @@ -410,12 +437,12 @@ describe("Inspection", () => { const system = yield* ActorSystemService; const actor = yield* system.spawn("late-inspector", machine); - const unregisterFailing = hub.register( + const unregisterFailing = actor.system.inspect( makeInspector(() => { throw new InspectorBoomError({ message: "boom" }); }), ); - const unregisterCollector = hub.register(collectingInspector(events)); + const unregisterCollector = actor.system.inspect(collectingInspector(events)); yield* actor.send(TestEvent.Fetch({ url: "https://example.com" })); yield* yieldFibers; @@ -427,6 +454,9 @@ describe("Inspection", () => { expect(events.every((event) => event.generation === 0)).toBe(true); expect((yield* actor.snapshot)._tag).toBe("Loading"); + yield* system.spawn("other-inspected-actor", machine); + expect(events.some((event) => event.actorId === "other-inspected-actor")).toBe(true); + unregisterFailing(); unregisterCollector(); const eventCount = events.length; @@ -436,10 +466,48 @@ describe("Inspection", () => { expect(events).toHaveLength(eventCount); expect((yield* actor.snapshot)._tag).toBe("Idle"); - }).pipe( - Effect.provide(ActorSystemDefault), - Effect.provideService(InspectorService, hub.inspector), - ); + }).pipe(Effect.provide(ActorSystemDefault)); + }); + + it.scopedLive("runs actor and system inspectors in registration order", () => { + const calls: string[] = []; + + return Effect.gen(function* () { + const machine = Machine.make({ + state: TestState, + event: TestEvent, + initial: TestState.Idle, + }).on(TestState.Idle, TestEvent.Fetch, ({ event }) => TestState.Loading({ url: event.url })); + const system = yield* ActorSystemService; + const unregisterFirst = system.inspect( + makeInspectorEffect((event) => + Effect.sync(() => { + if (event.type === "@machine.transition") calls.push("system-first"); + }), + ), + ); + const unregisterSecond = system.inspect( + makeInspectorEffect((event) => + Effect.sync(() => { + if (event.type === "@machine.transition") calls.push("system-second"); + }), + ), + ); + const actor = yield* system.spawn("ordered-inspectors", machine, { + inspect: makeInspectorEffect((event) => + Effect.sync(() => { + if (event.type === "@machine.transition") calls.push("actor"); + }), + ), + }); + + yield* actor.send(TestEvent.Fetch({ url: "https://example.com" })); + yield* yieldFibers; + + expect(calls).toEqual(["actor", "system-first", "system-second"]); + unregisterFirst(); + unregisterSecond(); + }).pipe(Effect.provide(ActorSystemDefault)); }); it.scopedLive("tracing inspector does not break actor processing", () => @@ -515,7 +583,7 @@ describe("Inspection", () => { Start: {}, Success: {}, }); - const events: InspectionEvent[] = []; + const events: AnyInspectionEvent[] = []; return Effect.gen(function* () { const machine = Machine.make({ @@ -532,6 +600,7 @@ describe("Inspection", () => { .final(TaskState.Done); const system = yield* ActorSystemService; + const unregister = system.inspect(collectingInspector(events)); const actor = yield* system.spawn("task-events", machine); yield* actor.send(TaskEvent.Start); @@ -551,9 +620,7 @@ describe("Inspection", () => { expect(event.taskName).toBe("load-user"); } } - }).pipe( - Effect.provide(ActorSystemDefault), - Effect.provideService(InspectorService, collectingInspector(events)), - ); + unregister(); + }).pipe(Effect.provide(ActorSystemDefault)); }); }); diff --git a/test/type-constraints.test.ts b/test/type-constraints.test.ts index 252a9aa..fccc850 100644 --- a/test/type-constraints.test.ts +++ b/test/type-constraints.test.ts @@ -19,6 +19,7 @@ import { Machine, State, Event, + makeInspector, simulate, } from "../src/index.js"; import { EntityMachine, toEntity } from "../src/cluster/index.js"; @@ -249,7 +250,24 @@ const _test3e = () => { }; }; -// Test 3f: Final output is inferred independently from final state +// Test 3f: Spawn inspection is actor-typed. System inspection is heterogeneous. +const inspectionMachine = Machine.make({ + state: MyState, + event: MyEvent, + initial: MyState.Idle, +}); +const actorInspector = makeInspector(() => {}); +const systemInspector = makeInspector(() => {}); +const _inspectedActor = Machine.spawn(inspectionMachine, { inspect: actorInspector }); +const _inspectionTypes = Effect.gen(function* () { + const system = yield* ActorSystemService; + yield* system.spawn("inspected", inspectionMachine, { inspect: actorInspector }); + system.inspect(systemInspector); + // @ts-expect-error - A system inspector must accept events from every actor in the system + system.inspect(actorInspector); +}); + +// Test 3g: Final output is inferred independently from final state const outputMachine = Machine.make({ state: MyState, event: MyEvent,