@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
@@ -1,355 +1,68 @@
1
- import { exactInteger, compareExactNumbers, isExactIntegerValue } from "./exact-json.js";
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
- // `$comment` is allowed (and ignored) at document and handler level — JSON has no comments
5
- // and scenario files are hand-authored story documents.
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
- if (typeof raw !== "object" || raw === null || Array.isArray(raw))
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 parseExtractor(name, raw, adapter) {
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
- // `nthCall` is the one kernel-builtin condition (1-based per-scope call index); every other
82
- // `on` key is the pack's declared vocabulary.
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
- const f = h.fault;
95
- if (typeof f !== "object" || f === null || Array.isArray(f))
96
- throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault is an object { kind: "slow" | "status" | "drop" | "generation", ... }`);
97
- const kinds = ["slow", "status", "drop", "generation"];
98
- if (!kinds.includes(f.kind))
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
- const PLACEHOLDER_SRC = "\\{\\{([a-zA-Z0-9_.-]+)(\\|(?:min|max):-?[\\d.]+)?\\}\\}";
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
- extractors;
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.extractors = document?.extractors ?? {};
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. Mutates scope state (calls, once, phase) exactly like serving. */
54
+ /** Decide one request: the first rule that matches and whose answer the request admits, else the miss. */
217
55
  next(req) {
218
- const scopeOf = (h) => (h.scope === "session" ? `session:${this.adapter.scopeKey(req)}` : "world");
219
- // The call counter ticks once per request on the WORLD scope (and the session scope when
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.matches(r.handler, req, scope))
59
+ if ("respond" in handler && this.adapter.admits && !this.adapter.admits(req, handler.respond))
231
60
  continue;
232
- if ("respond" in r.handler && this.adapter.admits && !this.adapter.admits(req, r.handler.respond))
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 value;
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: "A handler never fakes a SUCCESS on a route whose data this twin stores — seed that through the vendor's own API instead. Serving is deterministic.",
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: active handlers + match/miss counts)" },
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
  }
@@ -1,5 +1,5 @@
1
1
  import type { EventWrite } from './events.js';
2
- import type { ScenarioEngine } from './scenario.js';
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: a handler's decision, a miss, or a fault (scenario.ts `serve`). */
34
- export type ScenarioServed = Awaited<ReturnType<ScenarioEngine<unknown>['serve']>>;
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;
@@ -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;
@@ -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
- if (held !== null) {
31
- // A configured-but-corrupt clock is a WORLD defect — parseClock fails loudly, never silently drifting back to
32
- // wall-clock (two services disagreeing about now breaks the world).
33
- const clock = clockForm.parseClock(held);
34
- return new Date(clockForm.clockNowMs(clock, machineNow())).toISOString();
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.165",
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 {
@@ -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
+ }