Skip to content

Commit fc08038

Browse files
Harden machine operation totality (#28)
1 parent f8fa4e3 commit fc08038

7 files changed

Lines changed: 618 additions & 35 deletions

.changeset/wise-machines-harden.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Harden planning, snapshot round trips, and logical runtime resumption for valid typed machines. Initial choices no longer retain abandoned roots, history fallbacks resolve nested choices before snapshot normalization, and the independent finite-model oracle follows nested choice initializers.

src/internal/machinePlanner.ts

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,8 @@ const resolveHistoryTarget = Effect.fnUntraced(function*(
463463
}),
464464
actions: completed.actions,
465465
raisedEvents: completed.raisedEvents,
466-
emittedEvents: completed.emittedEvents
466+
emittedEvents: completed.emittedEvents,
467+
transitions: []
467468
}
468469
}
469470

@@ -481,7 +482,37 @@ const resolveHistoryTarget = Effect.fnUntraced(function*(
481482
if (collected.state === undefined || isHistoryTarget(collected.state) || !isSnapshot(collected.state)) {
482483
throw new Error(`Machine history default for "${target.path}" must return a complete snapshot containing its owner`)
483484
}
484-
const fallbackConfiguration = yield* normalizeConfigurationEffect(machine, collected.state as any)
485+
const fallbackChoice = choiceFromTarget(collected.state)
486+
const choiceResolution = fallbackChoice === undefined
487+
? undefined
488+
: yield* resolveChoiceTarget(
489+
machine,
490+
{
491+
active: new Set(),
492+
values: new Map(),
493+
outputs: new Map(),
494+
history: configuration.history
495+
},
496+
collected.state,
497+
event
498+
)
499+
let fallbackConfiguration = choiceResolution === undefined
500+
? yield* normalizeConfigurationEffect(machine, collected.state as any)
501+
: yield* normalizeTargetConfigurationEffect(machine, {
502+
active: new Set(),
503+
values: new Map(),
504+
outputs: new Map(),
505+
history: configuration.history
506+
}, choiceResolution.target as any)
507+
for (const additionalTarget of choiceResolution?.additionalTargets ?? []) {
508+
fallbackConfiguration = yield* normalizeTargetConfigurationEffect(
509+
machine,
510+
fallbackConfiguration,
511+
additionalTarget as
512+
| Machine.Snapshot<any>
513+
| Machine.Target<any, string>
514+
)
515+
}
485516
if (!fallbackConfiguration.active.has(target.parent)) {
486517
throw new Error(
487518
`Machine history default for "${target.path}" returned a configuration that does not contain owner state "${target.parent}"`
@@ -494,9 +525,10 @@ const resolveHistoryTarget = Effect.fnUntraced(function*(
494525
snapshot: snapshot as any,
495526
values: values as any
496527
}),
497-
actions: collected.actions,
498-
raisedEvents: collected.raisedEvents,
499-
emittedEvents: collected.emittedEvents
528+
actions: [...collected.actions, ...(choiceResolution?.actions ?? [])],
529+
raisedEvents: [...collected.raisedEvents, ...(choiceResolution?.raisedEvents ?? [])],
530+
emittedEvents: [...collected.emittedEvents, ...(choiceResolution?.emittedEvents ?? [])],
531+
transitions: choiceResolution?.transitions ?? []
500532
}
501533
})
502534

@@ -1090,6 +1122,14 @@ const withChoiceValues = (target: unknown, values: Readonly<Record<string, unkno
10901122
})
10911123
}
10921124

1125+
interface ResolvedChoiceTransition {
1126+
readonly source: string
1127+
readonly trigger: Machine.TransitionTrigger
1128+
readonly reenter: false
1129+
readonly target: string
1130+
readonly resolvedTarget: string
1131+
}
1132+
10931133
const resolveChoiceTarget = Effect.fnUntraced(function*(
10941134
machine: Machine.Any,
10951135
configuration: ActiveConfiguration,
@@ -1101,13 +1141,7 @@ const resolveChoiceTarget = Effect.fnUntraced(function*(
11011141
const actions: Array<DeferredAction> = []
11021142
const raisedEvents: Array<unknown> = []
11031143
const emittedEvents: Array<unknown> = []
1104-
const transitions: Array<{
1105-
readonly source: string
1106-
readonly trigger: Machine.TransitionTrigger
1107-
readonly reenter: false
1108-
readonly target: string
1109-
readonly resolvedTarget: string
1110-
}> = []
1144+
const transitions: Array<ResolvedChoiceTransition> = []
11111145
let iterations = 0
11121146

11131147
while (pending.length > 0) {
@@ -1231,6 +1265,7 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*<
12311265
readonly actions: ReadonlyArray<DeferredAction>
12321266
readonly raisedEvents: ReadonlyArray<unknown>
12331267
readonly emittedEvents: ReadonlyArray<unknown>
1268+
readonly transitions: ReadonlyArray<ResolvedChoiceTransition>
12341269
} | undefined
12351270
const reenteredHistoryParent = choiceResolvedTarget !== undefined && isHistoryTarget(choiceResolvedTarget) &&
12361271
selection.transition.reenter && state.active.has(choiceResolvedTarget.parent)
@@ -1269,6 +1304,7 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*<
12691304
const additionalHistoryActions: Array<DeferredAction> = []
12701305
const additionalHistoryRaisedEvents: Array<unknown> = []
12711306
const additionalHistoryEmittedEvents: Array<unknown> = []
1307+
const additionalHistoryChoiceTransitions: Array<ResolvedChoiceTransition> = []
12721308
const additionalChoiceTargets: Array<
12731309
Machine.Snapshot<States> | Machine.Target<States, Machine.StateIdentifier<States>>
12741310
> = []
@@ -1287,6 +1323,7 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*<
12871323
additionalHistoryActions.push(...resolved.actions)
12881324
additionalHistoryRaisedEvents.push(...resolved.raisedEvents)
12891325
additionalHistoryEmittedEvents.push(...resolved.emittedEvents)
1326+
additionalHistoryChoiceTransitions.push(...resolved.transitions)
12901327
}
12911328
const targetPath = target === undefined
12921329
? undefined
@@ -1331,7 +1368,11 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*<
13311368
changed,
13321369
exitPaths: [],
13331370
entryPaths: [],
1334-
choiceTransitions: choiceResolution?.transitions ?? []
1371+
choiceTransitions: [
1372+
...(choiceResolution?.transitions ?? []),
1373+
...(historyResolution?.transitions ?? []),
1374+
...additionalHistoryChoiceTransitions
1375+
]
13351376
} as EvaluatedTransition<States, Event, E, R, Context>
13361377
}
13371378

@@ -1380,7 +1421,11 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*<
13801421
)
13811422
)
13821423
: getEntryPaths(machine, stateAfterTransition, boundary),
1383-
choiceTransitions: choiceResolution?.transitions ?? []
1424+
choiceTransitions: [
1425+
...(choiceResolution?.transitions ?? []),
1426+
...(historyResolution?.transitions ?? []),
1427+
...additionalHistoryChoiceTransitions
1428+
]
13841429
} as EvaluatedTransition<States, Event, E, R, Context>
13851430
})
13861431

@@ -1553,6 +1598,7 @@ export const planInitial: <
15531598
const initialHistoryActions: Array<DeferredAction> = []
15541599
const initialHistoryRaisedEvents: Array<unknown> = []
15551600
const initialHistoryEmittedEvents: Array<unknown> = []
1601+
const initialHistoryChoiceTransitions: Array<ResolvedChoiceTransition> = []
15561602
const resolvedInitialTargets: Array<unknown> = []
15571603
for (
15581604
const target of choiceResolution === undefined
@@ -1568,6 +1614,7 @@ export const planInitial: <
15681614
initialHistoryActions.push(...history.actions)
15691615
initialHistoryRaisedEvents.push(...history.raisedEvents)
15701616
initialHistoryEmittedEvents.push(...history.emittedEvents)
1617+
initialHistoryChoiceTransitions.push(...history.transitions)
15711618
}
15721619
let resolvedConfiguration = choiceResolution === undefined
15731620
? yield* normalizeConfigurationEffect<States>(machine, state as Machine.Snapshot<States>)
@@ -1585,19 +1632,7 @@ export const planInitial: <
15851632
additionalTarget as Machine.Snapshot<States> | Machine.Target<States, Machine.StateIdentifier<States>>
15861633
)
15871634
}
1588-
const configuration: ActiveConfiguration = initialChoice === undefined
1589-
? resolvedConfiguration
1590-
: {
1591-
...resolvedConfiguration,
1592-
active: new Set([
1593-
...resolvedConfiguration.active,
1594-
...Object.keys(initialChoice.values).filter((path) => {
1595-
const node = getNode(machine, path)
1596-
return node.type !== "choice" && node.type !== "history"
1597-
})
1598-
]),
1599-
values: new Map([...Object.entries(initialChoice.values), ...resolvedConfiguration.values])
1600-
}
1635+
const configuration: ActiveConfiguration = resolvedConfiguration
16011636
validateInitialConfiguration(machine, configuration)
16021637
const startingState = snapshotFromConfiguration<States>(machine, configuration)
16031638
const initialEntryPaths = getInitialEntryPaths(machine, configuration)
@@ -1634,7 +1669,7 @@ export const planInitial: <
16341669
choiceResolution === undefined ? [] : [{
16351670
next: configuration,
16361671
event: InitialEvent,
1637-
transitions: choiceResolution.transitions,
1672+
transitions: [...choiceResolution.transitions, ...initialHistoryChoiceTransitions],
16381673
actions: [...choiceResolution.actions, ...initialHistoryActions] as ReadonlyArray<Effect.Effect<void, E, R>>,
16391674
raisedEvents: [
16401675
...choiceResolution.raisedEvents,

src/internal/machineTestReferenceModel.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,20 @@ const choiceResolvedTargetPath = (index: ModelIndex, path: string): string => {
622622
})!
623623
}
624624

625+
const choiceChainResolvedTargetPath = (index: ModelIndex, path: string): string => {
626+
let current = getState(index, path)
627+
const seen = new Set<string>()
628+
while (current.node._tag === "Choice") {
629+
if (seen.has(current.path)) return choiceResolvedTargetPath(index, current.path)
630+
seen.add(current.path)
631+
const selected = getState(index, current.node.selected)
632+
const nestedChoice = entryChoicePath(index, selected.path)
633+
if (nestedChoice === undefined) return choiceResolvedTargetPath(index, current.path)
634+
current = getState(index, nestedChoice)
635+
}
636+
return current.path
637+
}
638+
625639
const isPathInSubtree = (path: string, root: string): boolean => path === root || path.startsWith(`${root}.`)
626640

627641
const expandSelection = (
@@ -898,7 +912,7 @@ const transitionRecord = (index: ModelIndex, transition: FiniteTransition): Refe
898912
? runtimeTargetPath(index, transition)
899913
: entryChoicePath(index, transition.target) === undefined
900914
? runtimeTargetPath(index, transition)
901-
: choiceResolvedTargetPath(index, entryChoicePath(index, transition.target)!)
915+
: choiceChainResolvedTargetPath(index, entryChoicePath(index, transition.target)!)
902916
})
903917

904918
const choiceTransitionRecords = (index: ModelIndex, path: string): ReadonlyArray<ReferenceTransition> => {
@@ -909,26 +923,25 @@ const choiceTransitionRecords = (index: ModelIndex, path: string): ReadonlyArray
909923
if (seen.has(current.path)) break
910924
seen.add(current.path)
911925
const selected = getState(index, current.node.selected)
912-
const selectedTarget = selected.node._tag === "Choice"
913-
? selected.path
914-
: choiceResolvedTargetPath(index, current.path)
926+
const nestedChoice = entryChoicePath(index, selected.path)
927+
const selectedTarget = nestedChoice ?? choiceResolvedTargetPath(index, current.path)
915928
records.push({
916929
source: current.path,
917930
trigger: { type: "choice" },
918931
reenter: false,
919932
target: selectedTarget,
920933
resolvedTarget: choiceResolvedTargetPath(index, current.path)
921934
})
922-
current = getState(index, resolveChoicePath(index, selected.path))
935+
if (nestedChoice === undefined) break
936+
current = getState(index, nestedChoice)
923937
}
924938
return records
925939
}
926940

927941
const initialChoiceTransitionRecords = (index: ModelIndex, path: string): ReadonlyArray<ReferenceTransition> => {
928942
const current = getState(index, path)
929943
if (current.node._tag === "Choice") {
930-
const records = choiceTransitionRecords(index, current.path)
931-
return [...records, ...initialChoiceTransitionRecords(index, resolveChoicePath(index, current.path))]
944+
return choiceTransitionRecords(index, current.path)
932945
}
933946
if (current.node._tag === "Compound") {
934947
return initialChoiceTransitionRecords(index, initialChildPath(current))

test/MachineChoice.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,47 @@ describe("Machine choice pseudo-states", () => {
6666
])
6767
}))
6868

69+
it.effect("does not retain an abandoned initial root after a full choice target", () =>
70+
Effect.gen(function*() {
71+
class Outside extends Schema.TaggedClass<Outside>("InitialChoiceOutside")("InitialChoiceOutside", {}) {}
72+
const states = Machine.defineStates({
73+
Flow: {
74+
schema: Flow,
75+
initial: "Routing",
76+
states: { Routing: { type: "choice" }, Approved }
77+
},
78+
Outside
79+
})
80+
const entries: Array<string> = []
81+
const routed = Machine.make({
82+
states: states.states,
83+
events: [],
84+
initial: () => states.initial.Flow(new Flow({ score: 80 }), (flow) => flow.Routing())
85+
}).handle({
86+
Flow: {
87+
entry: () => Machine.action(Effect.sync(() => entries.push("Flow"))),
88+
states: {
89+
Routing: {
90+
choice: {
91+
targets: ["Outside"],
92+
transition: ({ target }) => target.full.Outside(new Outside({}))
93+
}
94+
}
95+
}
96+
},
97+
Outside: { entry: () => Machine.action(Effect.sync(() => entries.push("Outside"))) }
98+
})
99+
100+
const plan = yield* Machine.planInitial(routed)
101+
assert.deepStrictEqual(Machine.configuration(routed, plan.startingState).map(({ path }) => path), ["Outside"])
102+
assert.deepStrictEqual(plan.initialEntryPaths, ["Outside"])
103+
yield* Machine.runActions(plan.actions, {
104+
raise: () => Effect.void,
105+
sendParent: () => Effect.void
106+
})
107+
assert.deepStrictEqual(entries, ["Outside"])
108+
}))
109+
69110
it.effect("targets a choice from an event and preserves that event", () =>
70111
Effect.gen(function*() {
71112
const initial = yield* Machine.planInitial(machine)
@@ -497,6 +538,61 @@ describe("Machine choice pseudo-states", () => {
497538
assert.strictEqual(plan.state.state.path, "Flow.Active")
498539
}))
499540

541+
it.effect("resolves a nested choice inside a first-use history fallback", () =>
542+
Effect.gen(function*() {
543+
class Active extends Schema.TaggedClass<Active>("FallbackChoiceActive")("FallbackChoiceActive", {}) {}
544+
class Outside extends Schema.TaggedClass<Outside>("FallbackChoiceOutside")("FallbackChoiceOutside", {}) {}
545+
class Resume extends Schema.TaggedClass<Resume>("FallbackChoiceResume")("FallbackChoiceResume", {}) {}
546+
const states = Machine.defineStates({
547+
Flow: {
548+
schema: Flow,
549+
initial: "Active",
550+
states: {
551+
Active,
552+
Routing: { type: "choice" },
553+
Recent: { type: "history" }
554+
}
555+
},
556+
Outside
557+
})
558+
const historyChoice = Machine.make({
559+
states: states.states,
560+
events: [Resume],
561+
initial: () => states.initial.Outside(new Outside({}))
562+
}).handle({
563+
Flow: {
564+
history: {
565+
Recent: {
566+
default: ({ target }) => target.Flow(new Flow({ score: 1 }), (flow) => flow.Routing())
567+
}
568+
},
569+
states: {
570+
Routing: {
571+
choice: {
572+
targets: ["Flow.Active"],
573+
transition: ({ target }) => target.local.Active(new Active({}))
574+
}
575+
}
576+
}
577+
},
578+
Outside: {
579+
on: { FallbackChoiceResume: ({ target }) => target.history.Flow.Recent() }
580+
}
581+
})
582+
583+
const initial = yield* Machine.planInitial(historyChoice)
584+
const resumed = yield* Machine.plan(historyChoice, initial.state, new Resume({}))
585+
assert.strictEqual(resumed.next.path, "Flow")
586+
if (resumed.next.path === "Flow") assert.strictEqual(resumed.next.state.path, "Flow.Active")
587+
assert.deepStrictEqual(
588+
resumed.microsteps[0]?.transitions.map(({ source, trigger }) => ({ source, trigger: trigger.type })),
589+
[
590+
{ source: "Outside", trigger: "event" },
591+
{ source: "Flow.Routing", trigger: "choice" }
592+
]
593+
)
594+
}))
595+
500596
it("inspects declared choice edges without executing the resolver", () => {
501597
assert.deepStrictEqual(Machine.transitionDefinitions(machine).filter(({ trigger }) => trigger.type === "choice"), [
502598
{

0 commit comments

Comments
 (0)