@proophboard/exploration-runtime 0.0.0-stage → 0.1.0
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/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/connectionRules.d.ts +39 -0
- package/dist/lib/connectionRules.d.ts.map +1 -0
- package/dist/lib/connectionRules.js +475 -0
- package/dist/lib/connectionRules.js.map +1 -0
- package/dist/lib/toClassName.d.ts +42 -0
- package/dist/lib/toClassName.d.ts.map +1 -0
- package/dist/lib/toClassName.js +81 -0
- package/dist/lib/toClassName.js.map +1 -0
- package/dist/playFunction/ambientTypes.d.ts +30 -0
- package/dist/playFunction/ambientTypes.d.ts.map +1 -0
- package/dist/playFunction/ambientTypes.js +174 -0
- package/dist/playFunction/ambientTypes.js.map +1 -0
- package/dist/playFunction/cascadeBudget.d.ts +53 -0
- package/dist/playFunction/cascadeBudget.d.ts.map +1 -0
- package/dist/playFunction/cascadeBudget.js +74 -0
- package/dist/playFunction/cascadeBudget.js.map +1 -0
- package/dist/playFunction/clock.d.ts +58 -0
- package/dist/playFunction/clock.d.ts.map +1 -0
- package/dist/playFunction/clock.js +104 -0
- package/dist/playFunction/clock.js.map +1 -0
- package/dist/playFunction/modelTypes.d.ts +74 -0
- package/dist/playFunction/modelTypes.d.ts.map +1 -0
- package/dist/playFunction/modelTypes.js +345 -0
- package/dist/playFunction/modelTypes.js.map +1 -0
- package/dist/playFunction/playLog.d.ts +29 -0
- package/dist/playFunction/playLog.d.ts.map +1 -0
- package/dist/playFunction/playLog.js +64 -0
- package/dist/playFunction/playLog.js.map +1 -0
- package/dist/playFunction/playType.d.ts +52 -0
- package/dist/playFunction/playType.d.ts.map +1 -0
- package/dist/playFunction/playType.js +94 -0
- package/dist/playFunction/playType.js.map +1 -0
- package/dist/playFunction/runtimeFold.d.ts +51 -0
- package/dist/playFunction/runtimeFold.d.ts.map +1 -0
- package/dist/playFunction/runtimeFold.js +620 -0
- package/dist/playFunction/runtimeFold.js.map +1 -0
- package/dist/playFunction/runtimeSteps.d.ts +45 -0
- package/dist/playFunction/runtimeSteps.d.ts.map +1 -0
- package/dist/playFunction/runtimeSteps.js +112 -0
- package/dist/playFunction/runtimeSteps.js.map +1 -0
- package/dist/playFunction/sandbox.d.ts +52 -0
- package/dist/playFunction/sandbox.d.ts.map +1 -0
- package/dist/playFunction/sandbox.js +91 -0
- package/dist/playFunction/sandbox.js.map +1 -0
- package/dist/playFunction/scenarioRunner.d.ts +49 -0
- package/dist/playFunction/scenarioRunner.d.ts.map +1 -0
- package/dist/playFunction/scenarioRunner.js +331 -0
- package/dist/playFunction/scenarioRunner.js.map +1 -0
- package/dist/playFunction/transpile.d.ts +33 -0
- package/dist/playFunction/transpile.d.ts.map +1 -0
- package/dist/playFunction/transpile.js +71 -0
- package/dist/playFunction/transpile.js.map +1 -0
- package/dist/types/eventModel.d.ts +79 -0
- package/dist/types/eventModel.d.ts.map +1 -0
- package/dist/types/eventModel.js +12 -0
- package/dist/types/eventModel.js.map +1 -0
- package/dist/types/exploration.d.ts +143 -0
- package/dist/types/exploration.d.ts.map +1 -0
- package/dist/types/exploration.js +28 -0
- package/dist/types/exploration.js.map +1 -0
- package/package.json +48 -4
- package/README.md +0 -3
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtimeFold — the pure, environment-independent Exploration fold that executes
|
|
3
|
+
* AUTHORED play functions (4b), falling back to the built-in defaults (R11) when
|
|
4
|
+
* an element has no `playFunction`.
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately NOT a hook and touches no DOM/worker API, so it is:
|
|
7
|
+
* - unit-testable directly (the Web Worker can't be exercised under jsdom), and
|
|
8
|
+
* - usable both inside the Worker (handlerWorker.ts) and on the main-thread
|
|
9
|
+
* synchronous fast-path (when no element has a playFunction).
|
|
10
|
+
*
|
|
11
|
+
* Contract parity: with no authored handlers this reproduces `runReplay`'s exact
|
|
12
|
+
* default behaviour (so the M1–M3 runtime contract/tests still hold). Authored
|
|
13
|
+
* handlers extend it with: six handler kinds (interact/decide/apply/read/react/
|
|
14
|
+
* process), ordered multi-event commands, sequential apply, two-level leaf-replace
|
|
15
|
+
* merge, non-object Information shapes (via apply), deterministic clock + uuid(),
|
|
16
|
+
* the per-step cascade budget, and reject().
|
|
17
|
+
*
|
|
18
|
+
* Handler sources arrive PRE-TRANSPILED (TS→JS done by the caller/worker), so the
|
|
19
|
+
* fold stays synchronous. Compilation (`new Function`) is cached per source.
|
|
20
|
+
*/
|
|
21
|
+
import { toClassName } from '../lib/toClassName.js';
|
|
22
|
+
import { getCommandsDrivenByUi, getConnections } from '../lib/connectionRules.js';
|
|
23
|
+
import { deriveRuntimeSteps, mergeProjection, seedState, defaultDecide, } from './runtimeSteps.js';
|
|
24
|
+
import { DeterministicClock } from './clock.js';
|
|
25
|
+
import { CascadeBudget, isCascadeBudgetExceeded, DEFAULT_CASCADE_LIMITS } from './cascadeBudget.js';
|
|
26
|
+
import { compileHandler, runHandler, deepFreeze, isRejectSignal, } from './sandbox.js';
|
|
27
|
+
import { makePlayLogger } from './playLog.js';
|
|
28
|
+
function clone(value) {
|
|
29
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
30
|
+
}
|
|
31
|
+
function isPlainObject(v) {
|
|
32
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
33
|
+
}
|
|
34
|
+
/** Compiled-handler cache keyed by source (compilation is pure). */
|
|
35
|
+
const compiledCache = new Map();
|
|
36
|
+
function getCompiled(source, kind) {
|
|
37
|
+
const cacheKey = `${kind}::${source}`;
|
|
38
|
+
let fn = compiledCache.get(cacheKey);
|
|
39
|
+
if (!fn) {
|
|
40
|
+
fn = compileHandler(source, kind);
|
|
41
|
+
compiledCache.set(cacheKey, fn);
|
|
42
|
+
}
|
|
43
|
+
return fn;
|
|
44
|
+
}
|
|
45
|
+
/** Test seam. */
|
|
46
|
+
export function __clearCompiledCache() {
|
|
47
|
+
compiledCache.clear();
|
|
48
|
+
}
|
|
49
|
+
/** Does any element in the chapter carry an authored play function? */
|
|
50
|
+
export function hasAnyPlayFunction(chapter) {
|
|
51
|
+
if (!chapter)
|
|
52
|
+
return false;
|
|
53
|
+
return chapter.elements.some((e) => typeof e.playFunction === 'string' && e.playFunction.trim().length > 0);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Run the authored-handler fold. Pure: `(input) → RuntimeResultV2`.
|
|
57
|
+
*/
|
|
58
|
+
export function runFold(input) {
|
|
59
|
+
const { chapter, steps, scenario, playheadIndex, liveStorage, handlers } = input;
|
|
60
|
+
const log = makePlayLogger(input.logSource ?? 'PlayWorker');
|
|
61
|
+
const authoredCount = Object.keys(handlers).length;
|
|
62
|
+
log('flow', `fold start — playhead=${playheadIndex}, steps=${steps.length}, authoredHandlers=${authoredCount}`);
|
|
63
|
+
const clock = new DeterministicClock(scenario.clock);
|
|
64
|
+
const budget = new CascadeBudget(input.cascadeLimits ?? DEFAULT_CASCADE_LIMITS);
|
|
65
|
+
const errors = [];
|
|
66
|
+
// Automation-dispatched commands awaiting their slice (deferred execution).
|
|
67
|
+
const pendingCommands = [];
|
|
68
|
+
// Pending commands already fired this replay (so we don't fire twice).
|
|
69
|
+
const firedPending = new Set();
|
|
70
|
+
// Seed state (reuses the M1 seeder; keys are className-form).
|
|
71
|
+
const state = seedState(scenario, clock.now());
|
|
72
|
+
// Captured-input seam (navigation without a command): `state.$input` exposes
|
|
73
|
+
// the UI storage collected via `storage.save()` / manual capture to all
|
|
74
|
+
// handlers — notably Information `read(state)`, which can "query" a projection
|
|
75
|
+
// using a captured value (e.g. find the picked todo by `state.$input.todoId`)
|
|
76
|
+
// WITHOUT hijacking a command→event flow for pure navigation.
|
|
77
|
+
//
|
|
78
|
+
// Shape (Option A — flat, `$`-reserved): the top level mirrors the LAST visible
|
|
79
|
+
// step's storage (most recent step ≤ playhead that captured input), so
|
|
80
|
+
// `state.$input.todoId` reads the latest value directly; `$byStep` is a reserved
|
|
81
|
+
// sibling holding every visible step's storage keyed by step index. It is kept
|
|
82
|
+
// current as the fold advances, so `read` (computed at the playhead) sees input
|
|
83
|
+
// from this and all earlier visible steps — never from the future.
|
|
84
|
+
const inputByStep = {};
|
|
85
|
+
const refreshInput = () => {
|
|
86
|
+
// Flatten: last-captured wins at the top level; $byStep preserves history.
|
|
87
|
+
const stepIdx = Object.keys(inputByStep)
|
|
88
|
+
.map(Number)
|
|
89
|
+
.sort((a, b) => a - b);
|
|
90
|
+
const last = stepIdx.length ? inputByStep[stepIdx[stepIdx.length - 1]] : {};
|
|
91
|
+
state.$input = { ...last, $byStep: { ...inputByStep } };
|
|
92
|
+
};
|
|
93
|
+
refreshInput(); // seed with {} / empty $byStep
|
|
94
|
+
const handlerFor = (el) => {
|
|
95
|
+
if (!el)
|
|
96
|
+
return undefined;
|
|
97
|
+
const src = handlers[el.id];
|
|
98
|
+
return src && src.trim().length > 0 ? src : undefined;
|
|
99
|
+
};
|
|
100
|
+
// Frozen snapshot of state for handler input (handlers must not mutate it).
|
|
101
|
+
const frozenState = () => deepFreeze(clone(state));
|
|
102
|
+
// Event → reacting automation(s), resolved via connection rules (NOT slice
|
|
103
|
+
// co-membership): the triggering event may sit in the write slice BEFORE the
|
|
104
|
+
// automation. We index reacting automations by the event's NAME, since the
|
|
105
|
+
// runtime matches produced events to event elements by name.
|
|
106
|
+
const reactionsByEventName = new Map();
|
|
107
|
+
if (chapter) {
|
|
108
|
+
const connections = getConnections(chapter);
|
|
109
|
+
for (const conn of connections) {
|
|
110
|
+
if (conn.type !== 'event')
|
|
111
|
+
continue;
|
|
112
|
+
if (conn.fromElement.type !== 'event' || conn.toElement.type !== 'automation')
|
|
113
|
+
continue;
|
|
114
|
+
const name = conn.fromElement.name;
|
|
115
|
+
const list = reactionsByEventName.get(name) ?? [];
|
|
116
|
+
// Only reaction automations that actually author a `react` participate;
|
|
117
|
+
// a default (no playFunction) automation reaction is a no-op (R11).
|
|
118
|
+
list.push(conn.toElement);
|
|
119
|
+
reactionsByEventName.set(name, list);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Ids of automations that are event-reaction targets (so NOT jobs). */
|
|
123
|
+
const reactionAutomationIds = new Set();
|
|
124
|
+
for (const list of reactionsByEventName.values()) {
|
|
125
|
+
for (const a of list)
|
|
126
|
+
reactionAutomationIds.add(a.id);
|
|
127
|
+
}
|
|
128
|
+
// Command → event element(s) it produces, resolved via connection rules (the
|
|
129
|
+
// event may be in the command's slice OR a following event-only slice). Used by
|
|
130
|
+
// the DEFAULT decide so a command without an authored handler emits the right
|
|
131
|
+
// event(s) even across slices.
|
|
132
|
+
const eventsByCommandId = new Map();
|
|
133
|
+
if (chapter) {
|
|
134
|
+
const connections = getConnections(chapter);
|
|
135
|
+
for (const conn of connections) {
|
|
136
|
+
if (conn.type !== 'command')
|
|
137
|
+
continue;
|
|
138
|
+
if (conn.fromElement.type !== 'command' || conn.toElement.type !== 'event')
|
|
139
|
+
continue;
|
|
140
|
+
const list = eventsByCommandId.get(conn.fromElement.id) ?? [];
|
|
141
|
+
list.push(conn.toElement);
|
|
142
|
+
eventsByCommandId.set(conn.fromElement.id, list);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Event → Information element(s) it projects into, resolved via connection rules.
|
|
146
|
+
// In Event Modeling the Information an event writes is frequently drawn in a
|
|
147
|
+
// FOLLOWING read slice (connected by an event→information edge), NOT co-located
|
|
148
|
+
// in the event's write slice. Keyed by the event element's NAME (produced events
|
|
149
|
+
// match event elements by name; the same name may recur across slices). Used by
|
|
150
|
+
// the DEFAULT apply so a plain event still fills its connected Information.
|
|
151
|
+
const informationByEventName = new Map();
|
|
152
|
+
if (chapter) {
|
|
153
|
+
const connections = getConnections(chapter);
|
|
154
|
+
for (const conn of connections) {
|
|
155
|
+
if (conn.type !== 'event')
|
|
156
|
+
continue;
|
|
157
|
+
if (conn.fromElement.type !== 'event' || conn.toElement.type !== 'information')
|
|
158
|
+
continue;
|
|
159
|
+
const name = conn.fromElement.name;
|
|
160
|
+
const list = informationByEventName.get(name) ?? [];
|
|
161
|
+
list.push(conn.toElement);
|
|
162
|
+
informationByEventName.set(name, list);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Compute Information read() views at the current state (R11a, R21a). */
|
|
166
|
+
const computeReadViews = (atStep) => {
|
|
167
|
+
const views = [];
|
|
168
|
+
if (!chapter)
|
|
169
|
+
return views;
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
for (const el of chapter.elements) {
|
|
172
|
+
if (el.type !== 'information')
|
|
173
|
+
continue;
|
|
174
|
+
const authored = handlerFor(el);
|
|
175
|
+
if (!authored || seen.has(el.id))
|
|
176
|
+
continue;
|
|
177
|
+
seen.add(el.id);
|
|
178
|
+
const ctxKey = toClassName(el.context || 'App');
|
|
179
|
+
const elKey = toClassName(el.name);
|
|
180
|
+
try {
|
|
181
|
+
const view = runHandler(getCompiled(authored, 'read'), clock.surface(), [frozenState()]);
|
|
182
|
+
log('read', `read(state) → view for ${el.context || 'App'}.${el.name}`, view);
|
|
183
|
+
views.push({ elementId: el.id, key: `${ctxKey}.${elKey}`, view });
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
errors.push({
|
|
187
|
+
kind: 'threw',
|
|
188
|
+
message: err instanceof Error ? err.message : String(err),
|
|
189
|
+
stepIndex: atStep,
|
|
190
|
+
elementId: el.id,
|
|
191
|
+
elementName: el.name,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return views;
|
|
196
|
+
};
|
|
197
|
+
if (!chapter || steps.length === 0) {
|
|
198
|
+
return { events: state.$events, state, errors, readViews: computeReadViews(0), clockISO: clock.now() };
|
|
199
|
+
}
|
|
200
|
+
const upto = Math.max(0, Math.min(playheadIndex, steps.length - 1));
|
|
201
|
+
// Storage per step (live overrides recorded) — same routing as runReplay.
|
|
202
|
+
const recorded = new Map();
|
|
203
|
+
for (const entry of scenario.interactions ?? []) {
|
|
204
|
+
if (entry && typeof entry.stepIndex === 'number')
|
|
205
|
+
recorded.set(entry.stepIndex, entry.storage ?? {});
|
|
206
|
+
}
|
|
207
|
+
const storageForStep = (s) => {
|
|
208
|
+
const live = liveStorage?.[s];
|
|
209
|
+
return live !== undefined ? live : recorded.get(s);
|
|
210
|
+
};
|
|
211
|
+
/** Emit one runtime event: assign index/timestamp, tick clock, record, apply. */
|
|
212
|
+
const emitEvent = (step, partial) => {
|
|
213
|
+
budget.countEvent();
|
|
214
|
+
const runtimeEvent = {
|
|
215
|
+
...partial,
|
|
216
|
+
payload: partial.payload ?? {},
|
|
217
|
+
index: state.$events.length,
|
|
218
|
+
timestamp: clock.now(),
|
|
219
|
+
sliceId: step.slice.id, // M5 attribution: which slice emitted this event
|
|
220
|
+
};
|
|
221
|
+
state.$events.push(runtimeEvent);
|
|
222
|
+
log('event', `emit #${runtimeEvent.index} ${runtimeEvent.context}.${runtimeEvent.name} @${runtimeEvent.timestamp}`, runtimeEvent.payload);
|
|
223
|
+
clock.tick(); // auto-tick per produced event (R32-A)
|
|
224
|
+
// event.apply — authored or default.
|
|
225
|
+
applyEvent(step, runtimeEvent);
|
|
226
|
+
// Automation event-reactions (R10 react): fire reacting automations for this
|
|
227
|
+
// event, bounded by cascade DEPTH. Reactions produce commands that re-enter
|
|
228
|
+
// decide → events → (possibly) more reactions.
|
|
229
|
+
fireReactions(step, runtimeEvent);
|
|
230
|
+
return runtimeEvent;
|
|
231
|
+
};
|
|
232
|
+
/** Apply an event into projections: authored apply() or the default merge. */
|
|
233
|
+
const applyEvent = (step, event) => {
|
|
234
|
+
// Resolve the event element (for an authored apply) and the target Information.
|
|
235
|
+
const eventEl = step.events.find((e) => e.name === event.name) ?? step.events[0];
|
|
236
|
+
const authored = handlerFor(eventEl);
|
|
237
|
+
if (authored) {
|
|
238
|
+
try {
|
|
239
|
+
const patch = runHandler(getCompiled(authored, 'apply'), clock.surface(), [clone(event), frozenState()]);
|
|
240
|
+
log('apply', `apply(${event.name}, state) → projection patch`, patch);
|
|
241
|
+
if (isPlainObject(patch)) {
|
|
242
|
+
// Two-level leaf-replace merge of { Ctx: { El: {...} } }.
|
|
243
|
+
for (const [ctx, elements] of Object.entries(patch)) {
|
|
244
|
+
if (!isPlainObject(elements))
|
|
245
|
+
continue;
|
|
246
|
+
for (const [el, val] of Object.entries(elements)) {
|
|
247
|
+
applyLeaf(ctx, el, val);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
recordError(err, step, eventEl);
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// DEFAULT: merge the event payload into the target Information. The target is
|
|
258
|
+
// the single-object Information in the event's OWN slice if present, else the
|
|
259
|
+
// Information connected to this event via the Event Modeling edges (typically
|
|
260
|
+
// a following read slice). Merges into every connected Information (R11, R13).
|
|
261
|
+
const sameSliceInfo = step.information[0];
|
|
262
|
+
const targets = sameSliceInfo
|
|
263
|
+
? [sameSliceInfo]
|
|
264
|
+
: (informationByEventName.get(event.name) ?? []);
|
|
265
|
+
if (targets.length === 0) {
|
|
266
|
+
log('apply', `default apply(${event.name}) → NO target Information (same-slice none, no event→information connection)`);
|
|
267
|
+
}
|
|
268
|
+
for (const targetInfo of targets) {
|
|
269
|
+
log('apply', `default apply(${event.name}) → merge into ${targetInfo.context || 'App'}.${targetInfo.name}`, event.payload);
|
|
270
|
+
mergeProjection(state, targetInfo.context || 'App', targetInfo.name, event.payload);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
/** Leaf-replace write honouring the className keying + non-object shapes (R13). */
|
|
274
|
+
const applyLeaf = (ctxDisplay, elDisplay, value) => {
|
|
275
|
+
const ctxKey = toClassName(ctxDisplay);
|
|
276
|
+
const elKey = toClassName(elDisplay);
|
|
277
|
+
const ctxBucket = isPlainObject(state[ctxKey]) ? state[ctxKey] : {};
|
|
278
|
+
if (isPlainObject(value)) {
|
|
279
|
+
const existing = isPlainObject(ctxBucket[elKey]) ? ctxBucket[elKey] : {};
|
|
280
|
+
ctxBucket[elKey] = { ...existing, ...value }; // leaf-merge for objects
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
ctxBucket[elKey] = clone(value); // lists/scalars: replace wholesale
|
|
284
|
+
}
|
|
285
|
+
state[ctxKey] = ctxBucket;
|
|
286
|
+
};
|
|
287
|
+
const recordError = (err, step, el) => {
|
|
288
|
+
const where = el ? `${el.type} ${el.name}` : `step ${step.stepIndex}`;
|
|
289
|
+
if (isCascadeBudgetExceeded(err)) {
|
|
290
|
+
log('error', `cascade-exceeded (${err.kind}) at ${where}: ${err.message}`);
|
|
291
|
+
errors.push({
|
|
292
|
+
kind: 'cascade-exceeded',
|
|
293
|
+
message: err.message,
|
|
294
|
+
stepIndex: step.stepIndex,
|
|
295
|
+
sliceId: step.slice.id,
|
|
296
|
+
elementId: el?.id,
|
|
297
|
+
elementName: el?.name,
|
|
298
|
+
cascadeKind: err.kind,
|
|
299
|
+
});
|
|
300
|
+
throw err; // cascade stops the whole step — rethrow to unwind the fold step
|
|
301
|
+
}
|
|
302
|
+
if (isRejectSignal(err)) {
|
|
303
|
+
log('error', `reject() at ${where}: ${err.reason || 'Command rejected.'}`);
|
|
304
|
+
errors.push({
|
|
305
|
+
kind: 'rejected',
|
|
306
|
+
message: err.reason || 'Command rejected.',
|
|
307
|
+
stepIndex: step.stepIndex,
|
|
308
|
+
sliceId: step.slice.id,
|
|
309
|
+
elementId: el?.id,
|
|
310
|
+
elementName: el?.name,
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const name = err instanceof Error ? err.name : 'Unknown';
|
|
315
|
+
const firstFrame = err instanceof Error && err.stack ? (err.stack.split('\n')[1]?.trim() ?? '') : '';
|
|
316
|
+
log('error', `threw at ${where}: [${name}] ${err instanceof Error ? err.message : String(err)}${firstFrame ? ` @ ${firstFrame}` : ''}`);
|
|
317
|
+
errors.push({
|
|
318
|
+
kind: 'threw',
|
|
319
|
+
message: err instanceof Error ? err.message : String(err),
|
|
320
|
+
stepIndex: step.stepIndex,
|
|
321
|
+
sliceId: step.slice.id,
|
|
322
|
+
elementId: el?.id,
|
|
323
|
+
elementName: el?.name,
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
/** Resolve the command element a produced command refers to, by name (+context). */
|
|
327
|
+
const resolveCommandElement = (command, fallbackStep) => {
|
|
328
|
+
// Prefer a command element in the current step, else anywhere in the chapter.
|
|
329
|
+
const inStep = fallbackStep.commands.find((c) => c.name === command.name);
|
|
330
|
+
if (inStep)
|
|
331
|
+
return inStep;
|
|
332
|
+
return chapter.elements.find((e) => e.type === 'command' && e.name === command.name &&
|
|
333
|
+
(!command.context || (e.context || 'App') === command.context)) ?? chapter.elements.find((e) => e.type === 'command' && e.name === command.name);
|
|
334
|
+
};
|
|
335
|
+
/** The step a command element lives in (for its slice events in the default). */
|
|
336
|
+
const stepForElement = (el, fallback) => {
|
|
337
|
+
if (!el)
|
|
338
|
+
return fallback;
|
|
339
|
+
return steps.find((s) => s.slice.id === el.sliceId) ?? fallback;
|
|
340
|
+
};
|
|
341
|
+
/** Run a command through decide (authored or default) → ordered events. */
|
|
342
|
+
const runCommand = (step, command) => {
|
|
343
|
+
const cmdEl = resolveCommandElement(command, step);
|
|
344
|
+
const targetStep = stepForElement(cmdEl, step);
|
|
345
|
+
const authored = handlerFor(cmdEl);
|
|
346
|
+
let decided;
|
|
347
|
+
if (authored) {
|
|
348
|
+
try {
|
|
349
|
+
const out = runHandler(getCompiled(authored, 'decide'), clock.surface(), [clone(command), frozenState()]);
|
|
350
|
+
decided = Array.isArray(out)
|
|
351
|
+
? out.map((e) => {
|
|
352
|
+
const ev = e;
|
|
353
|
+
return { name: String(ev.name ?? ''), context: ev.context || command.context, payload: ev.payload ?? {} };
|
|
354
|
+
})
|
|
355
|
+
: [];
|
|
356
|
+
log('decide', `decide(${command.name}, state) → ${decided.length} event(s): ${decided.map((d) => d.name).join(', ') || '—'}`, command.payload);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
recordError(err, step, cmdEl);
|
|
360
|
+
return; // reject/throw: no events, no state change for this command
|
|
361
|
+
}
|
|
362
|
+
for (const ev of decided)
|
|
363
|
+
emitEvent(targetStep, ev);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// DEFAULT decide: emit the command's connected event(s) — which may live in
|
|
367
|
+
// a following event-only slice — each carrying the command payload (R11).
|
|
368
|
+
// Emit each event in the STEP that contains its event element, so authored
|
|
369
|
+
// apply / default-merge resolve against the right slice's Information.
|
|
370
|
+
const eventEls = cmdEl ? (eventsByCommandId.get(cmdEl.id) ?? []) : [];
|
|
371
|
+
if (eventEls.length > 0) {
|
|
372
|
+
log('decide', `default decide(${command.name}) → connected event(s): ${eventEls.map((e) => e.name).join(', ')}`, command.payload);
|
|
373
|
+
for (const evtEl of eventEls) {
|
|
374
|
+
const evtStep = stepForElement(evtEl, targetStep);
|
|
375
|
+
emitEvent(evtStep, {
|
|
376
|
+
name: evtEl.name,
|
|
377
|
+
context: evtEl.context || command.context,
|
|
378
|
+
payload: clone(command.payload) ?? {},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// Fallback: command's own slice events (M1 single-slice behaviour).
|
|
384
|
+
log('decide', `default decide(${command.name}) → slice event(s): ${targetStep.events.map((e) => e.name).join(', ') || '—'}`, command.payload);
|
|
385
|
+
for (const ev of defaultDecide(targetStep, command))
|
|
386
|
+
emitEvent(targetStep, ev);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
/** Build a command object for an element, with the storage that drives it. */
|
|
391
|
+
const commandFromElement = (cmdEl, storage) => ({
|
|
392
|
+
name: cmdEl.name,
|
|
393
|
+
context: cmdEl.context || 'App',
|
|
394
|
+
payload: storage ? clone(storage) : {},
|
|
395
|
+
});
|
|
396
|
+
/**
|
|
397
|
+
* Fire automation event-reactions for a just-emitted event (R10 react).
|
|
398
|
+
* Reacting automations are resolved via connection rules (reactionsByEventName).
|
|
399
|
+
* Each authored `react(event, state)` returns commands which are RECORDED as
|
|
400
|
+
* PENDING (not run immediately): they fire when the playhead reaches the target
|
|
401
|
+
* command's slice (draft §4.6), mirroring the UI captured-input → command flow.
|
|
402
|
+
*/
|
|
403
|
+
const fireReactions = (step, event) => {
|
|
404
|
+
const reactors = reactionsByEventName.get(event.name);
|
|
405
|
+
if (!reactors || reactors.length === 0)
|
|
406
|
+
return;
|
|
407
|
+
for (const autoEl of reactors) {
|
|
408
|
+
const authored = handlerFor(autoEl);
|
|
409
|
+
if (!authored)
|
|
410
|
+
continue; // default automation reaction is a no-op (R11)
|
|
411
|
+
try {
|
|
412
|
+
const out = runHandler(getCompiled(authored, 'react'), clock.surface(), [clone(event), frozenState()]);
|
|
413
|
+
const cmds = Array.isArray(out) ? out : [];
|
|
414
|
+
log('react', `react(${event.name}, state) by ${autoEl.name} → ${cmds.length} command(s): ${cmds.map((c) => c?.name).join(', ') || '—'}`);
|
|
415
|
+
if (Array.isArray(out)) {
|
|
416
|
+
for (const c of out) {
|
|
417
|
+
const cc = c;
|
|
418
|
+
recordPendingCommand(autoEl, {
|
|
419
|
+
name: String(cc.name ?? ''),
|
|
420
|
+
context: cc.context || (autoEl.context || 'App'),
|
|
421
|
+
payload: cc.payload ?? {},
|
|
422
|
+
}, event.name);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
recordError(err, step, autoEl);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
/** Record an automation-dispatched command as pending (fires at its slice). */
|
|
432
|
+
const recordPendingCommand = (autoEl, command, triggeredByEventName) => {
|
|
433
|
+
const target = chapter.elements.find((e) => e.type === 'command' && e.name === command.name &&
|
|
434
|
+
(!command.context || (e.context || 'App') === command.context)) ?? chapter.elements.find((e) => e.type === 'command' && e.name === command.name);
|
|
435
|
+
log('pending', `defer command ${command.context}.${command.name} from ${autoEl.name}${triggeredByEventName ? ` (on ${triggeredByEventName})` : ' (job)'} — ${target ? 'target resolved' : 'UNRESOLVED target'}`, command.payload);
|
|
436
|
+
pendingCommands.push({
|
|
437
|
+
commandElementId: target?.id,
|
|
438
|
+
name: command.name,
|
|
439
|
+
context: command.context,
|
|
440
|
+
payload: clone(command.payload) ?? {},
|
|
441
|
+
sourceAutomationId: autoEl.id,
|
|
442
|
+
sourceAutomationName: autoEl.name,
|
|
443
|
+
triggeredByEventName,
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
// Connection-aware storage routing (UI may drive a command in a later slice).
|
|
447
|
+
const storageByCommandId = new Map();
|
|
448
|
+
for (let s = 0; s <= upto; s++) {
|
|
449
|
+
const uiStep = steps[s];
|
|
450
|
+
if (!uiStep.uiElement)
|
|
451
|
+
continue;
|
|
452
|
+
const storage = storageForStep(s);
|
|
453
|
+
if (storage === undefined)
|
|
454
|
+
continue;
|
|
455
|
+
for (const cmd of getCommandsDrivenByUi(chapter, uiStep.uiElement)) {
|
|
456
|
+
storageByCommandId.set(cmd.id, storage);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// ── Fold steps 0..upto ─────────────────────────────────────────────────────
|
|
460
|
+
for (let s = 0; s <= upto; s++) {
|
|
461
|
+
const step = steps[s];
|
|
462
|
+
try {
|
|
463
|
+
log('flow', `step ${s}/${upto} — slice "${step.slice.label}"${step.uiElement ? ` ui=${step.uiElement.name}` : ''}${step.commands.length ? ` cmd=${step.commands.map((c) => c.name).join(',')}` : ''}${step.events.length ? ` evt=${step.events.map((e) => e.name).join(',')}` : ''}${step.automationElement ? ` auto=${step.automationElement.name}` : ''}`);
|
|
464
|
+
// Expose this step's captured input via state.$input BEFORE any handler on
|
|
465
|
+
// this step runs, so interact/decide/apply (and the end-of-fold read) can
|
|
466
|
+
// query it. Only UI steps carry input; others leave $input unchanged.
|
|
467
|
+
if (step.uiElement) {
|
|
468
|
+
const stepStorage = storageForStep(s);
|
|
469
|
+
if (stepStorage !== undefined) {
|
|
470
|
+
inputByStep[s] = stepStorage;
|
|
471
|
+
refreshInput();
|
|
472
|
+
log('input', `$input ← step ${s} storage (${step.uiElement.name})`, stepStorage);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// 1. UI step with an authored interact → commands (authored only; the
|
|
476
|
+
// default UI path is handled by storage routing feeding the command).
|
|
477
|
+
let uiInteractRan = false;
|
|
478
|
+
if (step.uiElement) {
|
|
479
|
+
const authored = handlerFor(step.uiElement);
|
|
480
|
+
if (authored) {
|
|
481
|
+
uiInteractRan = true;
|
|
482
|
+
const storage = storageForStep(s) ?? {};
|
|
483
|
+
try {
|
|
484
|
+
const out = runHandler(getCompiled(authored, 'interact'), clock.surface(), [clone(storage), frozenState()]);
|
|
485
|
+
const cmds = Array.isArray(out) ? out : [];
|
|
486
|
+
log('interact', `interact(storage, state) on ${step.uiElement.name} → ${cmds.length} command(s): ${cmds.map((c) => c?.name).join(', ') || '—'}`, storage);
|
|
487
|
+
if (Array.isArray(out)) {
|
|
488
|
+
for (const c of out) {
|
|
489
|
+
const cc = c;
|
|
490
|
+
runCommand(step, {
|
|
491
|
+
name: String(cc.name ?? ''),
|
|
492
|
+
context: cc.context || (step.uiElement.context || 'App'),
|
|
493
|
+
payload: cc.payload ?? {},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
recordError(err, step, step.uiElement);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// 2. Commands in the slice. Each command fires from exactly one source,
|
|
504
|
+
// checked in order: (a) a PENDING command dispatched earlier by an
|
|
505
|
+
// automation whose target is this command; (b) UI storage routed to it;
|
|
506
|
+
// (c) the M1 default. Skipped entirely when an authored UI interact ran
|
|
507
|
+
// (that interact is the command source for this step).
|
|
508
|
+
if (!uiInteractRan && step.commands.length > 0) {
|
|
509
|
+
for (const cmdEl of step.commands) {
|
|
510
|
+
// (a) consume pending commands targeting this command element.
|
|
511
|
+
let firedFromPending = false;
|
|
512
|
+
for (let pi = 0; pi < pendingCommands.length; pi++) {
|
|
513
|
+
if (firedPending.has(pi))
|
|
514
|
+
continue;
|
|
515
|
+
const pc = pendingCommands[pi];
|
|
516
|
+
if (pc.commandElementId !== cmdEl.id)
|
|
517
|
+
continue;
|
|
518
|
+
firedPending.add(pi);
|
|
519
|
+
firedFromPending = true;
|
|
520
|
+
log('pending', `fire deferred command ${pc.context}.${pc.name} at slice "${step.slice.label}" (from ${pc.sourceAutomationName})`, pc.payload);
|
|
521
|
+
runCommand(step, { name: pc.name, context: pc.context, payload: clone(pc.payload) });
|
|
522
|
+
}
|
|
523
|
+
if (firedFromPending)
|
|
524
|
+
continue;
|
|
525
|
+
// (b)/(c) storage-routed or default.
|
|
526
|
+
const storage = storageByCommandId.get(cmdEl.id);
|
|
527
|
+
runCommand(step, commandFromElement(cmdEl, storage));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
else if (!uiInteractRan && step.events.length > 0 && !step.uiElement) {
|
|
531
|
+
// 3. Hand-drawn events with no command driver: emit directly (empty payload),
|
|
532
|
+
// applying authored/default apply. Preserves M1 behaviour.
|
|
533
|
+
for (const evtEl of step.events) {
|
|
534
|
+
emitEvent(step, { name: evtEl.name, context: evtEl.context || 'App', payload: {} });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// 4. Automation (job) process(state) → commands, when the playhead reaches it.
|
|
538
|
+
// An automation is a JOB only if it is NOT an event-reaction target
|
|
539
|
+
// (reaction automations fire via fireReactions when their event occurs).
|
|
540
|
+
if (step.automationElement) {
|
|
541
|
+
const authored = handlerFor(step.automationElement);
|
|
542
|
+
const isReaction = reactionAutomationIds.has(step.automationElement.id);
|
|
543
|
+
if (authored && !isReaction) {
|
|
544
|
+
try {
|
|
545
|
+
const out = runHandler(getCompiled(authored, 'process'), clock.surface(), [frozenState()]);
|
|
546
|
+
const cmds = Array.isArray(out) ? out : [];
|
|
547
|
+
log('process', `process(state) on ${step.automationElement.name} → ${cmds.length} command(s): ${cmds.map((c) => c?.name).join(', ') || '—'}`);
|
|
548
|
+
if (Array.isArray(out)) {
|
|
549
|
+
for (const c of out) {
|
|
550
|
+
const cc = c;
|
|
551
|
+
// Deferred like a reaction: the job's command fires when the
|
|
552
|
+
// playhead reaches its (downstream) slice. Surfaced as pending.
|
|
553
|
+
recordPendingCommand(step.automationElement, {
|
|
554
|
+
name: String(cc.name ?? ''),
|
|
555
|
+
context: cc.context || (step.automationElement.context || 'App'),
|
|
556
|
+
payload: cc.payload ?? {},
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
recordError(err, step, step.automationElement);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// 5. Read-step surfacing: ensure seeded Information buckets appear (M1 parity).
|
|
567
|
+
if (step.events.length === 0 && step.commands.length === 0 && step.information.length > 0) {
|
|
568
|
+
for (const info of step.information) {
|
|
569
|
+
const ctxKey = toClassName(info.context || 'App');
|
|
570
|
+
const elKey = toClassName(info.name);
|
|
571
|
+
const ctx = isPlainObject(state[ctxKey]) ? state[ctxKey] : {};
|
|
572
|
+
if (!(elKey in ctx)) {
|
|
573
|
+
ctx[elKey] = ctx[elKey] ?? {};
|
|
574
|
+
state[ctxKey] = ctx;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
// A cascade-exceeded stops THIS step; record it (it may have been thrown
|
|
581
|
+
// from emitEvent's budget check without passing through recordError) and
|
|
582
|
+
// continue so the playhead settles with last-good state for earlier steps.
|
|
583
|
+
if (isCascadeBudgetExceeded(err)) {
|
|
584
|
+
if (!errors.some((e) => e.kind === 'cascade-exceeded' && e.stepIndex === step.stepIndex)) {
|
|
585
|
+
errors.push({
|
|
586
|
+
kind: 'cascade-exceeded',
|
|
587
|
+
message: err.message,
|
|
588
|
+
stepIndex: step.stepIndex,
|
|
589
|
+
sliceId: step.slice.id,
|
|
590
|
+
cascadeKind: err.kind,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
recordError(err, step);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const unfiredPending = pendingCommands.filter((_pc, i) => !firedPending.has(i));
|
|
600
|
+
const readViews = computeReadViews(upto);
|
|
601
|
+
log('flow', `fold done — events=${state.$events.length}, errors=${errors.length}, pending=${unfiredPending.length}, readViews=${readViews.length}, clock=${clock.now()}`);
|
|
602
|
+
return {
|
|
603
|
+
events: state.$events,
|
|
604
|
+
state,
|
|
605
|
+
errors,
|
|
606
|
+
readViews,
|
|
607
|
+
pendingCommands: unfiredPending,
|
|
608
|
+
clockISO: clock.now(),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/** Convenience: derive steps + run the fold in one call (used by the worker). */
|
|
612
|
+
export function foldChapter(chapter, scenario, playheadIndex, handlers, liveStorage, cascadeLimits, logSource) {
|
|
613
|
+
if (!chapter) {
|
|
614
|
+
const empty = seedState(scenario, new DeterministicClock(scenario.clock).now());
|
|
615
|
+
return { events: empty.$events, state: empty, errors: [], readViews: [] };
|
|
616
|
+
}
|
|
617
|
+
const steps = deriveRuntimeSteps(chapter);
|
|
618
|
+
return runFold({ chapter, steps, scenario, playheadIndex, handlers, liveStorage, cascadeLimits, logSource });
|
|
619
|
+
}
|
|
620
|
+
//# sourceMappingURL=runtimeFold.js.map
|