@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
package/dist/src/scenario.js
CHANGED
|
@@ -1,355 +1,68 @@
|
|
|
1
|
-
|
|
1
|
+
// A model vendor's turn, matched with the pack's own vocabulary (architecture, "Scenarios"; ADR 0036). The World's
|
|
2
|
+
// scenario places turn decisions and faults on a timeline (scenario-timeline.ts); scenario-serve.ts hands the ones not
|
|
3
|
+
// used up to this engine as ordered rules `{ on, respond | fault }`, and the first whose `on` conditions ALL hold, and
|
|
4
|
+
// whose answer the request admits, decides the turn. No match is the miss: the pack's labelled deterministic stub.
|
|
5
|
+
//
|
|
6
|
+
// The GRAMMAR (strict validation, ordering, the fault vocabulary) is this module's and the same for every vendor; the
|
|
7
|
+
// VOCABULARY (which `on` keys exist and how each matches, what `respond` may contain, which routes are stateful and so
|
|
8
|
+
// refuse success-shaped answers) is the pack's, declared through a PackScenarioAdapter. A typo fails loudly at load,
|
|
9
|
+
// naming the valid vocabulary, never silently mis-matches at serve.
|
|
2
10
|
export class ScenarioError extends Error {
|
|
3
11
|
}
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const HANDLER_KEYS = new Set(["id", "on", "respond", "fault", "once", "scope", "phase", "advancePhase", "$comment"]);
|
|
7
|
-
const DOCUMENT_KEYS = new Set(["extractors", "handlers", "$comment"]);
|
|
8
|
-
/** Strict-loud parse of a scenario DOCUMENT (the per-vendor handlers/<vendor>.json content).
|
|
9
|
-
* The caller does file IO; this validates. Every refusal names the valid vocabulary. */
|
|
12
|
+
const HANDLER_KEYS = new Set(["id", "on", "respond", "fault"]);
|
|
13
|
+
/** Strict-loud parse of a turn's rules. Every refusal names the valid vocabulary. */
|
|
10
14
|
export function parseScenarioDocument(raw, adapter) {
|
|
11
|
-
|
|
12
|
-
throw new ScenarioError(`${adapter.vendor} scenario: the document is an object { extractors?, handlers }`);
|
|
13
|
-
const doc = raw;
|
|
14
|
-
for (const key of Object.keys(doc)) {
|
|
15
|
-
if (!DOCUMENT_KEYS.has(key))
|
|
16
|
-
throw new ScenarioError(`${adapter.vendor} scenario: unknown key "${key}" (valid: ${[...DOCUMENT_KEYS].join(", ")})`);
|
|
17
|
-
}
|
|
18
|
-
const extractors = {};
|
|
19
|
-
if (doc.extractors !== undefined) {
|
|
20
|
-
if (typeof doc.extractors !== "object" || doc.extractors === null || Array.isArray(doc.extractors))
|
|
21
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractors is an object of named specs`);
|
|
22
|
-
for (const [name, spec] of Object.entries(doc.extractors)) {
|
|
23
|
-
extractors[name] = parseExtractor(name, spec, adapter);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
if (!Array.isArray(doc.handlers))
|
|
27
|
-
throw new ScenarioError(`${adapter.vendor} scenario: handlers is an array`);
|
|
28
|
-
const handlers = doc.handlers.map((h, i) => parseHandler(h, i, adapter, extractors));
|
|
29
|
-
return { ...(doc.extractors !== undefined ? { extractors } : {}), handlers };
|
|
15
|
+
return { handlers: raw.handlers.map((h, i) => parseHandler(h, i, adapter)) };
|
|
30
16
|
}
|
|
31
|
-
function
|
|
32
|
-
if (typeof raw !== "object" || raw === null)
|
|
33
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" is an object`);
|
|
34
|
-
const spec = raw;
|
|
35
|
-
const packKind = typeof spec.kind === "string" ? adapter.extractorKinds?.[spec.kind] : undefined;
|
|
36
|
-
if (packKind !== undefined) {
|
|
37
|
-
const refusal = packKind.validate(spec, name);
|
|
38
|
-
if (refusal)
|
|
39
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": ${refusal}`);
|
|
40
|
-
return spec;
|
|
41
|
-
}
|
|
42
|
-
if (spec.kind === "feature") {
|
|
43
|
-
if (typeof spec.feature !== "string" || spec.feature.length === 0)
|
|
44
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (feature) needs a feature name`);
|
|
45
|
-
for (const key of Object.keys(spec))
|
|
46
|
-
if (key !== "kind" && key !== "feature")
|
|
47
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
|
|
48
|
-
return { kind: "feature", feature: spec.feature };
|
|
49
|
-
}
|
|
50
|
-
if (spec.kind === "textPattern") {
|
|
51
|
-
if (adapter.text === undefined)
|
|
52
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" uses textPattern but this pack exposes no request text`);
|
|
53
|
-
if (typeof spec.pattern !== "string")
|
|
54
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (textPattern) needs a pattern`);
|
|
55
|
-
try {
|
|
56
|
-
new RegExp(spec.pattern);
|
|
57
|
-
}
|
|
58
|
-
catch (e) {
|
|
59
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": invalid pattern: ${e instanceof Error ? e.message : String(e)}`);
|
|
60
|
-
}
|
|
61
|
-
if (spec.as !== undefined && spec.as !== "string" && spec.as !== "number")
|
|
62
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": as is "string" or "number"`);
|
|
63
|
-
for (const key of Object.keys(spec))
|
|
64
|
-
if (!["kind", "pattern", "as"].includes(key))
|
|
65
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
|
|
66
|
-
return { kind: "textPattern", pattern: spec.pattern, ...(spec.as !== undefined ? { as: spec.as } : {}) };
|
|
67
|
-
}
|
|
68
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": kind is one of ${[...new Set(["feature", "textPattern", ...Object.keys(adapter.extractorKinds ?? {})])].join(", ")}`);
|
|
69
|
-
}
|
|
70
|
-
function parseHandler(raw, index, adapter, extractors) {
|
|
71
|
-
const at = `handler ${index + 1}`;
|
|
72
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
73
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at} is an object { on, respond, ... }`);
|
|
17
|
+
function parseHandler(raw, index, adapter) {
|
|
74
18
|
const h = raw;
|
|
19
|
+
const at = typeof h.id === "string" ? h.id : `rule ${index + 1}`;
|
|
75
20
|
for (const key of Object.keys(h)) {
|
|
76
21
|
if (!HANDLER_KEYS.has(key))
|
|
77
22
|
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown key "${key}" (valid: ${[...HANDLER_KEYS].join(", ")})`);
|
|
78
23
|
}
|
|
79
24
|
if (typeof h.on !== "object" || h.on === null || Array.isArray(h.on))
|
|
80
25
|
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`on\` is an object of conditions ({} matches all)`);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const validOn = ["nthCall", ...Object.keys(adapter.matchers)];
|
|
84
|
-
for (const [key, value] of Object.entries(h.on)) {
|
|
26
|
+
const validOn = Object.keys(adapter.matchers);
|
|
27
|
+
for (const key of Object.keys(h.on)) {
|
|
85
28
|
if (!validOn.includes(key))
|
|
86
29
|
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown \`on\` key "${key}" (valid here: ${validOn.join(", ")})`);
|
|
87
|
-
if (key === "nthCall" && (!Number.isInteger(value) || value < 1))
|
|
88
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: nthCall is a positive integer`);
|
|
89
30
|
}
|
|
90
31
|
const onRefusal = adapter.validateOn?.(h.on);
|
|
91
32
|
if (onRefusal)
|
|
92
33
|
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${onRefusal}`);
|
|
93
|
-
if (h.fault !== undefined) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.kind is one of ${kinds.join(", ")}, got ${JSON.stringify(f.kind)}`);
|
|
100
|
-
const keysFor = { slow: ["kind", "ms"], status: ["kind", "status", "retryAfterSeconds", "message", "metadata", "errorType"], generation: ["kind", "status", "message", "errorType", "metadata", "partialText", "event"], drop: ["kind", "holdMs"] };
|
|
101
|
-
for (const key of Object.keys(f))
|
|
102
|
-
if (!keysFor[f.kind].includes(key))
|
|
103
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is not a key of a ${String(f.kind)} fault (valid: ${keysFor[f.kind].join(", ")})`);
|
|
104
|
-
if (f.kind === "slow" && !(Number.isInteger(f.ms) && f.ms >= 0 && f.ms <= 600_000))
|
|
105
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.ms is an integer 0..600000 (milliseconds)`);
|
|
106
|
-
if ((f.kind === "status" || f.kind === "generation") && !(Number.isInteger(f.status) && f.status >= 400 && f.status <= 599))
|
|
107
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.status is an integer 400..599 — a fault is a refusal or a failure, never a success`);
|
|
108
|
-
if (f.kind === "status" && f.retryAfterSeconds !== undefined && !(Number.isInteger(f.retryAfterSeconds) && f.retryAfterSeconds >= 0))
|
|
109
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.retryAfterSeconds is a non-negative integer`);
|
|
110
|
-
if (f.kind === "status" && f.message !== undefined && typeof f.message !== "string")
|
|
111
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.message is a string`);
|
|
112
|
-
if ((f.kind === "status" || f.kind === "generation") && f.metadata !== undefined && (typeof f.metadata !== "object" || f.metadata === null || Array.isArray(f.metadata)))
|
|
113
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.metadata is an object`);
|
|
114
|
-
if (f.kind === "status" && f.errorType !== undefined && typeof f.errorType !== "string")
|
|
115
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.errorType is a string`);
|
|
116
|
-
if (f.kind === "generation") {
|
|
117
|
-
if (typeof f.message !== "string" || typeof f.errorType !== "string" || !f.errorType)
|
|
118
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation fault requires message and nonempty errorType`);
|
|
119
|
-
for (const key of ["partialText", "event"])
|
|
120
|
-
if (f[key] !== undefined && typeof f[key] !== "string")
|
|
121
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is a string`);
|
|
122
|
-
if (!adapter.validateGenerationFault)
|
|
123
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation faults require a compute-only adapter`);
|
|
124
|
-
const refusal = adapter.validateGenerationFault(f, h);
|
|
125
|
-
if (refusal)
|
|
126
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
|
|
127
|
-
}
|
|
128
|
-
if (f.kind === "drop" && f.holdMs !== undefined && !(Number.isInteger(f.holdMs) && f.holdMs >= 0))
|
|
129
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.holdMs is a non-negative integer`);
|
|
130
|
-
if (f.kind !== "slow" && "respond" in h)
|
|
131
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: a ${String(f.kind)} fault serves no content — drop \`respond\` (only a slow fault may carry one)`);
|
|
132
|
-
}
|
|
133
|
-
if (!("respond" in h) && h.fault === undefined)
|
|
134
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`respond\` is required (or a \`fault\`)`);
|
|
135
|
-
if (h.id !== undefined && (typeof h.id !== "string" || h.id.length === 0))
|
|
136
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: id is a non-empty string`);
|
|
137
|
-
if (h.once !== undefined && typeof h.once !== "boolean")
|
|
138
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: once is boolean`);
|
|
139
|
-
if (h.scope !== undefined && h.scope !== "world" && h.scope !== "session")
|
|
140
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope is "world" or "session"`);
|
|
141
|
-
if (h.scope === "session" && adapter.scopeKey === undefined)
|
|
142
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope "session" but this pack derives no session key`);
|
|
143
|
-
for (const key of ["phase", "advancePhase"]) {
|
|
144
|
-
if (h[key] !== undefined && (typeof h[key] !== "string" || h[key].length === 0))
|
|
145
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${key} is a non-empty string`);
|
|
146
|
-
}
|
|
147
|
-
for (const name of "respond" in h ? placeholderNames(h.respond) : []) {
|
|
148
|
-
if (extractors[name] === undefined)
|
|
149
|
-
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: respond references "{{${name}}}" but no extractor "${name}" is declared`);
|
|
34
|
+
if (h.fault !== undefined && h.fault.kind === "generation") {
|
|
35
|
+
if (!adapter.validateGenerationFault)
|
|
36
|
+
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation faults require a compute-only adapter`);
|
|
37
|
+
const refusal = adapter.validateGenerationFault(h.fault, h);
|
|
38
|
+
if (refusal)
|
|
39
|
+
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
|
|
150
40
|
}
|
|
151
41
|
const refusal = "respond" in h ? adapter.validateRespond?.(h.respond, h) : null;
|
|
152
42
|
if (refusal)
|
|
153
43
|
throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
|
|
154
44
|
return h;
|
|
155
45
|
}
|
|
156
|
-
|
|
157
|
-
function placeholderNames(value, out = new Set()) {
|
|
158
|
-
if (typeof value === "string") {
|
|
159
|
-
for (const m of value.matchAll(new RegExp(PLACEHOLDER_SRC, "g")))
|
|
160
|
-
out.add(m[1]);
|
|
161
|
-
// A lone unparseable {{...}} is almost certainly a typo'd placeholder — fail loudly.
|
|
162
|
-
const braces = /\{\{[^}]*\}\}/g.exec(value);
|
|
163
|
-
if (braces && !new RegExp(`^${PLACEHOLDER_SRC}$`).test(braces[0])) {
|
|
164
|
-
throw new ScenarioError(`malformed placeholder ${JSON.stringify(braces[0])} (expected {{name}} or {{name|min:N}}/{{name|max:N}})`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
else if (Array.isArray(value)) {
|
|
168
|
-
for (const v of value)
|
|
169
|
-
placeholderNames(v, out);
|
|
170
|
-
}
|
|
171
|
-
else if (typeof value === "object" && value !== null) {
|
|
172
|
-
for (const v of Object.values(value))
|
|
173
|
-
placeholderNames(v, out);
|
|
174
|
-
}
|
|
175
|
-
return out;
|
|
176
|
-
}
|
|
177
|
-
function adapterKind(adapter, kind) {
|
|
178
|
-
return adapter.extractorKinds?.[kind];
|
|
179
|
-
}
|
|
180
|
-
/** `{{name|min:N}}` / `{{name|max:N}}` — numeric clamps on an extracted value. */
|
|
181
|
-
function applyTransform(value, transform, vendor, where) {
|
|
182
|
-
if (!transform)
|
|
183
|
-
return value;
|
|
184
|
-
const [op, rawN] = transform.slice(1).split(":");
|
|
185
|
-
if (typeof value !== "number" && !(typeof value === "string" && isExactIntegerValue(value)))
|
|
186
|
-
throw new ScenarioError(`${vendor} scenario: ${where} applies |${op}:${rawN} to a non-numeric extractor value ${JSON.stringify(value)}`);
|
|
187
|
-
const n = /^-?\d+$/.test(rawN) ? exactInteger(rawN) : Number(rawN);
|
|
188
|
-
const cmp = compareExactNumbers(value, n);
|
|
189
|
-
return op === "min" ? (cmp <= 0 ? value : n) : (cmp >= 0 ? value : n);
|
|
190
|
-
}
|
|
191
|
-
const MISS_KEEP = 20;
|
|
192
|
-
/** The engine: pure state machine over registered handlers. One instance per twin server. */
|
|
46
|
+
/** The engine over one turn's rules: pure, the same rules and request giving the same decision. */
|
|
193
47
|
export class ScenarioEngine {
|
|
194
48
|
adapter;
|
|
195
|
-
|
|
196
|
-
baseline = [];
|
|
197
|
-
overrides = [];
|
|
198
|
-
scopes = new Map();
|
|
199
|
-
missCount = 0;
|
|
200
|
-
recentMisses = [];
|
|
49
|
+
document;
|
|
201
50
|
constructor(adapter, document) {
|
|
202
51
|
this.adapter = adapter;
|
|
203
|
-
this.
|
|
204
|
-
if (document) {
|
|
205
|
-
this.baseline = document.handlers.map((handler, i) => ({ handler, id: handler.id ?? `handler-${i + 1}`, source: "file", matches: 0 }));
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
/** In-process test overrides: LIFO over the baseline; returns a remover. NOT a runtime
|
|
209
|
-
* door — nothing outside this process can reach it, by design. */
|
|
210
|
-
use(...handlers) {
|
|
211
|
-
const parsed = handlers.map((h, i) => parseHandler(h, i, this.adapter, this.extractors));
|
|
212
|
-
const registered = parsed.map((handler, i) => ({ handler, id: handler.id ?? `use-${this.overrides.length + i + 1}`, source: "use", matches: 0 }));
|
|
213
|
-
this.overrides = [...registered, ...this.overrides]; // LIFO: newest first
|
|
214
|
-
return () => { this.overrides = this.overrides.filter((r) => !registered.includes(r)); };
|
|
52
|
+
this.document = document;
|
|
215
53
|
}
|
|
216
|
-
/** Decide one request
|
|
54
|
+
/** Decide one request: the first rule that matches and whose answer the request admits, else the miss. */
|
|
217
55
|
next(req) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// one exists) — before matching, so nthCall-style matchers see 1-based "this call".
|
|
221
|
-
this.scope("world").calls += 1;
|
|
222
|
-
if (this.adapter.scopeKey)
|
|
223
|
-
this.scope(`session:${this.adapter.scopeKey(req)}`).calls += 1;
|
|
224
|
-
for (const r of [...this.overrides, ...this.baseline]) {
|
|
225
|
-
const scope = this.scope(scopeOf(r.handler));
|
|
226
|
-
if (r.handler.phase !== undefined && scope.phase !== r.handler.phase)
|
|
227
|
-
continue;
|
|
228
|
-
if (r.handler.once && scope.onceFired.has(r.id))
|
|
56
|
+
for (const [i, handler] of this.document.handlers.entries()) {
|
|
57
|
+
if (!Object.entries(handler.on).every(([key, condition]) => this.adapter.matchers[key](req, condition)))
|
|
229
58
|
continue;
|
|
230
|
-
if (!this.
|
|
59
|
+
if ("respond" in handler && this.adapter.admits && !this.adapter.admits(req, handler.respond))
|
|
231
60
|
continue;
|
|
232
|
-
|
|
233
|
-
continue;
|
|
234
|
-
if (r.handler.once)
|
|
235
|
-
scope.onceFired.add(r.id);
|
|
236
|
-
if (r.handler.advancePhase !== undefined)
|
|
237
|
-
scope.phase = r.handler.advancePhase;
|
|
238
|
-
r.matches += 1;
|
|
239
|
-
// `ruleId` is the row's STABLE id — the authored `id` or the parser's `handler-<n>`
|
|
240
|
-
// default — the same name the status door reports, so a pack stamping the fired rule
|
|
241
|
-
// (deepgram's `scenario_rule`) and the operator reading /twin/scenario see one vocabulary.
|
|
242
|
-
try {
|
|
243
|
-
return { kind: "handler", handler: r.handler, respond: "respond" in r.handler ? this.substitute(r.handler.respond, req) : undefined, ruleId: r.id, ...(r.handler.fault !== undefined ? { fault: r.handler.fault } : {}) };
|
|
244
|
-
}
|
|
245
|
-
catch (e) {
|
|
246
|
-
// The handler that demanded the value is named: an authoring fault is fixed by finding it.
|
|
247
|
-
if (e instanceof ScenarioError)
|
|
248
|
-
throw new ScenarioError(`${e.message} [${r.id}${typeof r.handler.$comment === "string" ? `: ${String(r.handler.$comment).slice(0, 80)}` : ""}]`);
|
|
249
|
-
throw e;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
const miss = { features: this.adapter.features(req), phase: this.scope("world").phase };
|
|
253
|
-
this.missCount += 1;
|
|
254
|
-
this.recentMisses = [...this.recentMisses.slice(-(MISS_KEEP - 1)), miss];
|
|
255
|
-
return { kind: "miss", miss };
|
|
256
|
-
}
|
|
257
|
-
/** Decide one request AND honor its fault: a `slow` is awaited here and the decision is
|
|
258
|
-
* returned for the pack to serve; a `status` returns the rendered refusal to serve as-is; a
|
|
259
|
-
* `drop` never resolves within its hold. The one call a realizer makes so every vendor's
|
|
260
|
-
* outage reads the same. */
|
|
261
|
-
async serve(req, signal) {
|
|
262
|
-
throwIfScenarioAborted(signal);
|
|
263
|
-
const decision = this.next(req);
|
|
264
|
-
throwIfScenarioAborted(signal);
|
|
265
|
-
if (decision.kind !== "handler" || decision.fault === undefined)
|
|
266
|
-
return decision;
|
|
267
|
-
const result = await scenarioFaultResult(decision.fault, () => this.adapter.renderFault?.(decision.fault, req), signal);
|
|
268
|
-
throwIfScenarioAborted(signal);
|
|
269
|
-
return result === null ? decision : { kind: "fault", result, ruleId: decision.ruleId };
|
|
270
|
-
}
|
|
271
|
-
/** For GET /twin/scenario — active handlers with match counts, and the recent misses. */
|
|
272
|
-
status() {
|
|
273
|
-
const row = (r) => ({ id: r.id, ...(r.handler.phase !== undefined ? { phase: r.handler.phase } : {}), ...(r.handler.once !== undefined ? { once: r.handler.once } : {}), ...(r.handler.scope !== undefined ? { scope: r.handler.scope } : {}), matches: r.matches, source: r.source });
|
|
274
|
-
return { vendor: this.adapter.vendor, handlers: [...this.overrides, ...this.baseline].map(row), misses: this.missCount, recentMisses: [...this.recentMisses] };
|
|
275
|
-
}
|
|
276
|
-
scope(key) {
|
|
277
|
-
let s = this.scopes.get(key);
|
|
278
|
-
if (!s) {
|
|
279
|
-
s = { calls: 0, phase: undefined, onceFired: new Set() };
|
|
280
|
-
this.scopes.set(key, s);
|
|
281
|
-
}
|
|
282
|
-
return s;
|
|
283
|
-
}
|
|
284
|
-
matches(handler, req, scope) {
|
|
285
|
-
for (const [key, condition] of Object.entries(handler.on)) {
|
|
286
|
-
if (key === "nthCall") {
|
|
287
|
-
if (scope.calls !== condition)
|
|
288
|
-
return false;
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
const matcher = this.adapter.matchers[key];
|
|
292
|
-
if (!matcher)
|
|
293
|
-
return false; // unreachable post-parse; belt over braces
|
|
294
|
-
if (!matcher(req, condition))
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
return true;
|
|
298
|
-
}
|
|
299
|
-
extractValue(name, req, cache) {
|
|
300
|
-
const cached = cache.get(name);
|
|
301
|
-
if (cached !== undefined)
|
|
302
|
-
return cached;
|
|
303
|
-
const spec = this.extractors[name];
|
|
304
|
-
const packKind = adapterKind(this.adapter, spec.kind);
|
|
305
|
-
let value;
|
|
306
|
-
if (packKind !== undefined) {
|
|
307
|
-
value = packKind.extract(spec, req, name);
|
|
308
|
-
}
|
|
309
|
-
else if (spec.kind === "feature") {
|
|
310
|
-
const v = this.adapter.features(req)[spec.feature];
|
|
311
|
-
if (v === undefined)
|
|
312
|
-
throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": the request has no feature "${spec.feature}"`);
|
|
313
|
-
value = typeof v === "number" || typeof v === "string" ? v : String(v);
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
const text = this.adapter.text(req);
|
|
317
|
-
const tp = spec;
|
|
318
|
-
const m = new RegExp(tp.pattern).exec(text);
|
|
319
|
-
if (!m)
|
|
320
|
-
throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": pattern did not match the request (the handler demanded a value the request never stated)`);
|
|
321
|
-
const captured = m[1] ?? m[0];
|
|
322
|
-
if (tp.as === "number") {
|
|
323
|
-
const n = /^-?\d+$/.test(captured) ? exactInteger(captured) : Number(captured);
|
|
324
|
-
if (Number.isNaN(n))
|
|
325
|
-
throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": captured "${captured}" is not a number`);
|
|
326
|
-
value = n;
|
|
327
|
-
}
|
|
328
|
-
else
|
|
329
|
-
value = captured;
|
|
330
|
-
}
|
|
331
|
-
cache.set(name, value);
|
|
332
|
-
return value;
|
|
333
|
-
}
|
|
334
|
-
substitute(value, req, cache = new Map()) {
|
|
335
|
-
if (typeof value === "string") {
|
|
336
|
-
// A WHOLE-string placeholder yields the typed value (numbers stay numbers).
|
|
337
|
-
const whole = new RegExp(`^${PLACEHOLDER_SRC}$`).exec(value);
|
|
338
|
-
if (whole)
|
|
339
|
-
return applyTransform(this.extractValue(whole[1], req, cache), whole[2], this.adapter.vendor, value);
|
|
340
|
-
return value.replace(new RegExp(PLACEHOLDER_SRC, "g"), (all, name, transform) => String(applyTransform(this.extractValue(name, req, cache), transform, this.adapter.vendor, all)));
|
|
341
|
-
}
|
|
342
|
-
if (Array.isArray(value))
|
|
343
|
-
return value.map((v) => this.substitute(v, req, cache));
|
|
344
|
-
if (typeof value === "object" && value !== null) {
|
|
345
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.substitute(v, req, cache)]));
|
|
61
|
+
return { kind: "handler", handler, respond: handler.respond, ruleId: handler.id ?? `rule-${i + 1}`, ...(handler.fault !== undefined ? { fault: handler.fault } : {}) };
|
|
346
62
|
}
|
|
347
|
-
return
|
|
63
|
+
return { kind: "miss", miss: { features: this.adapter.features(req) } };
|
|
348
64
|
}
|
|
349
65
|
}
|
|
350
|
-
/** The GET /twin manifest for a STATEFUL twin (no scenario engine): what it stores, how
|
|
351
|
-
* state and identity work, where behavior scripting actually lives. Education ships
|
|
352
|
-
* INSIDE the twin — in-world agents have no repos or skills, only HTTP. */
|
|
353
66
|
/** Honor a fault outside the engine (hand-written serve paths): `slow` waits and returns null
|
|
354
67
|
* (serve normally now); `status` returns what to serve; `drop` holds the request for `holdMs`
|
|
355
68
|
* (default 300 000) and then throws, so the socket is released and nothing is ever answered. */
|
|
@@ -414,7 +127,6 @@ export function statefulTwinManifest(input) {
|
|
|
414
127
|
/** The GET /twin manifest — the discovery door's body. Education ships INSIDE the twin:
|
|
415
128
|
* in-world agents have no repos or skills, only HTTP. */
|
|
416
129
|
export function twinManifest(input) {
|
|
417
|
-
const status = input.engine?.status();
|
|
418
130
|
return {
|
|
419
131
|
twin: true,
|
|
420
132
|
vendor: input.vendor,
|
|
@@ -422,10 +134,9 @@ export function twinManifest(input) {
|
|
|
422
134
|
program: {
|
|
423
135
|
state: input.stateSentence,
|
|
424
136
|
behavior: input.behaviorSentence,
|
|
425
|
-
invariant: "
|
|
426
|
-
...(input.exampleHandler ? { exampleHandler: input.exampleHandler } : {}),
|
|
137
|
+
invariant: "Nothing answers in place of this twin: a World's scenario places turn decisions (point \"turn\") on its timeline, and the twin answers and records them; stored data is seeded through the vendor's own API. Serving is deterministic.",
|
|
138
|
+
...(input.exampleHandler ? { exampleDecision: { at: "0s", decision: { vendor: input.vendor, point: "turn", on: input.exampleHandler.on, outcome: input.exampleHandler.respond } } } : {}),
|
|
427
139
|
},
|
|
428
|
-
doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only:
|
|
429
|
-
...(status ? { activeHandlers: status.handlers.length, misses: status.misses } : {}),
|
|
140
|
+
doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only: what the World's scenario placed on this twin, and what it consumed)" },
|
|
430
141
|
};
|
|
431
142
|
}
|
package/dist/src/sockets.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EventWrite } from './events.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { ScenarioDecision, ScenarioFaultResult } from './scenario.js';
|
|
3
3
|
import type { WebSocketUpgrade } from './serve-http.js';
|
|
4
4
|
/** A socket the vendor serves: its path (and host, when the vendor serves it on a host of its own), as the manifest
|
|
5
5
|
* declares it. */
|
|
@@ -30,8 +30,12 @@ export type SocketSession = {
|
|
|
30
30
|
* scenario or the World carries none. A fault decision is the engine's to answer in its own frames. */
|
|
31
31
|
decide(asked: unknown): Promise<ScenarioServed | undefined>;
|
|
32
32
|
};
|
|
33
|
-
/** What the World's scenario serves a turn:
|
|
34
|
-
export type ScenarioServed =
|
|
33
|
+
/** What the World's scenario serves a turn (scenario-serve.ts): its decision, the miss, or a fault's answer. */
|
|
34
|
+
export type ScenarioServed = ScenarioDecision | {
|
|
35
|
+
kind: 'fault';
|
|
36
|
+
result: ScenarioFaultResult;
|
|
37
|
+
ruleId: string;
|
|
38
|
+
};
|
|
35
39
|
/** A socket's protocol, over the handler contract's context (a read of the World as the request that opened it). */
|
|
36
40
|
export type SocketEngine<C> = {
|
|
37
41
|
open?(session: SocketSession, ctx: C): void | Promise<void>;
|
|
@@ -51,4 +55,4 @@ export declare function expireSocketWaits(service: string, root: string | undefi
|
|
|
51
55
|
* awaited: the write may be one a session's own frame made, which that session finishes before it is told. */
|
|
52
56
|
export declare function socketWrite(service: string, root: string | undefined, write: EventWrite): void;
|
|
53
57
|
/** The upgrade the serve seam takes: a request to a declared socket's path (and host) opens a session of its engine. */
|
|
54
|
-
export declare function socketUpgrade<C>(service: string, root: string | undefined, decls: ReadonlyArray<SocketDecl>, engines: Record<string, SocketEngine<C>>, context: (request: Request, decl: SocketDecl, params: Record<string, string>) => Promise<C>, hostOf: (request: Request) => string, pathOf: (request: Request) => string, decide: (asked: unknown) => Promise<ScenarioServed | undefined>): WebSocketUpgrade;
|
|
58
|
+
export declare function socketUpgrade<C>(service: string, root: string | undefined, decls: ReadonlyArray<SocketDecl>, engines: Record<string, SocketEngine<C>>, context: (request: Request, decl: SocketDecl, params: Record<string, string>) => Promise<C>, hostOf: (request: Request) => string, pathOf: (request: Request) => string, decide: (asked: unknown, context: () => Promise<C>) => Promise<ScenarioServed | undefined>): WebSocketUpgrade;
|
package/dist/src/sockets.js
CHANGED
|
@@ -114,7 +114,7 @@ export function socketUpgrade(service, root, decls, engines, context, hostOf, pa
|
|
|
114
114
|
p.resolve(value);
|
|
115
115
|
return true;
|
|
116
116
|
},
|
|
117
|
-
decide,
|
|
117
|
+
decide: (asked) => decide(asked, () => o.context()),
|
|
118
118
|
};
|
|
119
119
|
const tenant = storageTenant(service), run = (fn) => runWithStorageTenant(service, tenant, fn);
|
|
120
120
|
const o = { tenant, run, session, engine, context: () => context(request.clone(), decl, params), queue: Promise.resolve(), pending };
|
|
@@ -2,3 +2,6 @@ export declare const WORLD_CLOCK_ENV = "TWIN_WORLD_CLOCK_FILE";
|
|
|
2
2
|
/** The world's "now": the clock file's time when one is configured (its frozen instant, or a running clock's time
|
|
3
3
|
* now); real wall-clock time otherwise. */
|
|
4
4
|
export declare function worldNow(): string;
|
|
5
|
+
/** Whether the World's time runs on by itself (a running clock, or no clock: the machine's), rather than standing frozen
|
|
6
|
+
* until it is moved. */
|
|
7
|
+
export declare function worldClockRunning(): boolean;
|
package/dist/src/world-clock.js
CHANGED
|
@@ -22,16 +22,21 @@ const machineNow = () => {
|
|
|
22
22
|
/** The world's "now": the clock file's time when one is configured (its frozen instant, or a running clock's time
|
|
23
23
|
* now); real wall-clock time otherwise. */
|
|
24
24
|
export function worldNow() {
|
|
25
|
+
const clock = heldClock();
|
|
26
|
+
return new Date(clock ? clockForm.clockNowMs(clock, machineNow()) : machineNow()).toISOString();
|
|
27
|
+
}
|
|
28
|
+
/** The World's clock as its file holds it, or null where it has none (the machine's time). One read answers both
|
|
29
|
+
* whether the clock file is there and what it holds, kept while the file's own facts stand (world-store.ts readKept):
|
|
30
|
+
* every operation asks the time. A configured-but-corrupt clock is a World defect: parseClock fails loudly, never
|
|
31
|
+
* drifting back to the wall clock (two services disagreeing about now breaks the World). */
|
|
32
|
+
function heldClock() {
|
|
25
33
|
const file = process.env[WORLD_CLOCK_ENV];
|
|
26
|
-
// One read answers both whether the clock file is there and what it holds.
|
|
27
|
-
// Kept while the clock file's own facts stand (world-store.ts readKept): every operation asks the time.
|
|
28
34
|
const store = getActiveWorldStore();
|
|
29
35
|
const held = file ? (store.readKept ? store.readKept(file) : store.read(file)) : null;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return new Date(machineNow()).toISOString();
|
|
36
|
+
return held === null ? null : clockForm.parseClock(held);
|
|
37
|
+
}
|
|
38
|
+
/** Whether the World's time runs on by itself (a running clock, or no clock: the machine's), rather than standing frozen
|
|
39
|
+
* until it is moved. */
|
|
40
|
+
export function worldClockRunning() {
|
|
41
|
+
return heldClock()?.kind !== 'frozen';
|
|
37
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.167",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -88,6 +88,10 @@
|
|
|
88
88
|
"types": "./dist/src/exact-json.d.ts",
|
|
89
89
|
"default": "./dist/src/exact-json.js"
|
|
90
90
|
},
|
|
91
|
+
"./checkout": {
|
|
92
|
+
"types": "./dist/src/checkout.d.ts",
|
|
93
|
+
"default": "./dist/src/checkout.js"
|
|
94
|
+
},
|
|
91
95
|
"./inject": "./inject.cjs",
|
|
92
96
|
"./attach": "./attach.cjs",
|
|
93
97
|
"./stream-bridge": "./stream-bridge.cjs",
|
package/src/changeset.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseExactJson } from './exact-json.ts';
|
|
2
2
|
import { resolveReferences } from './references.ts';
|
|
3
3
|
import { withHistoryLocks } from './ancestry.ts';
|
|
4
|
-
import { captureParentHistory, historyChanges, historyEntries, historyPrefix, readHistoryView, type HistoryReference } from './history.ts';
|
|
4
|
+
import { captureHistory, captureParentHistory, historyChanges, historyEntries, historyPrefix, readHistoryView, type HistoryReference } from './history.ts';
|
|
5
5
|
import { worldPaths } from './storage.ts';
|
|
6
6
|
// The changeset primitive — "commit" for operational reality (docs/concepts/the-model.md, v0).
|
|
7
7
|
//
|
|
@@ -88,6 +88,9 @@ export type LedgerPosition = {
|
|
|
88
88
|
stateService: string;
|
|
89
89
|
count: number;
|
|
90
90
|
lastActionId: string | null;
|
|
91
|
+
/** the twin's history view and its position in it: what a branch at this mark starts from (ADR 0036 §5) */
|
|
92
|
+
view?: string;
|
|
93
|
+
position?: number;
|
|
91
94
|
};
|
|
92
95
|
|
|
93
96
|
export const MARKER_KIND = 'volter.world.marker.v1';
|
|
@@ -102,6 +105,10 @@ export type WorldMarker = {
|
|
|
102
105
|
/** Every ledger that existed at capture time. A ledger absent here is treated as position 0
|
|
103
106
|
* (a twin that recorded its first action AFTER the mark contributes its whole ledger). */
|
|
104
107
|
ledgers: LedgerPosition[];
|
|
108
|
+
/** the World clock's instant at the mark, or null when it ran on the machine's time (a run's position, ADR 0036 §5) */
|
|
109
|
+
clock?: string | null;
|
|
110
|
+
/** whether the machine's fault door took the app's disk with the mark */
|
|
111
|
+
disk?: boolean;
|
|
105
112
|
};
|
|
106
113
|
|
|
107
114
|
/** The id reserved for the synthetic "everything this world has ever recorded" base. */
|
|
@@ -253,17 +260,20 @@ export function worldBootMarker(world: string, createdAt: string): WorldMarker {
|
|
|
253
260
|
}
|
|
254
261
|
|
|
255
262
|
/** Capture the current position of every ledger — the cross-service base marker. */
|
|
256
|
-
export function captureMarker(opts: { id: string; world: string; ledgers: LedgerRef[]; createdAt?: string }): WorldMarker {
|
|
263
|
+
export function captureMarker(opts: { id: string; world: string; ledgers: LedgerRef[]; createdAt?: string; clock?: string | null; disk?: boolean }): WorldMarker {
|
|
257
264
|
const ledgers = opts.ledgers.map((ledger) => {
|
|
258
265
|
const rows = listActions(ledger.stateService, ledger.controlRoot);
|
|
266
|
+
const { view, position } = captureHistory(ledger.stateService, ledger.controlRoot);
|
|
259
267
|
return {
|
|
260
268
|
service: ledger.service,
|
|
261
269
|
stateService: ledger.stateService,
|
|
262
270
|
count: rows.length,
|
|
263
271
|
lastActionId: rows.length ? rows[rows.length - 1]!.id : null,
|
|
272
|
+
view,
|
|
273
|
+
position,
|
|
264
274
|
} satisfies LedgerPosition;
|
|
265
275
|
});
|
|
266
|
-
return { kind: MARKER_KIND, id: opts.id, world: opts.world, createdAt: opts.createdAt ?? new Date().toISOString(), ledgers };
|
|
276
|
+
return { kind: MARKER_KIND, id: opts.id, world: opts.world, createdAt: opts.createdAt ?? new Date().toISOString(), ledgers, ...(opts.clock !== undefined ? { clock: opts.clock } : {}), ...(opts.disk ? { disk: true } : {}) };
|
|
267
277
|
}
|
|
268
278
|
|
|
269
279
|
function positionKey(service: string, stateService: string): string {
|
package/src/checkout.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** The checkout a directory is in: the nearest ancestor of the path as given that holds `.git` (a directory, or a
|
|
5
|
+
* worktree's file), or undefined outside every checkout. It walks the spelling it is handed; a caller whose base may
|
|
6
|
+
* be a symlink passes its physical path. What a checkout tracks, and what a failed Git query means, is each caller's. */
|
|
7
|
+
export function checkoutOf(dir: string): string | undefined {
|
|
8
|
+
for (let d = resolve(dir); ; d = dirname(d)) {
|
|
9
|
+
if (existsSync(join(d, '.git'))) return d;
|
|
10
|
+
if (dirname(d) === d) return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A run's completeness (ADR 0036 §9; architecture, "Scenarios"): the World is the application's internet closure, so
|
|
2
|
+
// what reached past it is what the World is missing. The relay notes every destination no twin serves (refused, or
|
|
3
|
+
// passed to the real internet where the World lets it), a twin every request to an operation it does not implement
|
|
4
|
+
// (answered as the vendor answers an unknown request), and a run every machine fault no fault door carried. All are
|
|
5
|
+
// appended, at World time, to the instance's completeness file (TWIN_WORLD_COMPLETENESS_FILE), and a run counts them.
|
|
6
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
7
|
+
import clockForm from '../world-clock.cjs';
|
|
8
|
+
|
|
9
|
+
const WORLD_COMPLETENESS_ENV = 'TWIN_WORLD_COMPLETENESS_FILE';
|
|
10
|
+
|
|
11
|
+
/** What a relay or a twin notes; the World's time is added as it is appended. */
|
|
12
|
+
type CompletenessNote =
|
|
13
|
+
| { kind: 'egress'; destination: string; outcome: 'refused' | 'passed'; rule?: string }
|
|
14
|
+
| { kind: 'unimplemented'; vendor: string; method: string; path: string; operation?: string }
|
|
15
|
+
| { kind: 'machine'; layer: string; fault: string; target?: string };
|
|
16
|
+
type CompletenessRecord = CompletenessNote & { at: string };
|
|
17
|
+
|
|
18
|
+
/** The World's time in `env` (its clock file's, else the machine's). */
|
|
19
|
+
function worldTimeIn(env: Record<string, string | undefined>): string {
|
|
20
|
+
const file = env.TWIN_WORLD_CLOCK_FILE;
|
|
21
|
+
const held = file ? getActiveWorldStore().read(file) : null;
|
|
22
|
+
return new Date(held ? clockForm.clockNowMs(clockForm.parseClock(held), Date.now()) : Date.now()).toISOString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Append one record to the World's completeness file named in `env`; a process outside a World notes nothing. */
|
|
26
|
+
export function noteCompleteness(env: Record<string, string | undefined>, record: CompletenessNote): void {
|
|
27
|
+
const file = env[WORLD_COMPLETENESS_ENV];
|
|
28
|
+
if (!file) return;
|
|
29
|
+
try { getActiveWorldStore().append(file, `${JSON.stringify({ at: worldTimeIn(env), ...record })}\n`); } catch { /* the answer still goes out */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Completeness = {
|
|
33
|
+
/** zero when the World was the whole internet the run reached */
|
|
34
|
+
total: number;
|
|
35
|
+
egress: Record<string, { refused: number; passed: number }>;
|
|
36
|
+
unimplemented: Record<string, Record<string, number>>;
|
|
37
|
+
/** machine faults the run placed and no fault door carried, by layer and kind (ADR 0036 §4) */
|
|
38
|
+
machine: Record<string, number>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** How many records the completeness file holds: where a run starts counting. */
|
|
42
|
+
export function completenessMark(file: string): number {
|
|
43
|
+
return (getActiveWorldStore().read(file) ?? '').split('\n').filter((line) => line.trim()).length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What reached past the World after the file's `mark`, counted per destination and per vendor's operation. */
|
|
47
|
+
export function completenessSince(file: string, mark: number): Completeness {
|
|
48
|
+
const out: Completeness = { total: 0, egress: {}, unimplemented: {}, machine: {} };
|
|
49
|
+
for (const line of (getActiveWorldStore().read(file) ?? '').split('\n').filter((l) => l.trim()).slice(mark)) {
|
|
50
|
+
const r = JSON.parse(line) as CompletenessRecord;
|
|
51
|
+
out.total += 1;
|
|
52
|
+
if (r.kind === 'egress') (out.egress[r.destination] ??= { refused: 0, passed: 0 })[r.outcome] += 1;
|
|
53
|
+
else if (r.kind === 'machine') out.machine[`${r.layer} ${r.fault}`] = (out.machine[`${r.layer} ${r.fault}`] ?? 0) + 1;
|
|
54
|
+
else { const ops = out.unimplemented[r.vendor] ??= {}; const key = r.operation ?? `${r.method} ${r.path}`; ops[key] = (ops[key] ?? 0) + 1; }
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|