Skip to content

Commit 2e45e12

Browse files
Add transition snapshots and structural metadata (#25)
1 parent 1312de1 commit 2e45e12

15 files changed

Lines changed: 1340 additions & 22 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Expose a captured full machine snapshot to event, eventless, and completion transition contexts; add serializable state-owned activity inspection for invokes, timers, and child machines; and surface resolved Effect Schema annotations plus descriptive pseudo-state annotations through state-node inspection.

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,39 @@ Refresh: {
379379
keyed by full dotted paths, such as `parents["Form.Editing"]`; `context.parent`
380380
provides the immediate parent directly and is `undefined` at a root state.
381381

382+
Event, eventless, and completion transition contexts also expose `snapshot`, a
383+
read-only view of the complete logical configuration captured at the beginning
384+
of that transition microstep. This lets one parallel region inspect a sibling
385+
without copying active-state facts into parent values:
386+
387+
```ts
388+
BufferReady: ;
389+
;(({ snapshot, target }) =>
390+
States.matches(snapshot, "Player.Network.Online")
391+
? target.local.Playing(State.cases.Playing.make({}))
392+
: undefined)
393+
```
394+
395+
All non-conflicting handlers selected together observe the same captured
396+
snapshot. An Effectful handler keeps that value; it does not read mutable live
397+
runtime state later. `snapshot` is intentionally absent from entry, exit,
398+
invoke, and choice contexts. In particular, startup and chained choices may run
399+
before a complete stable snapshot containing their pseudo-source exists.
400+
401+
Effect Schema annotations are the metadata source for active states. Annotate
402+
the schema itself; `Machine.stateNodes` exposes the resolved annotation map:
403+
404+
```ts
405+
const Saving = State.cases.Saving.annotate({
406+
title: "Saving document",
407+
description: "Persisting local changes to the server"
408+
})
409+
```
410+
411+
Schema-less choice and history nodes accept only descriptive `title`,
412+
`description`, and `documentation` annotations. Titles may be used as display
413+
labels, but structural paths remain the only identity and targeting mechanism.
414+
382415
## Planning Effects and staged actions
383416

384417
An Effect returned by a transition handler is part of planning. Use it to read
@@ -472,6 +505,14 @@ Descriptors are matched by id and machine identity, so independently created
472505
descriptors for the same pair address the same child without a global cache.
473506
Exporting one descriptor remains the clearest module boundary.
474507

508+
`Machine.activityDefinitions(machine)` inspects state-owned work without
509+
executing it. Static descriptors report their source path, lifecycle id, and
510+
kind. Timers also report normalized duration and emitted event tag;
511+
`invokeEffect` mappings are described as dynamic; invoked machines expose only
512+
safe child identity. A function-valued `invoke` factory is reported as dynamic
513+
and is never evaluated during inspection. The result is serializable and does
514+
not contain Effects, closures, services, or child runtimes.
515+
475516
## Reactivity
476517

477518
`AtomMachine.make` creates a lazy bridge backed by one running machine per

docs/agent-guide.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,47 @@ parents["Route.Ready.Editing"]
347347

348348
Do not guess short properties such as `parents.Ready`.
349349

350+
### Inspecting the full transition configuration
351+
352+
Event, `always`, and `onDone` transition contexts include a fully typed
353+
`snapshot`. It is the complete logical snapshot at the beginning of that
354+
microstep, before any selected transition is applied:
355+
356+
```ts
357+
BufferReady: ({ snapshot, target }) =>
358+
States.matches(snapshot, "Player.Network.Online")
359+
? target.local.Playing(new Playing({}))
360+
: undefined
361+
```
362+
363+
Use the existing `States.matches`, `States.get`, `States.getWithParents`, and
364+
`States.getSnapshot` helpers for cross-region reads. Parallel transitions
365+
selected in one microstep receive the same capture. Effectful handlers retain
366+
that captured value rather than consulting live runtime state.
367+
368+
Do not expect `snapshot` in entry, exit, invoke, initializer, history-default,
369+
or choice contexts. Choice is an important soundness boundary: a startup or
370+
chained choice can run without a complete stable configuration containing the
371+
pseudo-source, so the API does not fabricate a partial `Machine.Snapshot`.
372+
373+
### State annotations
374+
375+
Attach active-state metadata through Effect Schema:
376+
377+
```ts
378+
const Saving = State.cases.Saving.annotate({
379+
title: "Saving document",
380+
description: "Persisting local changes to the server",
381+
documentation: "https://docs.example.test/saving"
382+
})
383+
```
384+
385+
`Machine.stateNodes(machine)` returns the resolved annotation map. Choice and
386+
history definitions may declare an `annotations` object containing only
387+
`title`, `description`, and `documentation`. These values are descriptive;
388+
they cannot change behavior, identity, or targeting. Visualization may show a
389+
title, while the structural path remains authoritative.
390+
350391
Use `Machine.retag(TargetCase, source, patch?)` when sibling state payloads
351392
share fields. It removes the source discriminator, reuses only compatible
352393
fields, and requires a patch for every missing or incompatible required field.
@@ -508,6 +549,25 @@ Use the separate
508549
`Machine.childAddress<Event>(id)` constructor only for lower-level process
509550
logic that does not have a complete machine descriptor.
510551

552+
### Inspecting state-owned activities
553+
554+
Use `Machine.activityDefinitions(machine)` to inspect invokes without running
555+
them. Static `Machine.invoke`, `Machine.invokeEffect`, `Machine.after`, and
556+
`Machine.invokeMachine` descriptors expose serializable ownership metadata:
557+
558+
```ts
559+
Machine.activityDefinitions(machine)
560+
// [{ source: "Loading", id: "load-timeout", type: "timer",
561+
// duration: "10s", event: "LoadTimedOut" }]
562+
```
563+
564+
Effect success/failure mappers are closures and therefore appear as dynamic
565+
outcomes. Child machines expose descriptor identity, never their runtime or
566+
implementation. A function-valued `invoke` factory is represented as a dynamic
567+
activity because inspection must not evaluate user code. The existing invoke
568+
helpers remain the only execution API; this metadata does not add lifecycle
569+
configuration syntax or affect execution.
570+
511571
## AtomMachine and React
512572

513573
`AtomMachine.make(machine, ...input)` works when the machine has no external

src/Machine.ts

Lines changed: 108 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type * as Cause from "effect/Cause"
8-
import type * as Duration from "effect/Duration"
8+
import * as Duration from "effect/Duration"
99
import * as Effect from "effect/Effect"
1010
import * as Inspectable from "effect/Inspectable"
1111
import * as Option from "effect/Option"
@@ -15,6 +15,7 @@ import type * as Schema from "effect/Schema"
1515
import type * as Scope from "effect/Scope"
1616
import type * as Stream from "effect/Stream"
1717
import type * as Types from "effect/Types"
18+
import * as Activities from "./internal/machineActivities.js"
1819
import type {
1920
ChildAlreadyExistsError,
2021
InfiniteTransitionError,
@@ -1868,6 +1869,28 @@ export declare namespace Machine {
18681869
*/
18691870
export type TaggedSchema = Schema.Top & { readonly Type: { readonly _tag: PropertyKey } }
18701871

1872+
/**
1873+
* Descriptive annotations exposed for compiled state nodes.
1874+
*
1875+
* Schema-backed states resolve their complete Effect Schema annotation map.
1876+
* Pseudo-states accept only the descriptive fields below. Annotations never
1877+
* affect state identity, targeting, or runtime behavior.
1878+
*
1879+
* @category models
1880+
* @since 4.0.0
1881+
*/
1882+
export interface StateNodeAnnotations extends Schema.Annotations.Annotations {
1883+
readonly title?: string | undefined
1884+
readonly description?: string | undefined
1885+
readonly documentation?: string | undefined
1886+
}
1887+
1888+
/** Descriptive annotations accepted by schema-less pseudo-states. */
1889+
export type PseudoStateAnnotations = Pick<
1890+
StateNodeAnnotations,
1891+
"title" | "description" | "documentation"
1892+
>
1893+
18711894
/**
18721895
* Configuration accepted for an atomic object state node.
18731896
*
@@ -1928,6 +1951,7 @@ export declare namespace Machine {
19281951
readonly type: "history"
19291952
/** Defaults to shallow history. */
19301953
readonly history?: "shallow" | "deep"
1954+
readonly annotations?: PseudoStateAnnotations
19311955
}
19321956

19331957
/**
@@ -1942,6 +1966,7 @@ export declare namespace Machine {
19421966
*/
19431967
export interface ChoiceStateNodeConfig {
19441968
readonly type: "choice"
1969+
readonly annotations?: PseudoStateAnnotations
19451970
}
19461971

19471972
/**
@@ -2075,10 +2100,12 @@ export declare namespace Machine {
20752100
readonly key: string
20762101
readonly schema: TaggedSchema | undefined
20772102
readonly output: Schema.Top | undefined
2103+
/** Resolved Effect Schema annotations, or descriptive pseudo-state annotations. */
2104+
readonly annotations: Readonly<StateNodeAnnotations> | undefined
20782105
readonly type: "atomic" | "compound" | "parallel" | "final" | "history" | "choice"
20792106
readonly history: "shallow" | "deep" | undefined
20802107
readonly parent: Path | undefined
2081-
/** Active child paths. History pseudo-states are available through their `parent` relationship. */
2108+
/** Active child paths. Pseudo-states are available through their `parent` relationship. */
20822109
readonly children: ReadonlyArray<Path>
20832110
readonly initial: Path | undefined
20842111
readonly order: number
@@ -2156,6 +2183,18 @@ export declare namespace Machine {
21562183
readonly targets: TransitionTargets<TargetPath>
21572184
}
21582185

2186+
/**
2187+
* Serializable description of state-owned work.
2188+
*
2189+
* Static invoke descriptors expose their lifecycle id and kind without
2190+
* retaining Effects, closures, services, or child runtimes. A function-valued
2191+
* invoke factory is reported as dynamic and is never evaluated by inspection.
2192+
*
2193+
* @category models
2194+
* @since 4.0.0
2195+
*/
2196+
export type ActivityDefinition<SourcePath extends string = string> = Activities.ActivityDefinition<SourcePath>
2197+
21592198
/**
21602199
* Transition retained after hierarchy precedence and conflict resolution for
21612200
* one planned microstep.
@@ -3174,6 +3213,8 @@ export declare namespace Machine {
31743213
readonly state: StateByIdentifier<States, StateId>
31753214
readonly parent: ParentStateValue<States, StateId>
31763215
readonly parents: ParentStateValues<States, StateId>
3216+
/** Complete logical configuration captured at the start of this microstep. */
3217+
readonly snapshot: Snapshot<States>
31773218
readonly event: EventByTag<Events, EventTag>
31783219
readonly runtime: RuntimeEffect<Events, Emits>
31793220

@@ -3261,6 +3302,8 @@ export declare namespace Machine {
32613302
readonly state: StateByIdentifier<States, StateId>
32623303
readonly parent: ParentStateValue<States, StateId>
32633304
readonly parents: ParentStateValues<States, StateId>
3305+
/** Complete logical configuration captured at the start of this microstep. */
3306+
readonly snapshot: Snapshot<States>
32643307
readonly event: LifecycleEvent<Events>
32653308
readonly runtime: RuntimeEffect<Events, Emits>
32663309

@@ -3289,6 +3332,8 @@ export declare namespace Machine {
32893332
readonly state: StateByIdentifier<States, StateId>
32903333
readonly parent: ParentStateValue<States, StateId>
32913334
readonly parents: ParentStateValues<States, StateId>
3335+
/** Complete logical configuration captured at the start of this microstep. */
3336+
readonly snapshot: Snapshot<States>
32923337
readonly event: LifecycleEvent<Events>
32933338
readonly output: CompletionOutputByIdentifier<States, StateId>
32943339
readonly runtime: RuntimeEffect<Events, Emits>
@@ -3725,6 +3770,8 @@ export declare namespace Machine {
37253770
readonly requirements: Types.Covariant<ChildRequirements>
37263771
readonly initialError: Types.Covariant<ChildInitialError>
37273772
}
3773+
/** @internal Serializable descriptor metadata used by inspection. */
3774+
readonly [Activities.ActivityMetadataTypeId]?: Activities.StaticActivityMetadata
37283775
readonly id: string
37293776
/**
37303777
* Optional parent-local address for sending events to this invocation.
@@ -5948,7 +5995,11 @@ export const invoke = <
59485995
ChildRequirements,
59495996
ChildOutput,
59505997
ChildInitialError
5951-
> => ({ ...config, [InvokeTypeId]: undefined as any })
5998+
> => ({
5999+
...config,
6000+
[InvokeTypeId]: undefined as any,
6001+
[Activities.ActivityMetadataTypeId]: { type: "process" }
6002+
})
59526003

59536004
type InvokeEffectResult<Requirements, Event> = Machine.InvokeConfig<
59546005
any,
@@ -6020,8 +6071,8 @@ export const invokeEffect = <
60206071
readonly effect: Effect.Effect<unknown, unknown, unknown>
60216072
readonly onSuccess: (value: unknown) => unknown
60226073
readonly onFailure?: (error: unknown) => unknown
6023-
}) =>
6024-
invoke({
6074+
}) => ({
6075+
...invoke({
60256076
id: config.id,
60266077
src: () =>
60276078
effect(
@@ -6032,7 +6083,15 @@ export const invokeEffect = <
60326083
onSuccess: (value) => Effect.succeed(config.onSuccess(value))
60336084
})
60346085
)
6035-
}))(config as any) as any
6086+
}),
6087+
[Activities.ActivityMetadataTypeId]: {
6088+
type: "effect",
6089+
outcomes: {
6090+
success: "dynamic",
6091+
failure: config.onFailure === undefined ? "none" : "dynamic"
6092+
}
6093+
}
6094+
}))(config as any) as any
60366095

60376096
/**
60386097
* Creates a cancellable state-scoped delayed event.
@@ -6048,11 +6107,17 @@ export const after = <Event extends { readonly _tag: PropertyKey }>(
60486107
duration: Duration.Input,
60496108
event: Event,
60506109
options?: { readonly id?: InvokeLifecycleId }
6051-
): InvokeEffectResult<never, Event> =>
6052-
invoke({
6110+
): InvokeEffectResult<never, Event> => ({
6111+
...invoke({
60536112
id: options?.id ?? `Machine.after:${String(event._tag)}`,
60546113
src: () => effect(Effect.as(Effect.sleep(duration), event))
6055-
})
6114+
}),
6115+
[Activities.ActivityMetadataTypeId]: {
6116+
type: "timer",
6117+
duration: Duration.format(Duration.fromInputUnsafe(duration)),
6118+
event: String(event._tag)
6119+
}
6120+
})
60566121

60576122
type RetagFields<Target extends Machine.TaggedSchema> = Omit<Target["~type.make.in"], "_tag">
60586123

@@ -6317,6 +6382,13 @@ export const invokeMachine: {
63176382
: (internalProcess.toProcessLogic as any)(machine, config.input),
63186383
snapshot: config.snapshot,
63196384
onDone: config.onDone,
6385+
[Activities.ActivityMetadataTypeId]: {
6386+
type: "machine",
6387+
child: {
6388+
id: config.child.id,
6389+
machineId: machine.id ?? null
6390+
}
6391+
},
63206392
[InvokeTypeId]: undefined as any
63216393
}
63226394
}) as any
@@ -6426,10 +6498,11 @@ export const planInitial: <
64266498
*
64276499
* **Details**
64286500
*
6429-
* The result includes atomic, compound, parallel, final, and history nodes.
6430-
* Use each node's `parent` property to reconstruct the complete hierarchy.
6431-
* History pseudo-states are intentionally omitted from `children` because they
6432-
* can never appear in an active configuration.
6501+
* The result includes atomic, compound, parallel, final, history, and choice
6502+
* nodes together with their resolved descriptive annotations. Use each node's
6503+
* `parent` property to reconstruct the complete hierarchy. Pseudo-states are
6504+
* intentionally omitted from `children` because they can never appear in an
6505+
* active configuration.
64336506
*
64346507
* @category getters
64356508
* @since 4.0.0
@@ -6472,14 +6545,34 @@ export const transitionDefinitions = <M extends Machine.Any>(
64726545
>
64736546
>
64746547

6548+
/**
6549+
* Returns serializable descriptions of every state-owned activity.
6550+
*
6551+
* **Details**
6552+
*
6553+
* Static `invoke`, `invokeEffect`, `after`, and `invokeMachine` descriptors
6554+
* expose stable ownership and lifecycle metadata without serializing runtime
6555+
* values. Function-valued invoke factories are represented as dynamic and are
6556+
* never evaluated during inspection.
6557+
*
6558+
* @category getters
6559+
* @since 4.0.0
6560+
*/
6561+
export const activityDefinitions = <M extends Machine.Any>(
6562+
machine: M
6563+
): ReadonlyArray<Machine.ActivityDefinition<Machine.StateIdentifier<Machine.States<M>>>> =>
6564+
Activities.activityDefinitions(machine) as ReadonlyArray<
6565+
Machine.ActivityDefinition<Machine.StateIdentifier<Machine.States<M>>>
6566+
>
6567+
64756568
/**
64766569
* Returns every state node active in a decoded snapshot, in definition order.
64776570
*
64786571
* **Details**
64796572
*
64806573
* Active compound ancestors and parallel regions are included together with
6481-
* their active descendants. History pseudo-states are never active and are not
6482-
* returned.
6574+
* their active descendants. History and choice pseudo-states are never active
6575+
* and are not returned.
64836576
*
64846577
* @category getters
64856578
* @since 4.0.0

0 commit comments

Comments
 (0)