@volter/world-core 3.0.165 → 3.0.167

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.
Files changed (47) hide show
  1. package/attach.cjs +7 -3
  2. package/dist/attach.cjs +7 -3
  3. package/dist/src/changeset.d.ts +9 -0
  4. package/dist/src/changeset.js +5 -2
  5. package/dist/src/checkout.d.ts +4 -0
  6. package/dist/src/checkout.js +13 -0
  7. package/dist/src/completeness.d.ts +36 -0
  8. package/dist/src/completeness.js +46 -0
  9. package/dist/src/derived-core.d.ts +27 -0
  10. package/dist/src/derived-core.js +31 -2
  11. package/dist/src/events.d.ts +6 -1
  12. package/dist/src/events.js +58 -13
  13. package/dist/src/index.d.ts +4 -2
  14. package/dist/src/index.js +3 -2
  15. package/dist/src/openai-wire.js +0 -1
  16. package/dist/src/pack-fetch.d.ts +1 -3
  17. package/dist/src/pack-fetch.js +103 -43
  18. package/dist/src/runtime.d.ts +5 -2
  19. package/dist/src/runtime.js +3 -1
  20. package/dist/src/scenario-consume.d.ts +43 -0
  21. package/dist/src/scenario-consume.js +111 -0
  22. package/dist/src/scenario-serve.d.ts +16 -0
  23. package/dist/src/scenario-serve.js +47 -0
  24. package/dist/src/scenario-timeline.d.ts +131 -0
  25. package/dist/src/scenario-timeline.js +242 -0
  26. package/dist/src/scenario.d.ts +15 -93
  27. package/dist/src/scenario.js +34 -323
  28. package/dist/src/sockets.d.ts +8 -4
  29. package/dist/src/sockets.js +1 -1
  30. package/dist/src/world-clock.d.ts +3 -0
  31. package/dist/src/world-clock.js +14 -9
  32. package/package.json +5 -1
  33. package/src/changeset.ts +13 -3
  34. package/src/checkout.ts +12 -0
  35. package/src/completeness.ts +57 -0
  36. package/src/derived-core.ts +41 -2
  37. package/src/events.ts +58 -11
  38. package/src/index.ts +5 -3
  39. package/src/openai-wire.ts +0 -1
  40. package/src/pack-fetch.ts +81 -39
  41. package/src/runtime.ts +5 -2
  42. package/src/scenario-consume.ts +123 -0
  43. package/src/scenario-serve.ts +47 -0
  44. package/src/scenario-timeline.ts +269 -0
  45. package/src/scenario.ts +43 -341
  46. package/src/sockets.ts +5 -5
  47. package/src/world-clock.ts +16 -9
@@ -0,0 +1,47 @@
1
+ // A model vendor's turn and a request's fault, as the World's scenario places them (architecture, "Scenarios"). The
2
+ // placed turns are matched with the pack's own matchers (its `semantics/scenario.ts` adapter, through the kernel's
3
+ // grammar in scenario.ts), in timeline order; what answered, the labelled default or the fault that landed is recorded
4
+ // in the twin's log (scenario-consume.ts).
5
+ import { parseScenarioDocument, ScenarioEngine, scenarioFaultResult, type PackScenarioAdapter, type ScenarioFault, type ScenarioHandler, type ScenarioStatusFault } from './scenario.ts';
6
+ import { recordFault, recordTurn, requestFaults, turnDecisions, type ScenarioLedger } from './scenario-consume.ts';
7
+ import type { PlacedFault } from './scenario-timeline.ts';
8
+ import type { ScenarioServed } from './sockets.ts';
9
+
10
+ /** A request fault as the kernel's fault grammar answers it: `rejected` at the edge (503 unless named), `failed` and
11
+ * `lost` as the vendor's server failure (500 unless named). */
12
+ export function kernelFault(fault: PlacedFault): ScenarioFault {
13
+ if (fault.kind === 'slow') return { kind: 'slow', ms: fault.ms ?? 0 };
14
+ if (fault.kind === 'drop') return { kind: 'drop', ...(fault.ms !== undefined ? { holdMs: fault.ms } : {}) };
15
+ if (fault.kind === 'generation') return { kind: 'generation', status: fault.status!, message: fault.message!, errorType: fault.errorType!, ...(fault.metadata ? { metadata: fault.metadata } : {}), ...(fault.partialText !== undefined ? { partialText: fault.partialText } : {}), ...(fault.event !== undefined ? { event: fault.event } : {}) };
16
+ const status = fault.status ?? (fault.kind === 'rejected' ? 503 : fault.kind === 'rate-limit' ? 429 : 500);
17
+ return { kind: 'status', status, ...(fault.retryAfterSeconds !== undefined ? { retryAfterSeconds: fault.retryAfterSeconds } : {}), message: fault.message ?? `${fault.kind}: the World's scenario placed this fault (entry ${fault.index})`, ...(fault.errorType ? { errorType: fault.errorType } : {}), ...(fault.metadata ? { metadata: fault.metadata } : {}) };
18
+ }
19
+
20
+ /** A model vendor's turn: a placed fault whose `on` the request satisfies lands first (a `slow` one holds the turn, then
21
+ * it is decided); then the first placed turn decision whose `on` it satisfies and whose answer it admits, else the
22
+ * miss, the pack's labelled stub. What landed and what decided are recorded either way. */
23
+ export async function serveTurn<Req>(asked: Req, ledger: ScenarioLedger, adapter: PackScenarioAdapter<Req>, signal?: AbortSignal): Promise<ScenarioServed> {
24
+ const engineOf = (handlers: ScenarioHandler[]) => new ScenarioEngine(adapter, parseScenarioDocument({ handlers }, adapter));
25
+ const faults = requestFaults(ledger).filter((f) => f.on !== undefined);
26
+ if (faults.length) {
27
+ const hit = engineOf(faults.map((f) => ({ id: `entry-${f.index}`, on: f.on!, fault: kernelFault(f) }))).next(asked);
28
+ if (hit.kind === 'handler') {
29
+ const fault = faults.find((f) => `entry-${f.index}` === hit.ruleId)!;
30
+ await recordFault(ledger, fault, { point: 'turn' });
31
+ // a generation failure is admitted, then fails: the pack's handler answers it in its SDK's error wire
32
+ if (fault.kind === 'generation') return hit;
33
+ const result = await scenarioFaultResult(hit.fault!, () => adapter.renderFault?.(hit.fault as ScenarioStatusFault, asked), signal);
34
+ if (result !== null) return { kind: 'fault', result, ruleId: hit.ruleId };
35
+ }
36
+ }
37
+ const decisions = turnDecisions(ledger);
38
+ const decided = engineOf(decisions.map((d) => ({ id: `entry-${d.index}`, on: d.on ?? {}, respond: d.outcome }))).next(asked);
39
+ await recordTurn(ledger, decided.kind === 'handler' ? decisions.find((d) => `entry-${d.index}` === decided.ruleId) : undefined, decided.kind === 'handler' ? decided.respond : null);
40
+ return decided;
41
+ }
42
+
43
+ /** What a request fault answers in place of the vendor's answer: the vendor's own envelope where the pack renders one. */
44
+ export async function faultAnswer(fault: PlacedFault, render: (() => { body: unknown; headers?: Record<string, string> } | undefined) | undefined, signal?: AbortSignal): Promise<Response | null> {
45
+ const result = await scenarioFaultResult(kernelFault(fault), render, signal);
46
+ return result === null ? null : Response.json(result.body, { status: result.status, headers: result.headers });
47
+ }
@@ -0,0 +1,269 @@
1
+ // A SCENARIO is a timeline of inputs (ADR 0036; architecture, "Scenarios"): calls, people's actions, decisions, faults
2
+ // and the clock, played at World times after the World's seed, its prefix. Nothing in it answers in a twin's place: a
3
+ // twin consumes a decision where its vendor decides and a fault where it lands, and records both in its own log.
4
+ //
5
+ // This module is the grammar (a strict parse that names the vocabulary on every refusal), the resolution of a
6
+ // document's times, and the twins' read of the scenario a World is playing: the instance's resolved document, read
7
+ // through the World's store at every request as the clock is (TWIN_WORLD_SCENARIO_FILE), so a decision or a fault holds
8
+ // from its `at` with no reboot.
9
+ import { getActiveWorldStore } from './world-store.ts';
10
+ import { worldNow } from './world-clock.ts';
11
+ import { parseExactJson } from './exact-json.ts';
12
+
13
+ export const WORLD_SCENARIO_ENV = 'TWIN_WORLD_SCENARIO_FILE';
14
+
15
+ /** A call to a vendor's API at its own URL, routed to its twin as the application's calls are; `${NAME}` in a header is
16
+ * the value the World sets for its application, and `as` names the person whose World browser carries its cookies. */
17
+ export type ScenarioCall = { method: string; url: string; headers?: Record<string, string>; body?: unknown; as?: string };
18
+ export type ScenarioAction = { person: string; browser?: string; run: string };
19
+ /** The outcome of a vendor's judgement at one of its decision points, for the subject it is keyed to (absent: every
20
+ * subject); a model vendor's turn is the point `turn`, keyed by `on`, the pack's scenario matchers. */
21
+ type ScenarioDecisionInput = { vendor: string; point: string; subject?: string; on?: Record<string, unknown>; outcome: unknown; once?: boolean };
22
+
23
+ const FAULT_KINDS = {
24
+ request: ['rejected', 'failed', 'lost', 'slow', 'drop', 'generation'],
25
+ 'over-time': ['webhook-delay', 'webhook-duplicate', 'webhook-drop', 'lag', 'rate-limit'],
26
+ network: ['reset', 'dns', 'tls', 'latency', 'loss', 'partition'],
27
+ infrastructure: ['oom', 'disk-full', 'crash', 'restart'],
28
+ time: ['jump'],
29
+ } as const;
30
+ type FaultLayer = keyof typeof FAULT_KINDS;
31
+ /** The layers asked of the machine the app runs on, through its fault door (ADR 0036 §4). */
32
+ export const MACHINE_LAYERS: ReadonlyArray<FaultLayer> = ['network', 'infrastructure'];
33
+
34
+ export type ScenarioFaultInput = {
35
+ layer: FaultLayer;
36
+ kind: string;
37
+ /** the twin a vendor-layer fault lands in */
38
+ vendor?: string;
39
+ /** a request fault's operation (the spec's operationId), or a model turn's `on`; neither: every request */
40
+ operation?: string;
41
+ on?: Record<string, unknown>;
42
+ /** a point of the pack's own (`ctx.fault(point, subject)`: one item of a batch) */
43
+ point?: string;
44
+ subject?: string;
45
+ status?: number;
46
+ message?: string;
47
+ errorType?: string;
48
+ metadata?: Record<string, unknown>;
49
+ partialText?: string;
50
+ event?: string;
51
+ retryAfterSeconds?: number;
52
+ ms?: number;
53
+ /** a delay, a lag or a window: `90s`, `5m`, `12h`, `30d` */
54
+ by?: string;
55
+ per?: string;
56
+ limit?: number;
57
+ /** a machine fault's part of the app, as the machine knows it; a reset's or a TLS failure's destination; a full disk's
58
+ * volume */
59
+ target?: string;
60
+ to?: string;
61
+ volume?: string;
62
+ between?: [string, string];
63
+ rate?: number;
64
+ /** how many times it lands (default: every time, until `until`) */
65
+ times?: number;
66
+ until?: string;
67
+ };
68
+
69
+ type ScenarioClock = { advance: string } | { set: string };
70
+
71
+ type TimelineEntry = { at: string } & (
72
+ | { call: ScenarioCall }
73
+ | { action: ScenarioAction }
74
+ | { decision: ScenarioDecisionInput }
75
+ | { fault: ScenarioFaultInput }
76
+ | { clock: ScenarioClock }
77
+ );
78
+
79
+ export type Scenario = { start: string; random?: string; timeline: TimelineEntry[]; properties?: string };
80
+
81
+ /** A scenario as a World plays it: every `at` an instant, each entry its place in the timeline (what a twin's record of a
82
+ * consumption names). */
83
+ export type ResolvedScenario = { name: string; start: string; random: string; timeline: Array<TimelineEntry & { index: number }>; properties?: string };
84
+
85
+ export class ScenarioTimelineError extends Error {}
86
+
87
+ const fail = (where: string, message: string): never => { throw new ScenarioTimelineError(`scenario: ${where}: ${message}`); };
88
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
89
+ const onlyKeys = (value: Record<string, unknown>, keys: readonly string[], where: string): void => {
90
+ for (const key of Object.keys(value)) if (key !== '$comment' && !keys.includes(key)) fail(where, `unknown key "${key}" (valid: ${keys.join(', ')})`);
91
+ };
92
+ const text = (value: unknown, where: string, name: string): string => (typeof value === 'string' && value.length > 0 ? value : fail(where, `${name} is a non-empty string`));
93
+ const instant = (value: unknown, where: string, name: string): string => {
94
+ const at = text(value, where, name);
95
+ return Number.isNaN(Date.parse(at)) || !/^\d{4}-\d{2}-\d{2}T/.test(at) ? fail(where, `${name} is an ISO-8601 instant, got ${JSON.stringify(at)}`) : new Date(Date.parse(at)).toISOString();
96
+ };
97
+
98
+ /** `90s`, `5m`, `12h`, `30d` in milliseconds. */
99
+ export function durationMs(by: string): number {
100
+ const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(by.trim());
101
+ if (!m) throw new ScenarioTimelineError(`${JSON.stringify(by)} is not <N>(s|m|h|d)`);
102
+ return Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
103
+ }
104
+ const duration = (value: unknown, where: string, name: string): string => {
105
+ const by = text(value, where, name);
106
+ try { durationMs(by); } catch { fail(where, `${name} is a duration (90s, 5m, 12h, 30d), got ${JSON.stringify(by)}`); }
107
+ return by;
108
+ };
109
+ const positive = (value: unknown, where: string, name: string): number => (Number.isInteger(value) && (value as number) > 0 ? value as number : fail(where, `${name} is a positive integer`));
110
+
111
+ function parseCall(raw: unknown, where: string): ScenarioCall {
112
+ if (!isObject(raw)) return fail(where, 'call is an object { method, url, headers?, body?, as? }');
113
+ onlyKeys(raw, ['method', 'url', 'headers', 'body', 'as'], `${where} call`);
114
+ const method = text(raw.method, where, 'call.method').toUpperCase();
115
+ const url = text(raw.url, where, 'call.url');
116
+ if (!/^https?:\/\//.test(url)) fail(where, 'call.url is the vendor\'s own absolute URL (https://...)');
117
+ if (raw.headers !== undefined && (!isObject(raw.headers) || Object.values(raw.headers).some((x) => typeof x !== 'string'))) fail(where, 'call.headers is an object of strings');
118
+ return { method, url, ...(raw.headers ? { headers: raw.headers as Record<string, string> } : {}), ...('body' in raw ? { body: raw.body } : {}), ...(raw.as !== undefined ? { as: text(raw.as, where, 'call.as') } : {}) };
119
+ }
120
+
121
+ function parseAction(raw: unknown, where: string): ScenarioAction {
122
+ if (!isObject(raw)) return fail(where, 'action is an object { person, browser?, run }');
123
+ onlyKeys(raw, ['person', 'browser', 'run'], `${where} action`);
124
+ return { person: text(raw.person, where, 'action.person'), ...(raw.browser !== undefined ? { browser: text(raw.browser, where, 'action.browser') } : {}), run: text(raw.run, where, 'action.run') };
125
+ }
126
+
127
+ function parseDecision(raw: unknown, where: string): ScenarioDecisionInput {
128
+ if (!isObject(raw)) return fail(where, 'decision is an object { vendor, point, subject?, on?, outcome, once? }');
129
+ onlyKeys(raw, ['vendor', 'point', 'subject', 'on', 'outcome', 'once'], `${where} decision`);
130
+ if (!('outcome' in raw)) fail(where, 'decision.outcome is required: what the vendor decided');
131
+ if (raw.on !== undefined && !isObject(raw.on)) fail(where, 'decision.on is an object of the pack\'s turn matchers');
132
+ if (raw.once !== undefined && typeof raw.once !== 'boolean') fail(where, 'decision.once is boolean');
133
+ return { vendor: text(raw.vendor, where, 'decision.vendor'), point: text(raw.point, where, 'decision.point'), ...(raw.subject !== undefined ? { subject: text(raw.subject, where, 'decision.subject') } : {}), ...(raw.on !== undefined ? { on: raw.on as Record<string, unknown> } : {}), outcome: raw.outcome, ...(raw.once !== undefined ? { once: raw.once as boolean } : {}) };
134
+ }
135
+
136
+ const FAULT_KEYS: Record<string, readonly string[]> = {
137
+ rejected: ['status', 'message', 'errorType', 'metadata', 'retryAfterSeconds'],
138
+ failed: ['status', 'message', 'errorType', 'metadata', 'retryAfterSeconds'],
139
+ lost: ['status', 'message', 'errorType'],
140
+ slow: ['ms'],
141
+ drop: ['ms'],
142
+ generation: ['status', 'message', 'errorType', 'metadata', 'partialText', 'event'],
143
+ 'webhook-delay': ['event', 'by'],
144
+ 'webhook-duplicate': ['event'],
145
+ 'webhook-drop': ['event'],
146
+ lag: ['by'],
147
+ 'rate-limit': ['limit', 'per', 'retryAfterSeconds', 'message'],
148
+ reset: ['target', 'to'], dns: ['target'], tls: ['target', 'to'], latency: ['target', 'ms'], loss: ['target', 'rate'], partition: ['between'],
149
+ oom: ['target'], 'disk-full': ['target', 'volume'], crash: ['target'], restart: ['target'],
150
+ jump: ['by'],
151
+ };
152
+
153
+ function parseFault(raw: unknown, where: string): ScenarioFaultInput {
154
+ if (!isObject(raw)) return fail(where, 'fault is an object { layer, kind, ... }');
155
+ const layer = raw.layer as FaultLayer;
156
+ if (!(layer in FAULT_KINDS)) fail(where, `fault.layer is one of ${Object.keys(FAULT_KINDS).join(', ')}, got ${JSON.stringify(raw.layer)}`);
157
+ const kinds = FAULT_KINDS[layer] as readonly string[];
158
+ if (!kinds.includes(raw.kind as string)) fail(where, `a ${layer} fault's kind is one of ${kinds.join(', ')}, got ${JSON.stringify(raw.kind)}`);
159
+ const kind = raw.kind as string;
160
+ const vendorLayer = layer === 'request' || layer === 'over-time';
161
+ const placement = vendorLayer ? ['vendor', 'times', 'until', ...(layer === 'request' ? ['operation', 'on', 'point', 'subject'] : ['operation'])] : ['times', 'until'];
162
+ onlyKeys(raw, ['layer', 'kind', ...placement, ...FAULT_KEYS[kind]!], `${where} fault (${layer} ${kind})`);
163
+ if (vendorLayer) text(raw.vendor, where, 'fault.vendor');
164
+ if (raw.operation !== undefined) text(raw.operation, where, 'fault.operation');
165
+ if (raw.on !== undefined && !isObject(raw.on)) fail(where, 'fault.on is an object of the pack\'s turn matchers');
166
+ if ([raw.operation, raw.on, raw.point].filter((v) => v !== undefined).length > 1) fail(where, 'a fault lands on one of operation, on (a model turn) or point');
167
+ if (raw.subject !== undefined && raw.point === undefined) fail(where, 'fault.subject keys a fault at a pack\'s point; name the point');
168
+ if (raw.status !== undefined && !(Number.isInteger(raw.status) && (raw.status as number) >= 400 && (raw.status as number) <= 599)) fail(where, 'fault.status is an integer 400..599: a fault is a refusal or a failure, never a success');
169
+ if (kind === 'generation' && raw.on === undefined) fail(where, 'a generation failure is a model turn\'s: name its `on`');
170
+ if (kind === 'lost' && raw.on !== undefined) fail(where, 'a lost answer lands on an operation: name its `operation`');
171
+ if (kind === 'generation' && (typeof raw.message !== 'string' || typeof raw.errorType !== 'string' || !raw.errorType || raw.status === undefined)) fail(where, 'a generation fault names its status, message and nonempty errorType');
172
+ for (const name of ['message', 'errorType', 'partialText', 'event', 'target', 'to', 'volume'] as const) if (raw[name] !== undefined && typeof raw[name] !== 'string') fail(where, `fault.${name} is a string`);
173
+ if (raw.metadata !== undefined && !isObject(raw.metadata)) fail(where, 'fault.metadata is an object');
174
+ if (raw.ms !== undefined && !(Number.isInteger(raw.ms) && (raw.ms as number) >= 0 && (raw.ms as number) <= 600_000)) fail(where, 'fault.ms is an integer 0..600000 (milliseconds)');
175
+ if (raw.retryAfterSeconds !== undefined && !(Number.isInteger(raw.retryAfterSeconds) && (raw.retryAfterSeconds as number) >= 0)) fail(where, 'fault.retryAfterSeconds is a non-negative integer');
176
+ for (const name of ['by', 'per'] as const) if (raw[name] !== undefined) duration(raw[name], where, `fault.${name}`);
177
+ if (['webhook-delay', 'lag', 'jump'].includes(kind)) duration(raw.by, where, 'fault.by');
178
+ if (MACHINE_LAYERS.includes(layer) && kind !== 'partition' && raw.target === undefined) fail(where, `a ${layer} fault names its target, the part of the app it lands on`);
179
+ if (kind === 'rate-limit') { positive(raw.limit, where, 'fault.limit'); duration(raw.per, where, 'fault.per'); }
180
+ if (raw.rate !== undefined && !(typeof raw.rate === 'number' && raw.rate > 0 && raw.rate <= 1)) fail(where, 'fault.rate is a fraction in (0, 1]');
181
+ if (kind === 'partition' && !(Array.isArray(raw.between) && raw.between.length === 2 && raw.between.every((s) => typeof s === 'string' && s))) fail(where, 'a partition names its two sides: between [a, b]');
182
+ if (raw.times !== undefined) positive(raw.times, where, 'fault.times');
183
+ if (raw.until !== undefined) instant(raw.until, where, 'fault.until');
184
+ return raw as ScenarioFaultInput;
185
+ }
186
+
187
+ function parseClock(raw: unknown, where: string): ScenarioClock {
188
+ if (!isObject(raw)) return fail(where, 'clock is { advance: <duration> } or { set: <instant> }');
189
+ onlyKeys(raw, ['advance', 'set'], `${where} clock`);
190
+ if (('advance' in raw) === ('set' in raw)) fail(where, 'clock is one of { advance } or { set }');
191
+ return 'advance' in raw ? { advance: duration(raw.advance, where, 'clock.advance') } : { set: instant(raw.set, where, 'clock.set') };
192
+ }
193
+
194
+ const ENTRY_KINDS = ['call', 'action', 'decision', 'fault', 'clock'] as const;
195
+
196
+ /** Strict parse of a scenario document: every refusal names where it is and the vocabulary it expected. */
197
+ export function parseScenario(raw: unknown): Scenario {
198
+ if (!isObject(raw)) return fail('document', 'a scenario is an object { start, random?, timeline, properties? }');
199
+ onlyKeys(raw, ['start', 'random', 'timeline', 'properties'], 'document');
200
+ const start = instant(raw.start, 'document', 'start');
201
+ if (!Array.isArray(raw.timeline)) fail('document', 'timeline is an array of entries');
202
+ const timeline = (raw.timeline as unknown[]).map((entry, i): TimelineEntry => {
203
+ const where = `entry ${i + 1}`;
204
+ if (!isObject(entry)) return fail(where, `an entry is { at, ${ENTRY_KINDS.join(' | ')} }`);
205
+ onlyKeys(entry, ['at', ...ENTRY_KINDS], where);
206
+ const kinds = ENTRY_KINDS.filter((k) => k in entry);
207
+ if (kinds.length !== 1) fail(where, `an entry is exactly one of ${ENTRY_KINDS.join(', ')}`);
208
+ const at = text(entry.at, where, 'at');
209
+ if (Number.isNaN(Date.parse(at))) duration(at, where, 'at');
210
+ const kind = kinds[0]!;
211
+ const parsed = kind === 'call' ? parseCall(entry.call, where) : kind === 'action' ? parseAction(entry.action, where) : kind === 'decision' ? parseDecision(entry.decision, where) : kind === 'fault' ? parseFault(entry.fault, where) : parseClock(entry.clock, where);
212
+ return { at, [kind]: parsed } as TimelineEntry;
213
+ });
214
+ return { start, ...(raw.random !== undefined ? { random: text(raw.random, 'document', 'random') } : {}), timeline, ...(raw.properties !== undefined ? { properties: text(raw.properties, 'document', 'properties') } : {}) };
215
+ }
216
+
217
+ /** Every `at` an instant: an instant as written, a duration after `start`. An entry before the one ahead of it, or before
218
+ * `start`, is refused: the clock moves only forward through a timeline. */
219
+ export function resolveScenario(name: string, doc: Scenario): ResolvedScenario {
220
+ const start = Date.parse(doc.start);
221
+ let last = start;
222
+ const timeline = doc.timeline.map((entry, i) => {
223
+ const at = Number.isNaN(Date.parse(entry.at)) || !/^\d{4}-/.test(entry.at) ? start + durationMs(entry.at) : Date.parse(entry.at);
224
+ if (at < last) fail(`entry ${i + 1}`, `at ${new Date(at).toISOString()} is before the entry ahead of it (${new Date(last).toISOString()}); a timeline runs forward`);
225
+ last = at;
226
+ return { ...entry, at: new Date(at).toISOString(), index: i + 1 };
227
+ });
228
+ return { name, start: doc.start, random: doc.random ?? name, timeline, ...(doc.properties !== undefined ? { properties: doc.properties } : {}) };
229
+ }
230
+
231
+ const kept = new Map<string, { raw: string; value: unknown }>();
232
+
233
+ /** A JSON file of the World named by an env variable (its scenario, its facts), read through the World's store at each
234
+ * call as the clock is, and parsed again only when it changed; null where the variable or the file is absent. */
235
+ export function worldJsonFile<T>(env: string): T | null {
236
+ const file = process.env[env];
237
+ if (!file) return null;
238
+ const store = getActiveWorldStore();
239
+ const raw = store.readKept ? store.readKept(file) : store.read(file);
240
+ if (raw === null) return null;
241
+ const held = kept.get(env);
242
+ if (held?.raw === raw) return held.value as T;
243
+ const value = parseExactJson(raw);
244
+ kept.set(env, { raw, value });
245
+ return value as T;
246
+ }
247
+
248
+ /** The scenario this World is playing (the instance's resolved document), or null when it plays none. */
249
+ function activeScenario(): ResolvedScenario | null {
250
+ return worldJsonFile<ResolvedScenario>(WORLD_SCENARIO_ENV);
251
+ }
252
+
253
+ /** The decisions and vendor faults the World's scenario places on one vendor, in timeline order. */
254
+ export function placedOn(vendor: string): ResolvedScenario['timeline'] {
255
+ return (activeScenario()?.timeline ?? []).filter((e) => ('decision' in e && e.decision.vendor === vendor) || ('fault' in e && e.fault.vendor === vendor));
256
+ }
257
+
258
+ export type PlacedDecision = ScenarioDecisionInput & { index: number; at: string };
259
+ export type PlacedFault = ScenarioFaultInput & { index: number; at: string };
260
+
261
+ /** The decisions placed for a vendor's point at or before the World's now, in timeline order. */
262
+ export function placedDecisions(vendor: string, point: string, now: string = worldNow()): PlacedDecision[] {
263
+ return placedOn(vendor).flatMap((e) => ('decision' in e && e.decision.point === point && e.at <= now ? [{ ...e.decision, index: e.index, at: e.at }] : []));
264
+ }
265
+
266
+ /** The vendor faults placed on a vendor at or before the World's now and not past their `until`, in timeline order. */
267
+ export function placedFaults(vendor: string, layer: 'request' | 'over-time', now: string = worldNow()): PlacedFault[] {
268
+ return placedOn(vendor).flatMap((e) => ('fault' in e && e.fault.layer === layer && e.at <= now && (e.fault.until === undefined || now < new Date(Date.parse(e.fault.until)).toISOString()) ? [{ ...e.fault, index: e.index, at: e.at }] : []));
269
+ }