@typeonce/effect-machine 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +8 -8
- package/src/Machine.ts +6873 -0
- package/src/index.ts +1 -0
- package/src/internal/machine/activities.ts +108 -0
- package/src/internal/machine/atom.ts +636 -0
- package/src/internal/machine/cluster.ts +394 -0
- package/src/internal/machine/command.ts +58 -0
- package/src/internal/machine/commandRuntime.ts +43 -0
- package/src/internal/machine/configuration.ts +1331 -0
- package/src/internal/machine/errors.ts +87 -0
- package/src/internal/machine/executionPlan.ts +996 -0
- package/src/internal/machine/invocation.ts +119 -0
- package/src/internal/machine/machine.ts +1747 -0
- package/src/internal/machine/planner.ts +1933 -0
- package/src/internal/machine/process.ts +906 -0
- package/src/internal/machine/protocol.ts +322 -0
- package/src/internal/machine/readiness.ts +10 -0
- package/src/internal/machine/runtime.ts +2512 -0
- package/src/internal/machine/serialization.ts +498 -0
- package/src/internal/machine/stateDefinition.ts +270 -0
- package/src/internal/machine/symbols.ts +2 -0
- package/src/internal/machine/topology.ts +479 -0
- package/src/internal/testing/machine/arbitrary.ts +102 -0
- package/src/internal/testing/machine/exploration.ts +331 -0
- package/src/internal/testing/machine/finiteModel.ts +1498 -0
- package/src/internal/testing/machine/invariant.ts +372 -0
- package/src/internal/testing/machine/probe.ts +79 -0
- package/src/internal/testing/machine/referenceModel.ts +1505 -0
- package/src/internal/testing/machine/runtime.ts +1710 -0
- package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
- package/src/internal/testing/machine/trace.ts +150 -0
- package/src/internal/testing/machine/verification.ts +1890 -0
- package/src/testing/MachineTest.ts +2067 -0
- package/src/testing/index.ts +7 -0
- package/src/unstable/cluster/ClusterMachine.ts +390 -0
- package/src/unstable/cluster/index.ts +1 -0
- package/src/unstable/reactivity/AtomMachine.ts +649 -0
- package/src/unstable/reactivity/index.ts +1 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-defined semantic invariants over retained causal runtime evidence.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.4.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Data from "effect/Data"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Result from "effect/Result"
|
|
10
|
+
import * as Machine from "../../../Machine.js"
|
|
11
|
+
import type {
|
|
12
|
+
CausalRuntimeEvidence,
|
|
13
|
+
InvariantOptions,
|
|
14
|
+
InvariantOutcome,
|
|
15
|
+
PlannerRuntimeAgreementViolation,
|
|
16
|
+
RuntimeCommandInvariant,
|
|
17
|
+
RuntimeCommandInvariantContext,
|
|
18
|
+
RuntimeInvariant,
|
|
19
|
+
RuntimeInvariantBuilder,
|
|
20
|
+
RuntimeInvariantCheckResult,
|
|
21
|
+
RuntimeInvariantRecord,
|
|
22
|
+
RuntimeInvariantReport,
|
|
23
|
+
RuntimeInvariantScope,
|
|
24
|
+
RuntimeInvariantTranscript,
|
|
25
|
+
RuntimeInvariantViolation,
|
|
26
|
+
RuntimeSnapshotInvariant,
|
|
27
|
+
RuntimeSnapshotInvariantContext,
|
|
28
|
+
RuntimeSnapshotInvariantOptions,
|
|
29
|
+
RuntimeSnapshotObservation,
|
|
30
|
+
RuntimeTranscriptInvariant,
|
|
31
|
+
RuntimeTranscriptInvariantContext
|
|
32
|
+
} from "../../../testing/MachineTest.js"
|
|
33
|
+
|
|
34
|
+
type AnyMachine = Machine.Machine.Any
|
|
35
|
+
|
|
36
|
+
const validateName = (name: string): void => {
|
|
37
|
+
if (name.trim().length === 0) {
|
|
38
|
+
throw new Error("MachineTest.runtimeInvariants expected name to be a non-empty string")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const validateRequirement = <Context>(options: InvariantOptions<Context>): void => {
|
|
43
|
+
const minimum = options.require?.minObservations
|
|
44
|
+
if (minimum !== undefined && (!Number.isSafeInteger(minimum) || minimum < 0)) {
|
|
45
|
+
throw new Error("MachineTest.runtimeInvariants expected minObservations to be a non-negative safe integer")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const snapshotInvariant = <M extends AnyMachine>(
|
|
50
|
+
name: string,
|
|
51
|
+
check: (context: RuntimeSnapshotInvariantContext<M>) => InvariantOutcome,
|
|
52
|
+
options: RuntimeSnapshotInvariantOptions<M> = {}
|
|
53
|
+
): RuntimeSnapshotInvariant<M> => {
|
|
54
|
+
validateName(name)
|
|
55
|
+
validateRequirement(options)
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
_tag: "RuntimeSnapshotInvariant" as const,
|
|
58
|
+
name,
|
|
59
|
+
observe: options.observe ?? "settled",
|
|
60
|
+
check,
|
|
61
|
+
...(options.when === undefined ? {} : { when: options.when }),
|
|
62
|
+
...(options.require === undefined ? {} : { require: Object.freeze({ ...options.require }) })
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const commandInvariant = <M extends AnyMachine>(
|
|
67
|
+
name: string,
|
|
68
|
+
check: (context: RuntimeCommandInvariantContext<M>) => InvariantOutcome,
|
|
69
|
+
options: InvariantOptions<RuntimeCommandInvariantContext<M>> = {}
|
|
70
|
+
): RuntimeCommandInvariant<M> => {
|
|
71
|
+
validateName(name)
|
|
72
|
+
validateRequirement(options)
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
_tag: "RuntimeCommandInvariant" as const,
|
|
75
|
+
name,
|
|
76
|
+
check,
|
|
77
|
+
...(options.when === undefined ? {} : { when: options.when }),
|
|
78
|
+
...(options.require === undefined ? {} : { require: Object.freeze({ ...options.require }) })
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const transcriptInvariant = <M extends AnyMachine>(
|
|
83
|
+
name: string,
|
|
84
|
+
check: (context: RuntimeTranscriptInvariantContext<M>) => InvariantOutcome,
|
|
85
|
+
options: InvariantOptions<RuntimeTranscriptInvariantContext<M>> = {}
|
|
86
|
+
): RuntimeTranscriptInvariant<M> => {
|
|
87
|
+
validateName(name)
|
|
88
|
+
validateRequirement(options)
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
_tag: "RuntimeTranscriptInvariant" as const,
|
|
91
|
+
name,
|
|
92
|
+
check,
|
|
93
|
+
...(options.when === undefined ? {} : { when: options.when }),
|
|
94
|
+
...(options.require === undefined ? {} : { require: Object.freeze({ ...options.require }) })
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const runtimeInvariants = <M extends AnyMachine>(_machine: M): RuntimeInvariantBuilder<M> =>
|
|
99
|
+
Object.freeze({
|
|
100
|
+
snapshot: snapshotInvariant,
|
|
101
|
+
command: commandInvariant,
|
|
102
|
+
transcript: transcriptInvariant
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export class RuntimeInvariantError<M extends AnyMachine = AnyMachine> extends Data.TaggedError(
|
|
106
|
+
"MachineTestRuntimeInvariantError"
|
|
107
|
+
)<{
|
|
108
|
+
readonly transcript: RuntimeInvariantTranscript<M>
|
|
109
|
+
readonly violations: ReadonlyArray<RuntimeInvariantViolation<M>>
|
|
110
|
+
readonly report: RuntimeInvariantReport
|
|
111
|
+
}> {}
|
|
112
|
+
|
|
113
|
+
const normalizeTranscript = <M extends AnyMachine, Error, Output>(
|
|
114
|
+
transcript: CausalRuntimeEvidence<M, Error, Output>
|
|
115
|
+
): RuntimeInvariantTranscript<M> => ({
|
|
116
|
+
commands: transcript.commands,
|
|
117
|
+
initial: transcript.initial as RuntimeInvariantTranscript<M>["initial"],
|
|
118
|
+
records: transcript.records.map(({ actual, command, index }) => ({
|
|
119
|
+
index,
|
|
120
|
+
command,
|
|
121
|
+
result: actual.result,
|
|
122
|
+
snapshot: actual.snapshot as RuntimeInvariantRecord<M>["snapshot"],
|
|
123
|
+
awaited: actual.awaited as RuntimeInvariantRecord<M>["awaited"]
|
|
124
|
+
})),
|
|
125
|
+
final: transcript.final as RuntimeInvariantTranscript<M>["final"]
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
interface Observation<M extends AnyMachine, Context> {
|
|
129
|
+
readonly context: Context
|
|
130
|
+
readonly observationIndex?: number
|
|
131
|
+
readonly commandIndex: number | undefined
|
|
132
|
+
readonly awaitedIndex?: number
|
|
133
|
+
readonly phase?: RuntimeSnapshotObservation
|
|
134
|
+
readonly command?: RuntimeInvariantRecord<M>["command"]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const snapshotObservations = <M extends AnyMachine>(
|
|
138
|
+
machine: M,
|
|
139
|
+
transcript: RuntimeInvariantTranscript<M>,
|
|
140
|
+
invariant: RuntimeSnapshotInvariant<M>
|
|
141
|
+
): ReadonlyArray<Observation<M, RuntimeSnapshotInvariantContext<M>>> => {
|
|
142
|
+
const observations: Array<Observation<M, RuntimeSnapshotInvariantContext<M>>> = []
|
|
143
|
+
let observationIndex = 0
|
|
144
|
+
const add = (
|
|
145
|
+
snapshot: RuntimeInvariantTranscript<M>["initial"],
|
|
146
|
+
phase: RuntimeSnapshotObservation,
|
|
147
|
+
record?: RuntimeInvariantRecord<M>,
|
|
148
|
+
awaitedIndex?: number
|
|
149
|
+
): void => {
|
|
150
|
+
const context: RuntimeSnapshotInvariantContext<M> = {
|
|
151
|
+
machine,
|
|
152
|
+
transcript,
|
|
153
|
+
snapshot,
|
|
154
|
+
observationIndex,
|
|
155
|
+
phase,
|
|
156
|
+
commandIndex: record?.index,
|
|
157
|
+
awaitedIndex,
|
|
158
|
+
command: record?.command,
|
|
159
|
+
result: record?.result
|
|
160
|
+
}
|
|
161
|
+
observations.push({
|
|
162
|
+
context,
|
|
163
|
+
observationIndex,
|
|
164
|
+
commandIndex: record?.index,
|
|
165
|
+
...(awaitedIndex === undefined ? {} : { awaitedIndex }),
|
|
166
|
+
phase,
|
|
167
|
+
...(record === undefined ? {} : { command: record.command })
|
|
168
|
+
})
|
|
169
|
+
observationIndex += 1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (invariant.observe === "final") {
|
|
173
|
+
add(transcript.final, "final")
|
|
174
|
+
return observations
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (invariant.observe === "settled" || invariant.observe === "all") {
|
|
178
|
+
add(transcript.initial, "initial")
|
|
179
|
+
for (const record of transcript.records) add(record.snapshot, "command", record)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (invariant.observe === "awaited" || invariant.observe === "all") {
|
|
183
|
+
for (const record of transcript.records) {
|
|
184
|
+
record.awaited.forEach((snapshot, awaitedIndex) => add(snapshot, "awaited", record, awaitedIndex))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return observations
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const commandObservations = <M extends AnyMachine>(
|
|
192
|
+
machine: M,
|
|
193
|
+
transcript: RuntimeInvariantTranscript<M>
|
|
194
|
+
): ReadonlyArray<Observation<M, RuntimeCommandInvariantContext<M>>> =>
|
|
195
|
+
transcript.records.map((record, index) => ({
|
|
196
|
+
context: {
|
|
197
|
+
machine,
|
|
198
|
+
transcript,
|
|
199
|
+
record,
|
|
200
|
+
previous: transcript.records[index - 1],
|
|
201
|
+
index: record.index,
|
|
202
|
+
command: record.command,
|
|
203
|
+
result: record.result,
|
|
204
|
+
snapshot: record.snapshot,
|
|
205
|
+
awaited: record.awaited
|
|
206
|
+
},
|
|
207
|
+
commandIndex: record.index,
|
|
208
|
+
command: record.command
|
|
209
|
+
}))
|
|
210
|
+
|
|
211
|
+
const transcriptObservations = <M extends AnyMachine>(
|
|
212
|
+
machine: M,
|
|
213
|
+
transcript: RuntimeInvariantTranscript<M>
|
|
214
|
+
): ReadonlyArray<Observation<M, RuntimeTranscriptInvariantContext<M>>> => [{
|
|
215
|
+
context: { machine, transcript },
|
|
216
|
+
commandIndex: undefined
|
|
217
|
+
}]
|
|
218
|
+
|
|
219
|
+
const scopeOf = <M extends AnyMachine>(invariant: RuntimeInvariant<M>): RuntimeInvariantScope => {
|
|
220
|
+
switch (invariant._tag) {
|
|
221
|
+
case "RuntimeSnapshotInvariant":
|
|
222
|
+
return "snapshot"
|
|
223
|
+
case "RuntimeCommandInvariant":
|
|
224
|
+
return "command"
|
|
225
|
+
case "RuntimeTranscriptInvariant":
|
|
226
|
+
return "transcript"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const messageOf = (outcome: InvariantOutcome): string | undefined =>
|
|
231
|
+
outcome === true ? undefined : typeof outcome === "string" ? outcome : "Invariant predicate returned false"
|
|
232
|
+
|
|
233
|
+
type EvaluatedInvariant<Context> = InvariantOptions<Context> & {
|
|
234
|
+
readonly name: string
|
|
235
|
+
readonly check: (context: Context) => InvariantOutcome
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const evaluateInvariant = <M extends AnyMachine, Context>(
|
|
239
|
+
invariant: EvaluatedInvariant<Context>,
|
|
240
|
+
scope: RuntimeInvariantScope,
|
|
241
|
+
observations: ReadonlyArray<Observation<M, Context>>
|
|
242
|
+
): {
|
|
243
|
+
readonly check: RuntimeInvariantCheckResult
|
|
244
|
+
readonly violations: ReadonlyArray<RuntimeInvariantViolation<M>>
|
|
245
|
+
} => {
|
|
246
|
+
let observed = 0
|
|
247
|
+
let failures = 0
|
|
248
|
+
const violations: Array<RuntimeInvariantViolation<M>> = []
|
|
249
|
+
|
|
250
|
+
for (const observation of observations) {
|
|
251
|
+
if (invariant.when !== undefined && !invariant.when(observation.context)) continue
|
|
252
|
+
observed += 1
|
|
253
|
+
const message = messageOf(invariant.check(observation.context))
|
|
254
|
+
if (message === undefined) continue
|
|
255
|
+
failures += 1
|
|
256
|
+
violations.push({
|
|
257
|
+
invariant: invariant.name,
|
|
258
|
+
scope,
|
|
259
|
+
kind: "predicate",
|
|
260
|
+
...(observation.observationIndex === undefined ? {} : { observationIndex: observation.observationIndex }),
|
|
261
|
+
commandIndex: observation.commandIndex,
|
|
262
|
+
...(observation.awaitedIndex === undefined ? {} : { awaitedIndex: observation.awaitedIndex }),
|
|
263
|
+
...(observation.phase === undefined ? {} : { phase: observation.phase }),
|
|
264
|
+
...(observation.command === undefined ? {} : { command: observation.command }),
|
|
265
|
+
message
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const minimum = invariant.require?.minObservations ?? 0
|
|
270
|
+
const insufficient = observed < minimum
|
|
271
|
+
if (insufficient) {
|
|
272
|
+
violations.push({
|
|
273
|
+
invariant: invariant.name,
|
|
274
|
+
scope,
|
|
275
|
+
kind: "observations",
|
|
276
|
+
commandIndex: undefined,
|
|
277
|
+
message: `Invariant required at least ${minimum} observation${minimum === 1 ? "" : "s"} but observed ${observed}`
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
check: {
|
|
283
|
+
invariant: invariant.name,
|
|
284
|
+
scope,
|
|
285
|
+
status: failures > 0 ? "failed" : insufficient ? "insufficient" : observed === 0 ? "untested" : "passed",
|
|
286
|
+
observations: observed,
|
|
287
|
+
failures
|
|
288
|
+
},
|
|
289
|
+
violations
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const checkRuntimeInvariants = <M extends AnyMachine, Error, Output>(
|
|
294
|
+
machine: M,
|
|
295
|
+
evidence: CausalRuntimeEvidence<M, Error, Output>,
|
|
296
|
+
invariants: ReadonlyArray<RuntimeInvariant<M>>
|
|
297
|
+
): Effect.Effect<RuntimeInvariantReport, RuntimeInvariantError<M>> =>
|
|
298
|
+
Effect.suspend(() => {
|
|
299
|
+
const transcript = normalizeTranscript(evidence)
|
|
300
|
+
const checks: Array<RuntimeInvariantCheckResult> = []
|
|
301
|
+
const violations: Array<RuntimeInvariantViolation<M>> = []
|
|
302
|
+
|
|
303
|
+
for (const invariant of invariants) {
|
|
304
|
+
const scope = scopeOf(invariant)
|
|
305
|
+
const result = invariant._tag === "RuntimeSnapshotInvariant"
|
|
306
|
+
? evaluateInvariant(invariant, scope, snapshotObservations(machine, transcript, invariant))
|
|
307
|
+
: invariant._tag === "RuntimeCommandInvariant"
|
|
308
|
+
? evaluateInvariant(invariant, scope, commandObservations(machine, transcript))
|
|
309
|
+
: evaluateInvariant(invariant, scope, transcriptObservations(machine, transcript))
|
|
310
|
+
checks.push(result.check)
|
|
311
|
+
violations.push(...result.violations)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const report: RuntimeInvariantReport = { checks }
|
|
315
|
+
return violations.length === 0
|
|
316
|
+
? Effect.succeed(report)
|
|
317
|
+
: Effect.fail(new RuntimeInvariantError({ transcript, violations, report }))
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
export const assertRuntimeInvariants = <M extends AnyMachine, Error, Output>(
|
|
321
|
+
machine: M,
|
|
322
|
+
transcript: CausalRuntimeEvidence<M, Error, Output>,
|
|
323
|
+
invariants: ReadonlyArray<RuntimeInvariant<M>>
|
|
324
|
+
): Effect.Effect<void, RuntimeInvariantError<M>> =>
|
|
325
|
+
checkRuntimeInvariants(machine, transcript, invariants).pipe(Effect.asVoid)
|
|
326
|
+
|
|
327
|
+
export class PlannerRuntimeAgreementError<M extends AnyMachine = AnyMachine> extends Data.TaggedError(
|
|
328
|
+
"MachineTestPlannerRuntimeAgreementError"
|
|
329
|
+
)<{
|
|
330
|
+
readonly evidence: CausalRuntimeEvidence<M, unknown, unknown>
|
|
331
|
+
readonly violations: ReadonlyArray<PlannerRuntimeAgreementViolation<M>>
|
|
332
|
+
}> {}
|
|
333
|
+
|
|
334
|
+
const fingerprint = (value: unknown): string => {
|
|
335
|
+
const active = new WeakSet<object>()
|
|
336
|
+
const visit = (current: unknown): unknown => {
|
|
337
|
+
if (current === undefined) return ["undefined"]
|
|
338
|
+
if (current === null || typeof current === "boolean" || typeof current === "string") return current
|
|
339
|
+
if (typeof current === "number") {
|
|
340
|
+
if (Number.isNaN(current)) return ["number", "NaN"]
|
|
341
|
+
if (Object.is(current, -0)) return ["number", "-0"]
|
|
342
|
+
return current
|
|
343
|
+
}
|
|
344
|
+
if (typeof current === "bigint") return ["bigint", String(current)]
|
|
345
|
+
if (typeof current === "symbol") return ["symbol", Symbol.keyFor(current) ?? String(current)]
|
|
346
|
+
if (typeof current === "function") return ["function", current.name]
|
|
347
|
+
if (active.has(current)) return ["circular"]
|
|
348
|
+
active.add(current)
|
|
349
|
+
let result: unknown
|
|
350
|
+
if (current instanceof Date) {
|
|
351
|
+
result = ["date", Number.isNaN(current.getTime()) ? "Invalid Date" : current.toISOString()]
|
|
352
|
+
} else if (current instanceof Error) {
|
|
353
|
+
result = [
|
|
354
|
+
"error",
|
|
355
|
+
current.name,
|
|
356
|
+
current.message,
|
|
357
|
+
Object.keys(current).sort().map((key) => [key, visit((current as unknown as Record<string, unknown>)[key])])
|
|
358
|
+
]
|
|
359
|
+
} else if (ArrayBuffer.isView(current)) {
|
|
360
|
+
result = [current.constructor.name, Array.from(current as unknown as ArrayLike<number>)]
|
|
361
|
+
} else if (Array.isArray(current)) {
|
|
362
|
+
result = current.map(visit)
|
|
363
|
+
} else if (current instanceof Map) {
|
|
364
|
+
result = [
|
|
365
|
+
"map",
|
|
366
|
+
Array.from(current, ([key, item]) => [visit(key), visit(item)]).sort((left, right) =>
|
|
367
|
+
JSON.stringify(left).localeCompare(JSON.stringify(right))
|
|
368
|
+
)
|
|
369
|
+
]
|
|
370
|
+
} else if (current instanceof Set) {
|
|
371
|
+
result = [
|
|
372
|
+
"set",
|
|
373
|
+
Array.from(current, visit).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))
|
|
374
|
+
]
|
|
375
|
+
} else {
|
|
376
|
+
result = Object.keys(current).sort().map((key) => [key, visit((current as Record<string, unknown>)[key])])
|
|
377
|
+
}
|
|
378
|
+
active.delete(current)
|
|
379
|
+
return result
|
|
380
|
+
}
|
|
381
|
+
return JSON.stringify(visit(value))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const projection = (plan: {
|
|
385
|
+
readonly next: unknown
|
|
386
|
+
readonly commands: ReadonlyArray<unknown>
|
|
387
|
+
readonly emittedEvents: ReadonlyArray<unknown>
|
|
388
|
+
readonly microsteps: ReadonlyArray<{
|
|
389
|
+
readonly next: unknown
|
|
390
|
+
readonly event: unknown
|
|
391
|
+
readonly commands: ReadonlyArray<unknown>
|
|
392
|
+
readonly raisedEvents: ReadonlyArray<unknown>
|
|
393
|
+
readonly emittedEvents: ReadonlyArray<unknown>
|
|
394
|
+
readonly exitPaths: ReadonlyArray<string>
|
|
395
|
+
readonly entryPaths: ReadonlyArray<string>
|
|
396
|
+
readonly changed: boolean
|
|
397
|
+
}>
|
|
398
|
+
readonly done: boolean
|
|
399
|
+
readonly output: unknown
|
|
400
|
+
}) => ({
|
|
401
|
+
after: plan.next,
|
|
402
|
+
completion: { done: plan.done, output: plan.output },
|
|
403
|
+
commands: plan.commands.length,
|
|
404
|
+
emittedEvents: plan.emittedEvents,
|
|
405
|
+
microsteps: plan.microsteps.map((microstep) => ({
|
|
406
|
+
next: microstep.next,
|
|
407
|
+
event: microstep.event,
|
|
408
|
+
commands: microstep.commands.length,
|
|
409
|
+
raisedEvents: microstep.raisedEvents,
|
|
410
|
+
emittedEvents: microstep.emittedEvents,
|
|
411
|
+
exitPaths: microstep.exitPaths,
|
|
412
|
+
entryPaths: microstep.entryPaths,
|
|
413
|
+
changed: microstep.changed
|
|
414
|
+
}))
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
export const assertPlannerRuntimeAgreement = <M extends AnyMachine, Error, Output>(
|
|
418
|
+
machine: M,
|
|
419
|
+
evidence: CausalRuntimeEvidence<M, Error, Output>
|
|
420
|
+
): Effect.Effect<void, PlannerRuntimeAgreementError<M>> =>
|
|
421
|
+
Effect.gen(function*() {
|
|
422
|
+
const violations: Array<PlannerRuntimeAgreementViolation<M>> = []
|
|
423
|
+
const add = (
|
|
424
|
+
record: CausalRuntimeEvidence<M, Error, Output>["records"][number],
|
|
425
|
+
field: PlannerRuntimeAgreementViolation<M>["field"],
|
|
426
|
+
message: string
|
|
427
|
+
): void => {
|
|
428
|
+
violations.push({ commandIndex: record.index, command: record.command, field, message })
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const record of evidence.records) {
|
|
432
|
+
if (record.command._tag !== "Send" || record.actual.result._tag !== "SendProcessed") continue
|
|
433
|
+
const step = record.actual.result.step
|
|
434
|
+
const planned = yield* Effect.result(
|
|
435
|
+
Machine.plan(machine as any, step.before as any, record.command.event as any) as Effect.Effect<any, unknown>
|
|
436
|
+
)
|
|
437
|
+
if (Result.isFailure(planned)) {
|
|
438
|
+
add(record, "planning", `Fresh planning failed: ${String(planned.failure)}`)
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const expected = projection(planned.success)
|
|
443
|
+
const actual = projection(step.plan)
|
|
444
|
+
const compare = (
|
|
445
|
+
field: "planNext" | "after" | "completion" | "commands" | "emittedEvents" | "microsteps",
|
|
446
|
+
left: unknown,
|
|
447
|
+
right: unknown
|
|
448
|
+
): void => {
|
|
449
|
+
const actualFingerprint = fingerprint(left)
|
|
450
|
+
const expectedFingerprint = fingerprint(right)
|
|
451
|
+
if (actualFingerprint !== expectedFingerprint) {
|
|
452
|
+
add(
|
|
453
|
+
record,
|
|
454
|
+
field,
|
|
455
|
+
`Runtime ${field} disagreed with fresh planning: expected ${expectedFingerprint}, actual ${actualFingerprint}`
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
compare("planNext", actual.after, expected.after)
|
|
460
|
+
compare("after", step.after, expected.after)
|
|
461
|
+
compare("completion", actual.completion, expected.completion)
|
|
462
|
+
compare("commands", actual.commands, expected.commands)
|
|
463
|
+
compare("emittedEvents", actual.emittedEvents, expected.emittedEvents)
|
|
464
|
+
compare("microsteps", actual.microsteps, expected.microsteps)
|
|
465
|
+
|
|
466
|
+
const handled = planned.success.microsteps.length > 0
|
|
467
|
+
if (step.handled !== handled) add(record, "handled", `Expected handled=${handled} but observed ${step.handled}`)
|
|
468
|
+
const configurationChanged = planned.success.microsteps.some(({ changed }: { readonly changed: boolean }) =>
|
|
469
|
+
changed
|
|
470
|
+
)
|
|
471
|
+
if (step.configurationChanged !== configurationChanged) {
|
|
472
|
+
add(
|
|
473
|
+
record,
|
|
474
|
+
"configurationChanged",
|
|
475
|
+
`Expected configurationChanged=${configurationChanged} but observed ${step.configurationChanged}`
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (violations.length > 0) {
|
|
481
|
+
return yield* new PlannerRuntimeAgreementError({
|
|
482
|
+
evidence: evidence as CausalRuntimeEvidence<M, unknown, unknown>,
|
|
483
|
+
violations
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planner trace construction shared by scenario runs and graph exploration.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.4.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import * as Machine from "../../../Machine.js"
|
|
10
|
+
import type {
|
|
11
|
+
EventPlan,
|
|
12
|
+
InitialPlan,
|
|
13
|
+
InitialTrace,
|
|
14
|
+
RunError,
|
|
15
|
+
RunFailure,
|
|
16
|
+
RunServices,
|
|
17
|
+
Scenario,
|
|
18
|
+
Trace,
|
|
19
|
+
TraceStep
|
|
20
|
+
} from "../../../testing/MachineTest.js"
|
|
21
|
+
import type { EnsureExecutable } from "../../machine/readiness.js"
|
|
22
|
+
|
|
23
|
+
type AnyMachine = Machine.Machine.Any
|
|
24
|
+
|
|
25
|
+
type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
|
|
26
|
+
|
|
27
|
+
type StatePath<M extends AnyMachine> = Machine.Machine.StateIdentifier<Machine.Machine.States<M>>
|
|
28
|
+
|
|
29
|
+
type ReadyMachine<M extends AnyMachine> =
|
|
30
|
+
& M
|
|
31
|
+
& EnsureExecutable<
|
|
32
|
+
Machine.Machine.States<M>,
|
|
33
|
+
Machine.Machine.UnhandledStates<M>,
|
|
34
|
+
Machine.Machine.OutputStates<M>
|
|
35
|
+
>
|
|
36
|
+
|
|
37
|
+
export const rawConfigurationPaths = <M extends AnyMachine>(
|
|
38
|
+
machine: M,
|
|
39
|
+
snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
|
|
40
|
+
): ReadonlyArray<StatePath<M>> => {
|
|
41
|
+
const active = new Set<string>()
|
|
42
|
+
const visit = (current: unknown): void => {
|
|
43
|
+
if (typeof current !== "object" || current === null) return
|
|
44
|
+
const value = current as Record<string, unknown>
|
|
45
|
+
if (typeof value.path === "string") active.add(value.path)
|
|
46
|
+
if (value.state !== undefined) visit(value.state)
|
|
47
|
+
if (typeof value.states === "object" && value.states !== null) {
|
|
48
|
+
for (const child of Object.values(value.states)) visit(child)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
visit(snapshot)
|
|
52
|
+
return Machine.stateNodes(machine)
|
|
53
|
+
.filter((node) => node.type !== "history" && node.type !== "choice" && active.has(node.path))
|
|
54
|
+
.map((node) => node.path) as ReadonlyArray<StatePath<M>>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const appendTrace = <M extends AnyMachine>(
|
|
58
|
+
machine: ReadyMachine<M>,
|
|
59
|
+
trace: Trace<M>,
|
|
60
|
+
event: Machine.Machine.InputEvent<M>,
|
|
61
|
+
scenario: Scenario<M>
|
|
62
|
+
): Effect.Effect<Trace<M>, RunFailure<RunError<M>, M>, RunServices<M>> => {
|
|
63
|
+
const index = trace.steps.length
|
|
64
|
+
const before = trace.final
|
|
65
|
+
const beforeConfiguration = rawConfigurationPaths(machine, before)
|
|
66
|
+
return ((Machine.plan as any)(machine, before, event) as Effect.Effect<
|
|
67
|
+
EventPlan<M>,
|
|
68
|
+
RunError<M>,
|
|
69
|
+
RunServices<M>
|
|
70
|
+
>).pipe(
|
|
71
|
+
Effect.mapError((cause): RunFailure<RunError<M>, M> => ({
|
|
72
|
+
_tag: "MachineTestRunFailure",
|
|
73
|
+
scenario,
|
|
74
|
+
phase: "event",
|
|
75
|
+
eventIndex: index,
|
|
76
|
+
event,
|
|
77
|
+
initial: trace.initial,
|
|
78
|
+
steps: trace.steps.slice(),
|
|
79
|
+
cause
|
|
80
|
+
})),
|
|
81
|
+
Effect.map((plan) => {
|
|
82
|
+
const step: TraceStep<M> = {
|
|
83
|
+
index,
|
|
84
|
+
before,
|
|
85
|
+
beforeConfiguration,
|
|
86
|
+
event,
|
|
87
|
+
plan,
|
|
88
|
+
after: plan.next,
|
|
89
|
+
afterConfiguration: rawConfigurationPaths(machine, plan.next)
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
scenario,
|
|
93
|
+
initial: trace.initial,
|
|
94
|
+
steps: [...trace.steps, step],
|
|
95
|
+
final: plan.next,
|
|
96
|
+
finalConfiguration: step.afterConfiguration
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const run: <M extends AnyMachine>(
|
|
103
|
+
machine: ReadyMachine<M>,
|
|
104
|
+
scenario: Scenario<M>
|
|
105
|
+
) => Effect.Effect<Trace<M>, RunFailure<RunError<M>, M>, RunServices<M>> = Effect.fnUntraced(function*<
|
|
106
|
+
M extends AnyMachine
|
|
107
|
+
>(
|
|
108
|
+
machine: ReadyMachine<M>,
|
|
109
|
+
scenario: Scenario<M>
|
|
110
|
+
) {
|
|
111
|
+
const initialEffect = (machine.input === undefined || machine.input === Schema.Void
|
|
112
|
+
? (Machine.planInitial as any)(machine)
|
|
113
|
+
: (Machine.planInitial as any)(machine, (scenario as { readonly input: InputValue<M> }).input)) as Effect.Effect<
|
|
114
|
+
InitialPlan<M>,
|
|
115
|
+
RunError<M>,
|
|
116
|
+
RunServices<M>
|
|
117
|
+
>
|
|
118
|
+
const initial = yield* initialEffect.pipe(
|
|
119
|
+
Effect.mapError((cause): RunFailure<RunError<M>, M> => ({
|
|
120
|
+
_tag: "MachineTestRunFailure",
|
|
121
|
+
scenario,
|
|
122
|
+
phase: "initial",
|
|
123
|
+
eventIndex: undefined,
|
|
124
|
+
event: undefined,
|
|
125
|
+
initial: undefined,
|
|
126
|
+
steps: [],
|
|
127
|
+
cause
|
|
128
|
+
}))
|
|
129
|
+
)
|
|
130
|
+
const initialTrace: InitialTrace<M> = {
|
|
131
|
+
plan: initial,
|
|
132
|
+
startingState: initial.startingState,
|
|
133
|
+
startingConfiguration: rawConfigurationPaths(machine, initial.startingState),
|
|
134
|
+
initialEntryPaths: initial.initialEntryPaths,
|
|
135
|
+
configuration: rawConfigurationPaths(machine, initial.state)
|
|
136
|
+
}
|
|
137
|
+
let trace: Trace<M> = {
|
|
138
|
+
scenario,
|
|
139
|
+
initial: initialTrace,
|
|
140
|
+
steps: [],
|
|
141
|
+
final: initial.state,
|
|
142
|
+
finalConfiguration: initialTrace.configuration
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const event of scenario.events) {
|
|
146
|
+
trace = yield* appendTrace(machine, trace, event, scenario)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return trace
|
|
150
|
+
})
|