Skip to content

Commit 41d00a1

Browse files
committed
feat: replace inspector hub with spawn inspection
1 parent 113569d commit 41d00a1

12 files changed

Lines changed: 267 additions & 152 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect-machine": minor
3+
---
4+
5+
Replace the Inspector Hub with typed `inspect` spawn options and late system-wide registration through `actor.system.inspect`.

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,8 @@ Read [Atom and UI integration](./docs/atom-and-ui.md) and browse [all examples](
208208
- Durability saves committed transitions.
209209
- Supervision restarts defects within an Effect `Schedule` budget.
210210
- Inspection reports events, named transition operations, transitions, named guards, tasks, Effects, errors, stops, and actor generations.
211-
- Dynamic Inspector hubs let lazy tools observe existing actors without actor restart or state duplication.
211+
- Typed `inspect` spawn options observe one actor.
212+
- `actor.system.inspect` lets a late tool observe all actors in one system.
212213

213214
Read [Persistence and supervision](./docs/persistence-and-supervision.md) and [Inspection](./docs/inspection.md).
214215

‎docs/inspection.md‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
# Inspection
22

3-
Provide `InspectorService` when you allocate an actor. The actor captures it with the rest of its Effect context.
3+
Pass `inspect` when you spawn one actor. The inspector uses the machine state and event types.
44

55
```ts
66
const actor =
7-
yield * Machine.spawn(machine).pipe(Effect.provideService(InspectorService, consoleInspector()));
7+
yield *
8+
Machine.spawn(machine, {
9+
inspect: consoleInspector(),
10+
});
811
```
912

13+
You can also provide `InspectorService` as an ambient Effect service. The spawn option replaces the ambient inspector for that actor.
14+
1015
Inspection events cover:
1116

1217
- actor spawn
@@ -42,22 +47,34 @@ machine.on(State.Accepted, Event.Submit, function submitOrder({ state }) {
4247

4348
Each inspection event includes the actor generation. Generation zero is the first run. The value increases after each supervised restart.
4449

45-
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.
50+
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.
4651

47-
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.
52+
Use `actor.system.inspect` when an inspection consumer loads after an actor starts. The system inspector receives events from all actors in that system.
4853

4954
```ts
50-
const hub = makeInspectorHub<typeof State, typeof Event>();
51-
const actor =
52-
yield * Machine.spawn(machine).pipe(Effect.provideService(InspectorService, hub.inspector));
55+
const actor = yield * Machine.spawn(machine);
5356
yield * actor.start;
5457

55-
const unregister = hub.register(collectingInspector(events));
58+
const unregister = actor.system.inspect(
59+
makeInspector((event) => {
60+
events.push(event);
61+
}),
62+
);
5663
yield * actor.send(Event.Refresh);
5764
unregister();
5865
```
5966

60-
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.
67+
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.
68+
69+
Effect code can scope a late registration with `Effect.acquireRelease`.
70+
71+
```ts
72+
yield *
73+
Effect.acquireRelease(
74+
Effect.sync(() => actor.system.inspect(consoleInspector())),
75+
(unregister) => Effect.sync(unregister),
76+
);
77+
```
6178

6279
Do not log secrets in state, events, or tracing attributes.
6380

‎examples/core/src/inspection.ts‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import { Effect, Schema } from "effect";
2-
import {
3-
collectingInspector,
4-
Event,
5-
InspectorService,
6-
Machine,
7-
State,
8-
type InspectionEvent,
9-
} from "effect-machine";
2+
import { collectingInspector, Event, Machine, State, type InspectionEvent } from "effect-machine";
103

114
const AccessState = State({
125
Locked: { attempts: Schema.Finite },
@@ -41,9 +34,10 @@ const accessMachine = Machine.make({
4134

4235
export const inspectionProgram = Effect.gen(function* () {
4336
const events: Array<InspectionEvent<AccessState, AccessEvent>> = [];
44-
const actor = yield* Machine.spawn(accessMachine, { id: "access" }).pipe(
45-
Effect.provideService(InspectorService, collectingInspector(events)),
46-
);
37+
const actor = yield* Machine.spawn(accessMachine, {
38+
id: "access",
39+
inspect: collectingInspector(events),
40+
});
4741
yield* actor.start;
4842
yield* actor.send(AccessEvent.EnterCode({ code: "0000" }));
4943
yield* actor.send(AccessEvent.EnterCode({ code: "1234" }));

‎src/actor.ts‎

Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import type { InspectorService } from "./inspection.js";
3333
import { Inspector as InspectorTag } from "./inspection.js";
3434
import { resolveTransition, resolveTransitionEffect } from "./internal/transition.js";
3535
import type { ProcessEventHooks, ProcessEventResult } from "./internal/transition.js";
36-
import { emitWithTimestamp, makeInspectionHooks } from "./internal/inspection.js";
36+
import {
37+
emitWithTimestamp,
38+
makeInspectionDispatcher,
39+
makeInspectionHooks,
40+
} from "./internal/inspection.js";
3741
import type { NoReplyError } from "./errors.js";
3842
import { DuplicateActorError, ActorStoppedError } from "./errors.js";
3943
import {
@@ -354,14 +358,28 @@ export interface ActorSystemService {
354358
* Returns an unsubscribe function.
355359
*/
356360
readonly subscribe: (fn: SystemEventListener) => () => void;
361+
362+
/**
363+
* Inspect all actors in this system.
364+
*
365+
* Registration affects future events only. The returned function removes the inspector.
366+
*/
367+
readonly inspect: (
368+
inspector: InspectorService<{ readonly _tag: string }, { readonly _tag: string }>,
369+
) => () => void;
357370
}
358371

359372
export type SystemSpawnOptions<S, E, Input> = {
360373
readonly supervision?: Supervision.Policy;
361374
readonly lifecycle?: Lifecycle<S, E>;
362375
readonly hydrate?: S;
376+
readonly inspect?: InspectorService<S, E>;
363377
} & ([Input] extends [void] ? { readonly input?: never } : { readonly input: Input });
364378

379+
type SystemInspector = InspectorService<AnyState, AnyState>;
380+
381+
const systemInspectorsBySystem = new WeakMap<ActorSystemService, Set<SystemInspector>>();
382+
365383
/**
366384
* ActorSystem service tag
367385
*/
@@ -792,12 +810,13 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
792810
hydrated?: boolean;
793811
supervision?: Supervision.Policy;
794812
lifecycle?: Lifecycle<S, E>;
813+
inspect?: InspectorService<S, E>;
795814
/** @internal Called by system after each restart — emits ActorRestarted system event */
796815
onRestart?: (generation: number, exit: ActorExit<unknown>) => Effect.Effect<void>;
797816
},
798817
) {
799818
const lifecycle: Lifecycle<S, E> | undefined = options.lifecycle;
800-
const serviceContext = yield* Effect.context<R>();
819+
const capturedContext = yield* Effect.context<R>();
801820

802821
// Spawn is cold. The caller has already resolved machine input and hydration.
803822
// Recovery runs during start, not allocate.
@@ -807,10 +826,13 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
807826

808827
const { system, implicitSystemScope } = yield* resolveActorSystem();
809828

810-
// Get optional inspector from context
811-
const inspectorValue = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
829+
const ambientInspector = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
812830
| InspectorService<S, E>
813831
| undefined;
832+
const localInspector = options.inspect ?? ambientInspector;
833+
const systemInspectors = systemInspectorsBySystem.get(system) ?? new Set<SystemInspector>();
834+
const inspectorValue = makeInspectionDispatcher(localInspector, systemInspectors);
835+
const serviceContext = Context.add(capturedContext, InspectorTag, inspectorValue);
814836

815837
// Actor-specific state
816838
const childrenMap = new Map<string, ActorRef<AnyState, unknown>>();
@@ -820,10 +842,8 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
820842
// Generation counter. Recovery and inspection use the same value.
821843
const generation = { current: 0 };
822844

823-
const inspectionHooks = (runtimeGeneration: number): ProcessEventHooks<S, E> | undefined => {
824-
if (inspectorValue === undefined) return undefined;
825-
return makeInspectionHooks(id, inspectorValue, () => runtimeGeneration);
826-
};
845+
const inspectionHooks = (runtimeGeneration: number): ProcessEventHooks<S, E> =>
846+
makeInspectionHooks(id, inspectorValue, () => runtimeGeneration);
827847

828848
// Cell-owned resources: stable across generations (supervision)
829849
const stateRef = yield* SubscriptionRef.make<S>(initial);
@@ -868,44 +888,35 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
868888
/** Build lifecycle hooks for a generation */
869889
const buildRuntimeLifecycle = (runtimeGeneration: number): RuntimeLifecycleHooks<S, E> => {
870890
let stopEmitted = false;
871-
let onEvent: RuntimeLifecycleHooks<S, E>["onEvent"] = undefined;
872-
if (inspectorValue !== undefined) {
873-
onEvent = (state: S, event: E) =>
874-
emitWithTimestamp(inspectorValue, (timestamp) => ({
875-
type: "@machine.event",
876-
actorId: id,
877-
generation: runtimeGeneration,
878-
state,
879-
event,
880-
timestamp,
881-
}));
882-
}
883-
let onFinal: RuntimeLifecycleHooks<S, E>["onFinal"] = undefined;
884-
if (inspectorValue !== undefined) {
885-
onFinal = (state: S) =>
886-
Effect.gen(function* () {
887-
stopEmitted = true;
888-
yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
889-
type: "@machine.stop",
890-
actorId: id,
891-
generation: runtimeGeneration,
892-
finalState: state,
893-
timestamp,
894-
}));
895-
});
896-
}
897-
let onInitialSpawnEffects: RuntimeLifecycleHooks<S, E>["onInitialSpawnEffects"] = undefined;
898-
if (inspectorValue !== undefined) {
899-
onInitialSpawnEffects = (state: S) =>
900-
emitWithTimestamp(inspectorValue, (timestamp) => ({
901-
type: "@machine.effect",
891+
const onEvent: RuntimeLifecycleHooks<S, E>["onEvent"] = (state, event) =>
892+
emitWithTimestamp(inspectorValue, (timestamp) => ({
893+
type: "@machine.event",
894+
actorId: id,
895+
generation: runtimeGeneration,
896+
state,
897+
event,
898+
timestamp,
899+
}));
900+
const onFinal: RuntimeLifecycleHooks<S, E>["onFinal"] = (state) =>
901+
Effect.gen(function* () {
902+
stopEmitted = true;
903+
yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
904+
type: "@machine.stop",
902905
actorId: id,
903906
generation: runtimeGeneration,
904-
effectType: "spawn",
905-
state,
907+
finalState: state,
906908
timestamp,
907909
}));
908-
}
910+
});
911+
const onInitialSpawnEffects: RuntimeLifecycleHooks<S, E>["onInitialSpawnEffects"] = (state) =>
912+
emitWithTimestamp(inspectorValue, (timestamp) => ({
913+
type: "@machine.effect",
914+
actorId: id,
915+
generation: runtimeGeneration,
916+
effectType: "spawn",
917+
state,
918+
timestamp,
919+
}));
909920
return {
910921
onEvent,
911922
onStateChange: (result, event) =>
@@ -1129,6 +1140,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
11291140
// Observable infrastructure
11301141
const eventPubSub = yield* PubSub.unbounded<SystemEvent>();
11311142
const eventListeners = new Set<SystemEventListener>();
1143+
const systemInspectors = new Set<SystemInspector>();
11321144

11331145
const emitSystemEvent = (event: SystemEvent): Effect.Effect<void> =>
11341146
Effect.sync(() => notifySystemListeners(eventListeners, event)).pipe(
@@ -1143,7 +1155,11 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
11431155
MutableHashMap.forEach(actorsMap, (actor) => {
11441156
stops.push(actor.stop);
11451157
});
1146-
return Effect.all(stops).pipe(Effect.andThen(PubSub.shutdown(eventPubSub)), Effect.asVoid);
1158+
return Effect.all(stops).pipe(
1159+
Effect.andThen(Effect.sync(() => systemInspectors.clear())),
1160+
Effect.andThen(PubSub.shutdown(eventPubSub)),
1161+
Effect.asVoid,
1162+
);
11471163
});
11481164

11491165
/** Check for duplicate ID, register actor, attach scope cleanup if available */
@@ -1244,6 +1260,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
12441260
hydrated: spawnOptions?.hydrate !== undefined,
12451261
supervision: spawnOptions?.supervision,
12461262
lifecycle: spawnOptions?.lifecycle,
1263+
inspect: spawnOptions?.inspect,
12471264
onRestart,
12481265
});
12491266
actorRef = actor as unknown as ActorRef<AnyState, unknown>;
@@ -1339,7 +1356,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
13391356
return true;
13401357
});
13411358

1342-
return ActorSystem.of({
1359+
const system = ActorSystem.of({
13431360
spawn,
13441361
get,
13451362
watch,
@@ -1358,7 +1375,15 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
13581375
eventListeners.delete(fn);
13591376
};
13601377
},
1378+
inspect: (inspector) => {
1379+
systemInspectors.add(inspector);
1380+
return () => {
1381+
systemInspectors.delete(inspector);
1382+
};
1383+
},
13611384
});
1385+
systemInspectorsBySystem.set(system, systemInspectors);
1386+
return system;
13621387
});
13631388

13641389
/**

‎src/index.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ export type {
9090
EventReceivedEvent,
9191
InspectionEvent,
9292
InspectorService as Inspector,
93-
InspectorHub,
9493
InspectorHandler,
9594
OperationEvent,
9695
SpawnEvent,
@@ -106,6 +105,5 @@ export {
106105
Inspector as InspectorService,
107106
makeInspector,
108107
makeInspectorEffect,
109-
makeInspectorHub,
110108
tracingInspector,
111109
} from "./inspection.js";

0 commit comments

Comments
 (0)