@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.
- package/attach.cjs +7 -3
- package/dist/attach.cjs +7 -3
- package/dist/src/changeset.d.ts +9 -0
- package/dist/src/changeset.js +5 -2
- package/dist/src/checkout.d.ts +4 -0
- package/dist/src/checkout.js +13 -0
- package/dist/src/completeness.d.ts +36 -0
- package/dist/src/completeness.js +46 -0
- package/dist/src/derived-core.d.ts +27 -0
- package/dist/src/derived-core.js +31 -2
- package/dist/src/events.d.ts +6 -1
- package/dist/src/events.js +58 -13
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +3 -2
- package/dist/src/openai-wire.js +0 -1
- package/dist/src/pack-fetch.d.ts +1 -3
- package/dist/src/pack-fetch.js +103 -43
- package/dist/src/runtime.d.ts +5 -2
- package/dist/src/runtime.js +3 -1
- package/dist/src/scenario-consume.d.ts +43 -0
- package/dist/src/scenario-consume.js +111 -0
- package/dist/src/scenario-serve.d.ts +16 -0
- package/dist/src/scenario-serve.js +47 -0
- package/dist/src/scenario-timeline.d.ts +131 -0
- package/dist/src/scenario-timeline.js +242 -0
- package/dist/src/scenario.d.ts +15 -93
- package/dist/src/scenario.js +34 -323
- package/dist/src/sockets.d.ts +8 -4
- package/dist/src/sockets.js +1 -1
- package/dist/src/world-clock.d.ts +3 -0
- package/dist/src/world-clock.js +14 -9
- package/package.json +5 -1
- package/src/changeset.ts +13 -3
- package/src/checkout.ts +12 -0
- package/src/completeness.ts +57 -0
- package/src/derived-core.ts +41 -2
- package/src/events.ts +58 -11
- package/src/index.ts +5 -3
- package/src/openai-wire.ts +0 -1
- package/src/pack-fetch.ts +81 -39
- package/src/runtime.ts +5 -2
- package/src/scenario-consume.ts +123 -0
- package/src/scenario-serve.ts +47 -0
- package/src/scenario-timeline.ts +269 -0
- package/src/scenario.ts +43 -341
- package/src/sockets.ts +5 -5
- package/src/world-clock.ts +16 -9
|
@@ -0,0 +1,242 @@
|
|
|
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.js";
|
|
10
|
+
import { worldNow } from "./world-clock.js";
|
|
11
|
+
import { parseExactJson } from "./exact-json.js";
|
|
12
|
+
export const WORLD_SCENARIO_ENV = 'TWIN_WORLD_SCENARIO_FILE';
|
|
13
|
+
const FAULT_KINDS = {
|
|
14
|
+
request: ['rejected', 'failed', 'lost', 'slow', 'drop', 'generation'],
|
|
15
|
+
'over-time': ['webhook-delay', 'webhook-duplicate', 'webhook-drop', 'lag', 'rate-limit'],
|
|
16
|
+
network: ['reset', 'dns', 'tls', 'latency', 'loss', 'partition'],
|
|
17
|
+
infrastructure: ['oom', 'disk-full', 'crash', 'restart'],
|
|
18
|
+
time: ['jump'],
|
|
19
|
+
};
|
|
20
|
+
/** The layers asked of the machine the app runs on, through its fault door (ADR 0036 §4). */
|
|
21
|
+
export const MACHINE_LAYERS = ['network', 'infrastructure'];
|
|
22
|
+
export class ScenarioTimelineError extends Error {
|
|
23
|
+
}
|
|
24
|
+
const fail = (where, message) => { throw new ScenarioTimelineError(`scenario: ${where}: ${message}`); };
|
|
25
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
26
|
+
const onlyKeys = (value, keys, where) => {
|
|
27
|
+
for (const key of Object.keys(value))
|
|
28
|
+
if (key !== '$comment' && !keys.includes(key))
|
|
29
|
+
fail(where, `unknown key "${key}" (valid: ${keys.join(', ')})`);
|
|
30
|
+
};
|
|
31
|
+
const text = (value, where, name) => (typeof value === 'string' && value.length > 0 ? value : fail(where, `${name} is a non-empty string`));
|
|
32
|
+
const instant = (value, where, name) => {
|
|
33
|
+
const at = text(value, where, name);
|
|
34
|
+
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();
|
|
35
|
+
};
|
|
36
|
+
/** `90s`, `5m`, `12h`, `30d` in milliseconds. */
|
|
37
|
+
export function durationMs(by) {
|
|
38
|
+
const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(by.trim());
|
|
39
|
+
if (!m)
|
|
40
|
+
throw new ScenarioTimelineError(`${JSON.stringify(by)} is not <N>(s|m|h|d)`);
|
|
41
|
+
return Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
|
|
42
|
+
}
|
|
43
|
+
const duration = (value, where, name) => {
|
|
44
|
+
const by = text(value, where, name);
|
|
45
|
+
try {
|
|
46
|
+
durationMs(by);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
fail(where, `${name} is a duration (90s, 5m, 12h, 30d), got ${JSON.stringify(by)}`);
|
|
50
|
+
}
|
|
51
|
+
return by;
|
|
52
|
+
};
|
|
53
|
+
const positive = (value, where, name) => (Number.isInteger(value) && value > 0 ? value : fail(where, `${name} is a positive integer`));
|
|
54
|
+
function parseCall(raw, where) {
|
|
55
|
+
if (!isObject(raw))
|
|
56
|
+
return fail(where, 'call is an object { method, url, headers?, body?, as? }');
|
|
57
|
+
onlyKeys(raw, ['method', 'url', 'headers', 'body', 'as'], `${where} call`);
|
|
58
|
+
const method = text(raw.method, where, 'call.method').toUpperCase();
|
|
59
|
+
const url = text(raw.url, where, 'call.url');
|
|
60
|
+
if (!/^https?:\/\//.test(url))
|
|
61
|
+
fail(where, 'call.url is the vendor\'s own absolute URL (https://...)');
|
|
62
|
+
if (raw.headers !== undefined && (!isObject(raw.headers) || Object.values(raw.headers).some((x) => typeof x !== 'string')))
|
|
63
|
+
fail(where, 'call.headers is an object of strings');
|
|
64
|
+
return { method, url, ...(raw.headers ? { headers: raw.headers } : {}), ...('body' in raw ? { body: raw.body } : {}), ...(raw.as !== undefined ? { as: text(raw.as, where, 'call.as') } : {}) };
|
|
65
|
+
}
|
|
66
|
+
function parseAction(raw, where) {
|
|
67
|
+
if (!isObject(raw))
|
|
68
|
+
return fail(where, 'action is an object { person, browser?, run }');
|
|
69
|
+
onlyKeys(raw, ['person', 'browser', 'run'], `${where} action`);
|
|
70
|
+
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') };
|
|
71
|
+
}
|
|
72
|
+
function parseDecision(raw, where) {
|
|
73
|
+
if (!isObject(raw))
|
|
74
|
+
return fail(where, 'decision is an object { vendor, point, subject?, on?, outcome, once? }');
|
|
75
|
+
onlyKeys(raw, ['vendor', 'point', 'subject', 'on', 'outcome', 'once'], `${where} decision`);
|
|
76
|
+
if (!('outcome' in raw))
|
|
77
|
+
fail(where, 'decision.outcome is required: what the vendor decided');
|
|
78
|
+
if (raw.on !== undefined && !isObject(raw.on))
|
|
79
|
+
fail(where, 'decision.on is an object of the pack\'s turn matchers');
|
|
80
|
+
if (raw.once !== undefined && typeof raw.once !== 'boolean')
|
|
81
|
+
fail(where, 'decision.once is boolean');
|
|
82
|
+
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 } : {}), outcome: raw.outcome, ...(raw.once !== undefined ? { once: raw.once } : {}) };
|
|
83
|
+
}
|
|
84
|
+
const FAULT_KEYS = {
|
|
85
|
+
rejected: ['status', 'message', 'errorType', 'metadata', 'retryAfterSeconds'],
|
|
86
|
+
failed: ['status', 'message', 'errorType', 'metadata', 'retryAfterSeconds'],
|
|
87
|
+
lost: ['status', 'message', 'errorType'],
|
|
88
|
+
slow: ['ms'],
|
|
89
|
+
drop: ['ms'],
|
|
90
|
+
generation: ['status', 'message', 'errorType', 'metadata', 'partialText', 'event'],
|
|
91
|
+
'webhook-delay': ['event', 'by'],
|
|
92
|
+
'webhook-duplicate': ['event'],
|
|
93
|
+
'webhook-drop': ['event'],
|
|
94
|
+
lag: ['by'],
|
|
95
|
+
'rate-limit': ['limit', 'per', 'retryAfterSeconds', 'message'],
|
|
96
|
+
reset: ['target', 'to'], dns: ['target'], tls: ['target', 'to'], latency: ['target', 'ms'], loss: ['target', 'rate'], partition: ['between'],
|
|
97
|
+
oom: ['target'], 'disk-full': ['target', 'volume'], crash: ['target'], restart: ['target'],
|
|
98
|
+
jump: ['by'],
|
|
99
|
+
};
|
|
100
|
+
function parseFault(raw, where) {
|
|
101
|
+
if (!isObject(raw))
|
|
102
|
+
return fail(where, 'fault is an object { layer, kind, ... }');
|
|
103
|
+
const layer = raw.layer;
|
|
104
|
+
if (!(layer in FAULT_KINDS))
|
|
105
|
+
fail(where, `fault.layer is one of ${Object.keys(FAULT_KINDS).join(', ')}, got ${JSON.stringify(raw.layer)}`);
|
|
106
|
+
const kinds = FAULT_KINDS[layer];
|
|
107
|
+
if (!kinds.includes(raw.kind))
|
|
108
|
+
fail(where, `a ${layer} fault's kind is one of ${kinds.join(', ')}, got ${JSON.stringify(raw.kind)}`);
|
|
109
|
+
const kind = raw.kind;
|
|
110
|
+
const vendorLayer = layer === 'request' || layer === 'over-time';
|
|
111
|
+
const placement = vendorLayer ? ['vendor', 'times', 'until', ...(layer === 'request' ? ['operation', 'on', 'point', 'subject'] : ['operation'])] : ['times', 'until'];
|
|
112
|
+
onlyKeys(raw, ['layer', 'kind', ...placement, ...FAULT_KEYS[kind]], `${where} fault (${layer} ${kind})`);
|
|
113
|
+
if (vendorLayer)
|
|
114
|
+
text(raw.vendor, where, 'fault.vendor');
|
|
115
|
+
if (raw.operation !== undefined)
|
|
116
|
+
text(raw.operation, where, 'fault.operation');
|
|
117
|
+
if (raw.on !== undefined && !isObject(raw.on))
|
|
118
|
+
fail(where, 'fault.on is an object of the pack\'s turn matchers');
|
|
119
|
+
if ([raw.operation, raw.on, raw.point].filter((v) => v !== undefined).length > 1)
|
|
120
|
+
fail(where, 'a fault lands on one of operation, on (a model turn) or point');
|
|
121
|
+
if (raw.subject !== undefined && raw.point === undefined)
|
|
122
|
+
fail(where, 'fault.subject keys a fault at a pack\'s point; name the point');
|
|
123
|
+
if (raw.status !== undefined && !(Number.isInteger(raw.status) && raw.status >= 400 && raw.status <= 599))
|
|
124
|
+
fail(where, 'fault.status is an integer 400..599: a fault is a refusal or a failure, never a success');
|
|
125
|
+
if (kind === 'generation' && raw.on === undefined)
|
|
126
|
+
fail(where, 'a generation failure is a model turn\'s: name its `on`');
|
|
127
|
+
if (kind === 'lost' && raw.on !== undefined)
|
|
128
|
+
fail(where, 'a lost answer lands on an operation: name its `operation`');
|
|
129
|
+
if (kind === 'generation' && (typeof raw.message !== 'string' || typeof raw.errorType !== 'string' || !raw.errorType || raw.status === undefined))
|
|
130
|
+
fail(where, 'a generation fault names its status, message and nonempty errorType');
|
|
131
|
+
for (const name of ['message', 'errorType', 'partialText', 'event', 'target', 'to', 'volume'])
|
|
132
|
+
if (raw[name] !== undefined && typeof raw[name] !== 'string')
|
|
133
|
+
fail(where, `fault.${name} is a string`);
|
|
134
|
+
if (raw.metadata !== undefined && !isObject(raw.metadata))
|
|
135
|
+
fail(where, 'fault.metadata is an object');
|
|
136
|
+
if (raw.ms !== undefined && !(Number.isInteger(raw.ms) && raw.ms >= 0 && raw.ms <= 600_000))
|
|
137
|
+
fail(where, 'fault.ms is an integer 0..600000 (milliseconds)');
|
|
138
|
+
if (raw.retryAfterSeconds !== undefined && !(Number.isInteger(raw.retryAfterSeconds) && raw.retryAfterSeconds >= 0))
|
|
139
|
+
fail(where, 'fault.retryAfterSeconds is a non-negative integer');
|
|
140
|
+
for (const name of ['by', 'per'])
|
|
141
|
+
if (raw[name] !== undefined)
|
|
142
|
+
duration(raw[name], where, `fault.${name}`);
|
|
143
|
+
if (['webhook-delay', 'lag', 'jump'].includes(kind))
|
|
144
|
+
duration(raw.by, where, 'fault.by');
|
|
145
|
+
if (MACHINE_LAYERS.includes(layer) && kind !== 'partition' && raw.target === undefined)
|
|
146
|
+
fail(where, `a ${layer} fault names its target, the part of the app it lands on`);
|
|
147
|
+
if (kind === 'rate-limit') {
|
|
148
|
+
positive(raw.limit, where, 'fault.limit');
|
|
149
|
+
duration(raw.per, where, 'fault.per');
|
|
150
|
+
}
|
|
151
|
+
if (raw.rate !== undefined && !(typeof raw.rate === 'number' && raw.rate > 0 && raw.rate <= 1))
|
|
152
|
+
fail(where, 'fault.rate is a fraction in (0, 1]');
|
|
153
|
+
if (kind === 'partition' && !(Array.isArray(raw.between) && raw.between.length === 2 && raw.between.every((s) => typeof s === 'string' && s)))
|
|
154
|
+
fail(where, 'a partition names its two sides: between [a, b]');
|
|
155
|
+
if (raw.times !== undefined)
|
|
156
|
+
positive(raw.times, where, 'fault.times');
|
|
157
|
+
if (raw.until !== undefined)
|
|
158
|
+
instant(raw.until, where, 'fault.until');
|
|
159
|
+
return raw;
|
|
160
|
+
}
|
|
161
|
+
function parseClock(raw, where) {
|
|
162
|
+
if (!isObject(raw))
|
|
163
|
+
return fail(where, 'clock is { advance: <duration> } or { set: <instant> }');
|
|
164
|
+
onlyKeys(raw, ['advance', 'set'], `${where} clock`);
|
|
165
|
+
if (('advance' in raw) === ('set' in raw))
|
|
166
|
+
fail(where, 'clock is one of { advance } or { set }');
|
|
167
|
+
return 'advance' in raw ? { advance: duration(raw.advance, where, 'clock.advance') } : { set: instant(raw.set, where, 'clock.set') };
|
|
168
|
+
}
|
|
169
|
+
const ENTRY_KINDS = ['call', 'action', 'decision', 'fault', 'clock'];
|
|
170
|
+
/** Strict parse of a scenario document: every refusal names where it is and the vocabulary it expected. */
|
|
171
|
+
export function parseScenario(raw) {
|
|
172
|
+
if (!isObject(raw))
|
|
173
|
+
return fail('document', 'a scenario is an object { start, random?, timeline, properties? }');
|
|
174
|
+
onlyKeys(raw, ['start', 'random', 'timeline', 'properties'], 'document');
|
|
175
|
+
const start = instant(raw.start, 'document', 'start');
|
|
176
|
+
if (!Array.isArray(raw.timeline))
|
|
177
|
+
fail('document', 'timeline is an array of entries');
|
|
178
|
+
const timeline = raw.timeline.map((entry, i) => {
|
|
179
|
+
const where = `entry ${i + 1}`;
|
|
180
|
+
if (!isObject(entry))
|
|
181
|
+
return fail(where, `an entry is { at, ${ENTRY_KINDS.join(' | ')} }`);
|
|
182
|
+
onlyKeys(entry, ['at', ...ENTRY_KINDS], where);
|
|
183
|
+
const kinds = ENTRY_KINDS.filter((k) => k in entry);
|
|
184
|
+
if (kinds.length !== 1)
|
|
185
|
+
fail(where, `an entry is exactly one of ${ENTRY_KINDS.join(', ')}`);
|
|
186
|
+
const at = text(entry.at, where, 'at');
|
|
187
|
+
if (Number.isNaN(Date.parse(at)))
|
|
188
|
+
duration(at, where, 'at');
|
|
189
|
+
const kind = kinds[0];
|
|
190
|
+
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);
|
|
191
|
+
return { at, [kind]: parsed };
|
|
192
|
+
});
|
|
193
|
+
return { start, ...(raw.random !== undefined ? { random: text(raw.random, 'document', 'random') } : {}), timeline, ...(raw.properties !== undefined ? { properties: text(raw.properties, 'document', 'properties') } : {}) };
|
|
194
|
+
}
|
|
195
|
+
/** Every `at` an instant: an instant as written, a duration after `start`. An entry before the one ahead of it, or before
|
|
196
|
+
* `start`, is refused: the clock moves only forward through a timeline. */
|
|
197
|
+
export function resolveScenario(name, doc) {
|
|
198
|
+
const start = Date.parse(doc.start);
|
|
199
|
+
let last = start;
|
|
200
|
+
const timeline = doc.timeline.map((entry, i) => {
|
|
201
|
+
const at = Number.isNaN(Date.parse(entry.at)) || !/^\d{4}-/.test(entry.at) ? start + durationMs(entry.at) : Date.parse(entry.at);
|
|
202
|
+
if (at < last)
|
|
203
|
+
fail(`entry ${i + 1}`, `at ${new Date(at).toISOString()} is before the entry ahead of it (${new Date(last).toISOString()}); a timeline runs forward`);
|
|
204
|
+
last = at;
|
|
205
|
+
return { ...entry, at: new Date(at).toISOString(), index: i + 1 };
|
|
206
|
+
});
|
|
207
|
+
return { name, start: doc.start, random: doc.random ?? name, timeline, ...(doc.properties !== undefined ? { properties: doc.properties } : {}) };
|
|
208
|
+
}
|
|
209
|
+
const kept = new Map();
|
|
210
|
+
/** A JSON file of the World named by an env variable (its scenario, its facts), read through the World's store at each
|
|
211
|
+
* call as the clock is, and parsed again only when it changed; null where the variable or the file is absent. */
|
|
212
|
+
export function worldJsonFile(env) {
|
|
213
|
+
const file = process.env[env];
|
|
214
|
+
if (!file)
|
|
215
|
+
return null;
|
|
216
|
+
const store = getActiveWorldStore();
|
|
217
|
+
const raw = store.readKept ? store.readKept(file) : store.read(file);
|
|
218
|
+
if (raw === null)
|
|
219
|
+
return null;
|
|
220
|
+
const held = kept.get(env);
|
|
221
|
+
if (held?.raw === raw)
|
|
222
|
+
return held.value;
|
|
223
|
+
const value = parseExactJson(raw);
|
|
224
|
+
kept.set(env, { raw, value });
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
/** The scenario this World is playing (the instance's resolved document), or null when it plays none. */
|
|
228
|
+
function activeScenario() {
|
|
229
|
+
return worldJsonFile(WORLD_SCENARIO_ENV);
|
|
230
|
+
}
|
|
231
|
+
/** The decisions and vendor faults the World's scenario places on one vendor, in timeline order. */
|
|
232
|
+
export function placedOn(vendor) {
|
|
233
|
+
return (activeScenario()?.timeline ?? []).filter((e) => ('decision' in e && e.decision.vendor === vendor) || ('fault' in e && e.fault.vendor === vendor));
|
|
234
|
+
}
|
|
235
|
+
/** The decisions placed for a vendor's point at or before the World's now, in timeline order. */
|
|
236
|
+
export function placedDecisions(vendor, point, now = worldNow()) {
|
|
237
|
+
return placedOn(vendor).flatMap((e) => ('decision' in e && e.decision.point === point && e.at <= now ? [{ ...e.decision, index: e.index, at: e.at }] : []));
|
|
238
|
+
}
|
|
239
|
+
/** The vendor faults placed on a vendor at or before the World's now and not past their `until`, in timeline order. */
|
|
240
|
+
export function placedFaults(vendor, layer, now = worldNow()) {
|
|
241
|
+
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 }] : []));
|
|
242
|
+
}
|
package/dist/src/scenario.d.ts
CHANGED
|
@@ -12,8 +12,8 @@ export type PackScenarioAdapter<Req> = {
|
|
|
12
12
|
/** The legal `on` keys and their per-request semantics. Pure. */
|
|
13
13
|
matchers: Record<string, ScenarioMatcher<Req>>;
|
|
14
14
|
/** Validate `on` CONDITION VALUES at load (key membership is the kernel's; VALUE typing is
|
|
15
|
-
* the pack's —
|
|
16
|
-
*
|
|
15
|
+
* the pack's — a mistyped value silently never matching is the misfire strict loading exists
|
|
16
|
+
* to prevent). Return an error string to refuse. */
|
|
17
17
|
validateOn?: (on: Record<string, unknown>) => string | null;
|
|
18
18
|
/** Validate a handler's `respond` payload at load; return an error string to refuse.
|
|
19
19
|
* This is ALSO where a pack refuses success-shaped handlers on stateful routes
|
|
@@ -22,22 +22,10 @@ export type PackScenarioAdapter<Req> = {
|
|
|
22
22
|
/** Whether the request admits a handler's answer (OpenAI's `tool_choice: 'none'` admits no tool call): a handler whose
|
|
23
23
|
* answer it does not admit is passed over, and the next that matches answers, else the stub. Absent, every answer. */
|
|
24
24
|
admits?: (req: Req, respond: unknown) => boolean;
|
|
25
|
-
/** The request's text corpus for `textPattern` extractors (packs with text requests). */
|
|
26
|
-
text?: (req: Req) => string;
|
|
27
|
-
/** Pack-defined extractor KINDS beyond the builtins (feature, textPattern). A pack kind with
|
|
28
|
-
* a builtin's name OVERRIDES the builtin (e.g. a richer textPattern with flags/group).
|
|
29
|
-
* validate returns an error string to refuse the spec at load; extract runs at serve time
|
|
30
|
-
* and throws ScenarioError when the request cannot supply the value. */
|
|
31
|
-
extractorKinds?: Record<string, {
|
|
32
|
-
validate: (spec: Record<string, unknown>, name: string) => string | null;
|
|
33
|
-
extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number;
|
|
34
|
-
}>;
|
|
35
|
-
/** The per-session scope discriminator; omitted → all requests share one "world" scope. */
|
|
36
|
-
scopeKey?: (req: Req) => string;
|
|
37
|
-
/** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
|
|
38
|
-
* vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
|
|
39
25
|
/** Opt-in scope check for post-admission model generation failures; absent rejects them. */
|
|
40
26
|
validateGenerationFault?: (fault: ScenarioGenerationFault, handler: ScenarioHandler) => string | null;
|
|
27
|
+
/** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
|
|
28
|
+
* vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
|
|
41
29
|
renderFault?: (fault: ScenarioStatusFault, req: Req) => {
|
|
42
30
|
body: unknown;
|
|
43
31
|
headers?: Record<string, string>;
|
|
@@ -90,49 +78,21 @@ export type ScenarioFaultResult = {
|
|
|
90
78
|
body: unknown;
|
|
91
79
|
};
|
|
92
80
|
export type ScenarioHandler = {
|
|
93
|
-
/**
|
|
81
|
+
/** The rule's id: `entry-<n>`, the timeline entry it came from. */
|
|
94
82
|
id?: string;
|
|
95
|
-
/** Conditions — ALL must hold. `{}` matches every request
|
|
83
|
+
/** Conditions — ALL must hold. `{}` matches every request. */
|
|
96
84
|
on: Record<string, unknown>;
|
|
97
|
-
/**
|
|
98
|
-
* Optional only on a handler that carries a `fault` (a `slow` may still carry one; a
|
|
99
|
-
* `status` or `drop` serves no content, so it must not). */
|
|
85
|
+
/** The turn's answer, which the pack realizes into the vendor's envelope; absent on a fault. */
|
|
100
86
|
respond?: unknown;
|
|
101
|
-
/**
|
|
102
|
-
* semantics as any handler — a mid-story outage is `{on, fault, once}` or `{phase}`. */
|
|
87
|
+
/** The fault that answers the turn instead (ScenarioFault). */
|
|
103
88
|
fault?: ScenarioFault;
|
|
104
|
-
/** Fire at most once per scope. */
|
|
105
|
-
once?: boolean;
|
|
106
|
-
/** Reserved: "world" (default) | "session" — with "session", once/phase state is per
|
|
107
|
-
* scopeKey instead of shared. */
|
|
108
|
-
scope?: "world" | "session";
|
|
109
|
-
/** Fires only while the scope's phase equals this. Handlers without `phase` fire in any. */
|
|
110
|
-
phase?: string;
|
|
111
|
-
/** On fire, move the scope's phase — the tiny sequencing primitive that replaces
|
|
112
|
-
* linear scripts and nthCall arithmetic. */
|
|
113
|
-
advancePhase?: string;
|
|
114
89
|
};
|
|
115
|
-
/**
|
|
116
|
-
* builtin kinds, or any kind the pack's adapter declares. Payload placeholders support the
|
|
117
|
-
* numeric transforms `{{name|min:N}}` / `{{name|max:N}}`; a whole-string placeholder yields
|
|
118
|
-
* the TYPED value (numbers stay numbers). */
|
|
119
|
-
export type ScenarioExtractorSpec = {
|
|
120
|
-
kind: "feature";
|
|
121
|
-
feature: string;
|
|
122
|
-
} | {
|
|
123
|
-
kind: "textPattern";
|
|
124
|
-
pattern: string;
|
|
125
|
-
as?: "string" | "number";
|
|
126
|
-
} | ({
|
|
127
|
-
kind: string;
|
|
128
|
-
} & Record<string, unknown>);
|
|
90
|
+
/** The ordered rules of one turn. */
|
|
129
91
|
export type ScenarioDocument = {
|
|
130
|
-
extractors?: Record<string, ScenarioExtractorSpec>;
|
|
131
92
|
handlers: ScenarioHandler[];
|
|
132
93
|
};
|
|
133
94
|
export type ScenarioMissRecord = {
|
|
134
95
|
features: ScenarioFeatures;
|
|
135
|
-
phase: string | undefined;
|
|
136
96
|
};
|
|
137
97
|
export type ScenarioDecision = {
|
|
138
98
|
kind: "handler";
|
|
@@ -144,54 +104,17 @@ export type ScenarioDecision = {
|
|
|
144
104
|
kind: "miss";
|
|
145
105
|
miss: ScenarioMissRecord;
|
|
146
106
|
};
|
|
147
|
-
export type ScenarioStatus = {
|
|
148
|
-
vendor: string;
|
|
149
|
-
handlers: Array<{
|
|
150
|
-
id: string;
|
|
151
|
-
phase?: string;
|
|
152
|
-
once?: boolean;
|
|
153
|
-
scope?: string;
|
|
154
|
-
matches: number;
|
|
155
|
-
source: "file" | "use";
|
|
156
|
-
}>;
|
|
157
|
-
misses: number;
|
|
158
|
-
recentMisses: ScenarioMissRecord[];
|
|
159
|
-
};
|
|
160
107
|
export declare class ScenarioError extends Error {
|
|
161
108
|
}
|
|
162
|
-
/** Strict-loud parse of a
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
/** The engine: pure state machine over registered handlers. One instance per twin server. */
|
|
109
|
+
/** Strict-loud parse of a turn's rules. Every refusal names the valid vocabulary. */
|
|
110
|
+
export declare function parseScenarioDocument<Req>(raw: ScenarioDocument, adapter: PackScenarioAdapter<Req>): ScenarioDocument;
|
|
111
|
+
/** The engine over one turn's rules: pure, the same rules and request giving the same decision. */
|
|
166
112
|
export declare class ScenarioEngine<Req> {
|
|
167
113
|
private readonly adapter;
|
|
168
|
-
private readonly
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
private scopes;
|
|
172
|
-
private missCount;
|
|
173
|
-
private recentMisses;
|
|
174
|
-
constructor(adapter: PackScenarioAdapter<Req>, document?: ScenarioDocument);
|
|
175
|
-
/** In-process test overrides: LIFO over the baseline; returns a remover. NOT a runtime
|
|
176
|
-
* door — nothing outside this process can reach it, by design. */
|
|
177
|
-
use(...handlers: ScenarioHandler[]): () => void;
|
|
178
|
-
/** Decide one request. Mutates scope state (calls, once, phase) exactly like serving. */
|
|
114
|
+
private readonly document;
|
|
115
|
+
constructor(adapter: PackScenarioAdapter<Req>, document: ScenarioDocument);
|
|
116
|
+
/** Decide one request: the first rule that matches and whose answer the request admits, else the miss. */
|
|
179
117
|
next(req: Req): ScenarioDecision;
|
|
180
|
-
/** Decide one request AND honor its fault: a `slow` is awaited here and the decision is
|
|
181
|
-
* returned for the pack to serve; a `status` returns the rendered refusal to serve as-is; a
|
|
182
|
-
* `drop` never resolves within its hold. The one call a realizer makes so every vendor's
|
|
183
|
-
* outage reads the same. */
|
|
184
|
-
serve(req: Req, signal?: AbortSignal): Promise<ScenarioDecision | {
|
|
185
|
-
kind: "fault";
|
|
186
|
-
result: ScenarioFaultResult;
|
|
187
|
-
ruleId: string;
|
|
188
|
-
}>;
|
|
189
|
-
/** For GET /twin/scenario — active handlers with match counts, and the recent misses. */
|
|
190
|
-
status(): ScenarioStatus;
|
|
191
|
-
private scope;
|
|
192
|
-
private matches;
|
|
193
|
-
private extractValue;
|
|
194
|
-
private substitute;
|
|
195
118
|
}
|
|
196
119
|
export declare function scenarioFaultResult(fault: ScenarioFault, render?: () => {
|
|
197
120
|
body: unknown;
|
|
@@ -218,5 +141,4 @@ export declare function twinManifest(input: {
|
|
|
218
141
|
stateSentence: string;
|
|
219
142
|
behaviorSentence: string;
|
|
220
143
|
exampleHandler: ScenarioHandler | null;
|
|
221
|
-
engine?: ScenarioEngine<never>;
|
|
222
144
|
}): Record<string, unknown>;
|