@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/src/derived-core.ts
CHANGED
|
@@ -45,6 +45,8 @@ import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from
|
|
|
45
45
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
|
|
46
46
|
import { getPack, type PackDescriptor } from './packRegistry.ts';
|
|
47
47
|
import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
|
|
48
|
+
import { decide, fact, faultAt, listLag, recordFault, serviceLedger, type ScenarioLedger } from './scenario-consume.ts';
|
|
49
|
+
import { durationMs, placedOn } from './scenario-timeline.ts';
|
|
48
50
|
import { bodyTextOf, checkJsonBody, takeParsedBody } from './derived.ts';
|
|
49
51
|
|
|
50
52
|
// ── the manifest ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1510,7 +1512,7 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1510
1512
|
const endpoint = (kind: import('./events.ts').EndpointsDecl): import('./events.ts').EndpointsDecl => ({ ...kind, storedAs: storedType(m, kind.storedAs), ...(kind.joins ? { joins: Object.fromEntries(Object.entries(kind.joins).map(([name, join]) => [name, { ...join, storedAs: storedType(m, join.storedAs) }])) } : {}) });
|
|
1511
1513
|
const events = { ...m.events, ...(m.events.store ? { store: storedType(m, m.events.store) } : {}), endpoints: 'storedAs' in m.events.endpoints ? endpoint(m.events.endpoints) : m.events.endpoints.map(endpoint) };
|
|
1512
1514
|
const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
|
|
1513
|
-
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined, scopeOfCall.get(call)?.application);
|
|
1515
|
+
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined, scopeOfCall.get(call)?.application, m.vendor);
|
|
1514
1516
|
}
|
|
1515
1517
|
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
|
|
1516
1518
|
// a read held until the World writes (held-reads.ts) hears it
|
|
@@ -1930,7 +1932,14 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1930
1932
|
}
|
|
1931
1933
|
case 'list': {
|
|
1932
1934
|
if (!m.list) return { unmodeled: 'no manifest list configuration' };
|
|
1933
|
-
|
|
1935
|
+
// a `lag` the World's scenario placed: a write younger than it is missing from lists, as an eventually consistent
|
|
1936
|
+
// index misses it (architecture, "Scenarios"); recorded when it withholds one
|
|
1937
|
+
const ledger = serviceLedger({ vendor: m.vendor, service: m.service, root, at, recording: !(scope.readOnly === true || isReadOnlyRequest(call.request)) });
|
|
1938
|
+
const lag = listLag(ledger, op.id);
|
|
1939
|
+
const settled = (r: TwinResource): boolean => !lag || Date.parse(r.updatedAt) <= Date.parse(at) - durationMs(lag.by!);
|
|
1940
|
+
const listed = stored(m, resource, root).filter(underParent);
|
|
1941
|
+
if (lag && listed.some((r) => !settled(r))) await recordFault(ledger, lag, { operation: op.id, withheld: listed.filter((r) => !settled(r)).map((r) => r.id) });
|
|
1942
|
+
const rows = listed.filter(settled).map((r) => view(m, resource, r));
|
|
1934
1943
|
const arranged = arrangeRows(m, resource, params, rows);
|
|
1935
1944
|
if (!Array.isArray(arranged)) return { served: vendorError(m, arranged) };
|
|
1936
1945
|
return { served: pageRows(m, op, call, params, arranged, resource, root, paths) };
|
|
@@ -2030,6 +2039,16 @@ export type SemanticsContext = {
|
|
|
2030
2039
|
/** What the World's scenario decided for this turn (a model vendor's answer: the handler matched, or the miss), on an
|
|
2031
2040
|
* operation the pack's `semantics/scenario.ts` names; undefined elsewhere, or when the World carries no scenario. */
|
|
2032
2041
|
scenario: ScenarioDecision | undefined;
|
|
2042
|
+
/** The vendor's judgement at one of its decision points (a fraud score, an identity check, deliverability), for the
|
|
2043
|
+
* subject it is keyed to: what the World's scenario decided, else `byDefault`, the vendor's normal outcome; recorded in
|
|
2044
|
+
* the twin's log either way (architecture, "Scenarios"). The handler follows it with what the vendor does next. */
|
|
2045
|
+
decide<T>(point: string, subject: string, byDefault: T): Promise<T>;
|
|
2046
|
+
/** A fault the World's scenario placed at a point of the pack's own (one item of a batch) for `subject`, recorded as
|
|
2047
|
+
* landed; undefined when none is placed. */
|
|
2048
|
+
fault(point: string, subject: string): Promise<{ kind: string; status?: number; message?: string; errorType?: string } | undefined>;
|
|
2049
|
+
/** A World fact (an exchange rate, a holiday, a place) the twin answers with in its own shape, recorded as answered;
|
|
2050
|
+
* undefined when the World holds none. */
|
|
2051
|
+
fact(kind: string, key: string): Promise<unknown>;
|
|
2033
2052
|
/** A message the vendor sends the application's server and decides by its answer, waited on for at most `within`
|
|
2034
2053
|
* milliseconds (a real-time authorization request): its status and body, or why there was none. */
|
|
2035
2054
|
ask(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer>;
|
|
@@ -2245,6 +2264,21 @@ const coreReadsBody = (type: string | null): boolean => {
|
|
|
2245
2264
|
const media = (type ?? '').split(';')[0]!.trim().toLowerCase();
|
|
2246
2265
|
return media === '' || media === 'application/json' || media.endsWith('+json') || media === 'application/x-www-form-urlencoded' || media.startsWith('multipart/') || media.startsWith('text/');
|
|
2247
2266
|
};
|
|
2267
|
+
const ledgers = new WeakMap<object, () => ScenarioLedger>();
|
|
2268
|
+
/** What a context consumes the World's scenario through (scenario-consume.ts): its twin's bookkeeping, at its time. */
|
|
2269
|
+
export function scenarioLedgerOf(ctx: object): ScenarioLedger {
|
|
2270
|
+
const ledger = ledgers.get(ctx);
|
|
2271
|
+
if (!ledger) throw new Error('a scenario ledger is a context the kernel opened');
|
|
2272
|
+
return ledger();
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/** `GET /twin/scenario`: what the World's scenario placed on this vendor, and what the twin consumed of it. */
|
|
2276
|
+
export function scenarioRecords(m: DerivedManifest, root?: string): { vendor: string; placed: unknown[]; consumed: { decisions: unknown[]; faults: unknown[] } } {
|
|
2277
|
+
const placed = placedOn(m.vendor);
|
|
2278
|
+
const kept = (type: string) => resourcesOfType(m.service, storedType(m, type), root).map((r) => ownFields(r));
|
|
2279
|
+
return { vendor: m.vendor, placed, consumed: { decisions: kept('_decision'), faults: kept('_fault') } };
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2248
2282
|
export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScope): Promise<SemanticsContext> {
|
|
2249
2283
|
const root = scope.root;
|
|
2250
2284
|
const asAsked = call;
|
|
@@ -2301,6 +2335,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2301
2335
|
const row = context.row(link.resource, link.id);
|
|
2302
2336
|
return row && row.deleted !== true ? row : undefined;
|
|
2303
2337
|
};
|
|
2338
|
+
const ledger = (): ScenarioLedger => ({ vendor: m.vendor, service: m.service, root, at, recording: !(scope.readOnly === true || isReadOnlyRequest(call.request)), rows: (type) => context.rowsRaw(type), record: (type, fields, id) => context.record(type, fields, id) });
|
|
2304
2339
|
const context: SemanticsContext = {
|
|
2305
2340
|
call,
|
|
2306
2341
|
params,
|
|
@@ -2310,6 +2345,9 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2310
2345
|
root,
|
|
2311
2346
|
publicBase: twinPublicBase(call.request),
|
|
2312
2347
|
scenario: scenarioDecisionOf(call.request),
|
|
2348
|
+
decide: (point, subject, byDefault) => decide(ledger(), point, subject, byDefault),
|
|
2349
|
+
fault: (point, subject) => faultAt(ledger(), point, subject),
|
|
2350
|
+
fact: (kind, key) => fact(ledger(), kind, key),
|
|
2313
2351
|
ask: (url, init, within) => askApplication(url, init, within, scope.application),
|
|
2314
2352
|
worldEnv: (name) => worldEnvValue(name),
|
|
2315
2353
|
asVendor: (fn) => runAsVendorMove(fn),
|
|
@@ -2570,6 +2608,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2570
2608
|
batch: (fn) => fn(),
|
|
2571
2609
|
};
|
|
2572
2610
|
if (batchable(m, root)) bindBatch(m, call, context, params, root, at, rawRow);
|
|
2611
|
+
ledgers.set(context, ledger);
|
|
2573
2612
|
return context;
|
|
2574
2613
|
}
|
|
2575
2614
|
|
package/src/events.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { deliveryTraceHeaders } from './trace-context.ts';
|
|
|
14
14
|
import { sendRecordedEvent } from './event-delivery.ts';
|
|
15
15
|
import { getActiveWorldStore, withWorldStore, type WorldStore } from './world-store.ts';
|
|
16
16
|
import { worldPaths } from './storage.ts';
|
|
17
|
+
import { worldNow } from './world-clock.ts';
|
|
18
|
+
import { recordFault, serviceLedger, webhookFault } from './scenario-consume.ts';
|
|
19
|
+
import { durationMs } from './scenario-timeline.ts';
|
|
17
20
|
|
|
18
21
|
/** How the vendor signs a delivery. */
|
|
19
22
|
export type EventScheme =
|
|
@@ -390,6 +393,7 @@ export async function deliverEvents(
|
|
|
390
393
|
transport: EventTransport = worldTransport,
|
|
391
394
|
values?: (type: string) => Promise<Record<string, unknown>>,
|
|
392
395
|
application?: ApplicationStandIn,
|
|
396
|
+
vendor?: string,
|
|
393
397
|
): Promise<EventDelivery[]> {
|
|
394
398
|
if (write.storedType === decl.store) return [];
|
|
395
399
|
const dispatch: EventTransport = application ? async (url, body, headers) => {
|
|
@@ -397,7 +401,7 @@ export async function deliverEvents(
|
|
|
397
401
|
return answer === undefined ? transport(url, body, headers) : answer === 'unreachable' ? 0 : answer.status;
|
|
398
402
|
} : transport;
|
|
399
403
|
const sent: EventDelivery[] = [];
|
|
400
|
-
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, dispatch, values, type)));
|
|
404
|
+
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, dispatch, values, type, vendor)));
|
|
401
405
|
return sent;
|
|
402
406
|
}
|
|
403
407
|
|
|
@@ -410,6 +414,7 @@ async function deliverEventType(
|
|
|
410
414
|
transport: EventTransport,
|
|
411
415
|
values: ((type: string) => Promise<Record<string, unknown>>) | undefined,
|
|
412
416
|
type: string,
|
|
417
|
+
vendor: string | undefined,
|
|
413
418
|
): Promise<EventDelivery[]> {
|
|
414
419
|
const all = twinResources(service, write.root) as unknown as Array<Record<string, unknown>>;
|
|
415
420
|
const kinds = Array.isArray(decl.endpoints) ? decl.endpoints : [decl.endpoints as EndpointsDecl];
|
|
@@ -474,16 +479,33 @@ async function deliverEventType(
|
|
|
474
479
|
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
475
480
|
const record = decl.record ?? '_world_event_delivery';
|
|
476
481
|
const id = `${record.slice(1)}_v1_${seq}`;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
482
|
+
const packet = { url, body, headers, event_type: type, sent_at: millis,
|
|
483
|
+
delivery_protocol: 1, event_id: String(filled.$id),
|
|
484
|
+
endpoint_type: kind.storedAs, endpoint_id: String(row.id),
|
|
485
|
+
delivery_state: 'queued', attempt_count: 0, current_attempt: null,
|
|
486
|
+
transport_status: null, finished_at: null, accepted_at: null,
|
|
487
|
+
};
|
|
488
|
+
await applyTwinWrite(service, { operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id, fields: packet, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
|
|
489
|
+
// a webhook fault the World's scenario placed (architecture, "Scenarios"): dropped, held until the World's clock
|
|
490
|
+
// reaches its delay (sent when the clock's mover reaches it, or by a running clock's timer: releaseHeldDeliveries),
|
|
491
|
+
// or sent twice, recorded as landed
|
|
492
|
+
const ledger = vendor ? serviceLedger({ vendor, service, root: write.root, at: write.occurredAt, recording: true }) : undefined;
|
|
493
|
+
const fault = ledger ? webhookFault(ledger, type) : undefined;
|
|
494
|
+
if (ledger && fault) await recordFault(ledger, fault, { event: type, delivery: id });
|
|
495
|
+
if (fault?.kind === 'webhook-drop' || fault?.kind === 'webhook-delay') {
|
|
496
|
+
const held = fault.kind === 'webhook-delay' ? { delivery_state: 'held', due_at: millis + durationMs(fault.by!) } : { delivery_state: 'dropped' };
|
|
497
|
+
await applyTwinWrite(service, { operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id, fields: held, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
|
|
498
|
+
sent.pop();
|
|
499
|
+
if (fault.kind === 'webhook-delay') heldListeners.get(worldPaths(service, write.root).root)?.();
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
486
502
|
recorded.push({ type: record, id });
|
|
503
|
+
if (fault?.kind === 'webhook-duplicate') {
|
|
504
|
+
const twin = `${id}_duplicate`;
|
|
505
|
+
await applyTwinWrite(service, { operation: `${record.slice(1)}.record`, subjectType: record, subjectId: twin, fields: packet, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
|
|
506
|
+
sent.push(sent.at(-1)!);
|
|
507
|
+
recorded.push({ type: record, id: twin });
|
|
508
|
+
}
|
|
487
509
|
}
|
|
488
510
|
// sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
|
|
489
511
|
// slow or down fails no request
|
|
@@ -493,6 +515,12 @@ async function deliverEventType(
|
|
|
493
515
|
// active before it runs. Transport completion writes to the same retained state.
|
|
494
516
|
const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
|
|
495
517
|
for (const d of sent) deliveryObserver?.({ ...d, service });
|
|
518
|
+
dispatchRecorded(service, store, deliveryRoot, recorded, transport, trace);
|
|
519
|
+
return sent;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Each recorded packet sent after the write's answer, keeping its request's store and root. */
|
|
523
|
+
function dispatchRecorded(service: string, store: WorldStore, deliveryRoot: string, recorded: ReadonlyArray<{ type: string; id: string }>, transport: EventTransport, trace: Record<string, string>): void {
|
|
496
524
|
const roots = pendingDeliveries.get(store) ?? new Map<string, Set<Promise<void>>>();
|
|
497
525
|
pendingDeliveries.set(store, roots);
|
|
498
526
|
const pending = roots.get(deliveryRoot) ?? new Set<Promise<void>>();
|
|
@@ -516,5 +544,24 @@ async function deliverEventType(
|
|
|
516
544
|
}
|
|
517
545
|
}, 0);
|
|
518
546
|
}
|
|
519
|
-
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const heldListeners = new Map<string, () => void>();
|
|
550
|
+
/** Who hears that a twin held a packet for later (its pack's fetch, which sets the timer a running clock needs). */
|
|
551
|
+
export function onHeldDelivery(service: string, root: string | undefined, listener: () => void): void {
|
|
552
|
+
heldListeners.set(worldPaths(service, root).root, listener);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Send what a `webhook-delay` held that the World's clock has reached, in the order it fell due, and wait until it is
|
|
556
|
+
* sent; answer the instant the next held packet falls due, if any (ADR 0036 §4: the mover of the clock waits on it). */
|
|
557
|
+
export async function releaseHeldDeliveries(service: string, root: string | undefined, record = '_world_event_delivery', transport: EventTransport = worldTransport): Promise<string | null> {
|
|
558
|
+
const now = Date.parse(worldNow());
|
|
559
|
+
const held = twinResources(service, root).filter((r) => r.type === record && r.deleted !== true && r.delivery_state === 'held').sort((a, b) => Number(a.due_at) - Number(b.due_at));
|
|
560
|
+
const due = held.filter((r) => Number(r.due_at) <= now);
|
|
561
|
+
for (const r of due) await applyTwinWrite(service, { operation: `${record.slice(1)}.release`, subjectType: record, subjectId: r.id, fields: { delivery_state: 'queued' }, occurredAt: worldNow(), actor: { kind: 'system' } }, root);
|
|
562
|
+
const deliveryRoot = worldPaths(service, root).root;
|
|
563
|
+
dispatchRecorded(service, getActiveWorldStore(), deliveryRoot, due.map((r) => ({ type: record, id: r.id })), transport, {});
|
|
564
|
+
await settleEventDeliveries(deliveryRoot);
|
|
565
|
+
const next = held.find((r) => Number(r.due_at) > now);
|
|
566
|
+
return next ? new Date(Number(next.due_at)).toISOString() : null;
|
|
520
567
|
}
|
package/src/index.ts
CHANGED
|
@@ -100,9 +100,11 @@ export * as live from './live/index.ts';
|
|
|
100
100
|
export * as mongodb from './mongodb/index.ts';
|
|
101
101
|
export * as git from './git/index.ts';
|
|
102
102
|
|
|
103
|
-
// ── the scenario grammar (architecture, "
|
|
104
|
-
export {
|
|
105
|
-
export
|
|
103
|
+
// ── the scenario grammar (architecture, "Scenarios") ───────────────────────────────────────────
|
|
104
|
+
export { scenarioFaultResult, ScenarioError } from './scenario.ts';
|
|
105
|
+
export { parseScenario, resolveScenario, WORLD_SCENARIO_ENV } from './scenario-timeline.ts';
|
|
106
|
+
export type { ResolvedScenario, Scenario } from './scenario-timeline.ts';
|
|
107
|
+
export type { PackScenarioAdapter, ScenarioDecision, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord } from './scenario.ts';
|
|
106
108
|
|
|
107
109
|
export { verifyAttestationBundle } from './attestation.ts';
|
|
108
110
|
|
package/src/openai-wire.ts
CHANGED
|
@@ -350,7 +350,6 @@ export function scenario(vendor: string, operations: string[], faultBody: (statu
|
|
|
350
350
|
toolResultFor: (r, c) => nonEmpty(c) && r.results.includes(c),
|
|
351
351
|
hasTool: (r, c) => nonEmpty(c) && r.tools.includes(c),
|
|
352
352
|
},
|
|
353
|
-
text: (r) => r.text,
|
|
354
353
|
// source: https://platform.openai.com/docs/api-reference/chat/create "none means the model will not call any tool and instead generates a message"
|
|
355
354
|
admits: (r, respond) => !(r.toolsForbidden && obj(respond).toolCalls !== undefined),
|
|
356
355
|
validateOn: (on) => {
|
package/src/pack-fetch.ts
CHANGED
|
@@ -13,11 +13,16 @@ import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
|
|
|
13
13
|
import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
|
|
14
14
|
import { PREFIX_PARAMS_HEADER } from './derived.ts';
|
|
15
15
|
import { withHeaders } from './with-headers.ts';
|
|
16
|
-
import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type ScreenDecl, type VendorManifest } from './derived-core.ts';
|
|
16
|
+
import { authRefusal, bindSemantics, contextFor, scenarioLedgerOf, scenarioRecords, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type ScreenDecl, type VendorManifest } from './derived-core.ts';
|
|
17
17
|
import { compileSurface, createDerivedFetch, matchOperation, pathParams, type DerivedFetch, type DerivedOperation, type DerivedOwner, type DerivedSurface, type Workspace, type BoardFrame } from './derived.ts';
|
|
18
18
|
import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from './request-scope.ts';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter, type ScenarioStatusFault } from './scenario.ts';
|
|
20
|
+
import { placedOn, type PlacedFault } from './scenario-timeline.ts';
|
|
21
|
+
import { rateLimited, recordFault, requestFaults, type ScenarioLedger } from './scenario-consume.ts';
|
|
22
|
+
import { faultAnswer, kernelFault, serveTurn } from './scenario-serve.ts';
|
|
23
|
+
import { onHeldDelivery, releaseHeldDeliveries } from './events.ts';
|
|
24
|
+
import { getActiveWorldStore, withWorldStore } from './world-store.ts';
|
|
25
|
+
import { noteCompleteness } from './completeness.ts';
|
|
21
26
|
import { runWithCorrelationId } from './actions.ts';
|
|
22
27
|
import { deferPerform, deployableEntries, performRequestGroup, RefusedWriteError, VendorWriteError } from './head.ts';
|
|
23
28
|
import { FLUSH, ZERO_SHA, pktLine, readPktLines } from './git/smart-http.ts';
|
|
@@ -35,7 +40,7 @@ import { liveIngest } from './live/ingest.ts';
|
|
|
35
40
|
import type { LiveEngine } from './live/types.ts';
|
|
36
41
|
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
37
42
|
import { gitRepositoryOf, resourcesOfType } from './derived-core.ts';
|
|
38
|
-
import { worldNow } from './world-clock.ts';
|
|
43
|
+
import { worldClockRunning, worldNow } from './world-clock.ts';
|
|
39
44
|
import type { WebSocketUpgrade, WebSocketPeer } from './serve-http.ts';
|
|
40
45
|
import { MACHINE_POOL, POOL_KINDS, resetFakePool } from './machines.ts';
|
|
41
46
|
import { applyTwinWrite } from './serve.ts';
|
|
@@ -103,10 +108,9 @@ export type LanePart = {
|
|
|
103
108
|
};
|
|
104
109
|
|
|
105
110
|
/** Where a pack serves: its World's root, whether it was started read-only, the World instant when a caller pins it, the
|
|
106
|
-
* World's managed Postgres the runtime binds a pack declaring `managedDatabase` to (a handler's `ctx.engine`)
|
|
107
|
-
* World's scenario file for a model vendor's turns (`scenarioPath`, the runtime's `--scenario`). */
|
|
111
|
+
* and the World's managed Postgres the runtime binds a pack declaring `managedDatabase` to (a handler's `ctx.engine`). */
|
|
108
112
|
export type PackFetchOptions = {
|
|
109
|
-
root?: string; readOnly?: boolean; clock?: () => string; database?: string;
|
|
113
|
+
root?: string; readOnly?: boolean; clock?: () => string; database?: string;
|
|
110
114
|
/** the application a walk stands in for: this fetch's requests and declared events are answered by it */
|
|
111
115
|
application?: ApplicationStandIn;
|
|
112
116
|
};
|
|
@@ -320,6 +324,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
320
324
|
gap: async (request, operation) => {
|
|
321
325
|
const refused = m.auth?.beforeRouting ? authRefusal(m, request, options.root) : undefined;
|
|
322
326
|
if (refused) return refused;
|
|
327
|
+
// an operation this twin does not implement is what the World is missing at this vendor (ADR 0036 §9)
|
|
328
|
+
noteCompleteness(process.env, { kind: 'unimplemented', vendor: m.vendor, method: request.method, path: new URL(request.url).pathname, ...(operation ? { operation: operation.id } : {}) });
|
|
323
329
|
if (parts.gap) {
|
|
324
330
|
const url = new URL(request.url);
|
|
325
331
|
const matched = operation ? matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers) : undefined;
|
|
@@ -343,23 +349,30 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
343
349
|
if (graphql && gql && request.method === 'POST' && gql.paths.includes(new URL(request.url).pathname.replace(/\/+$/, ''))) return graphql(request);
|
|
344
350
|
return rest(request);
|
|
345
351
|
}, { owners: rest.owners });
|
|
346
|
-
// the World's scenario, read
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
352
|
+
// the World's scenario (scenario-timeline.ts), read through the World's store at every request as the clock is: a
|
|
353
|
+
// model vendor's turns, and the faults placed on this vendor's requests, consumed and recorded in its own log
|
|
354
|
+
const placesThis = (): boolean => placedOn(m.vendor).length > 0;
|
|
355
|
+
// the work the World's time makes due (ADR 0036 §4): done when the clock's mover asks (`POST /twin/due`) and waits, and
|
|
356
|
+
// under a running clock by one timer set for the instant the next work falls due
|
|
357
|
+
let dueTimer: ReturnType<typeof setTimeout> | undefined;
|
|
358
|
+
const due = async (): Promise<string | null> => {
|
|
359
|
+
if (!m.events) return null;
|
|
360
|
+
const next = await releaseHeldDeliveries(m.service, options.root, m.events.record);
|
|
361
|
+
if (dueTimer !== undefined) { clearTimeout(dueTimer); dueTimer = undefined; }
|
|
362
|
+
const store = getActiveWorldStore();
|
|
363
|
+
if (next !== null && worldClockRunning()) {
|
|
364
|
+
dueTimer = setTimeout(() => { dueTimer = undefined; void withWorldStore(store, due).catch(() => undefined); }, Math.max(0, Date.parse(next) - Date.parse(worldNow())));
|
|
365
|
+
(dueTimer as { unref?: () => void }).unref?.();
|
|
357
366
|
}
|
|
358
|
-
return
|
|
367
|
+
return next;
|
|
359
368
|
};
|
|
360
|
-
|
|
369
|
+
// a packet held under a running clock sets the timer for when it falls due; so does a twin that starts with held work
|
|
370
|
+
// in its store (what time crossed while it was down is sent at once)
|
|
371
|
+
const arm = (): void => { if (worldClockRunning()) void due().catch(() => undefined); };
|
|
372
|
+
if (m.events) { onHeldDelivery(m.service, options.root, arm); const store = getActiveWorldStore(); queueMicrotask(() => { void withWorldStore(store, async () => arm()); }); }
|
|
373
|
+
/** The API, a turn handed its decision (ctx.scenario), or answered as the fault the World's scenario placed on it. */
|
|
361
374
|
const decided = async (arrived: Request): Promise<Response> => {
|
|
362
|
-
if (!parts.scenario) return api(arrived);
|
|
375
|
+
if (!parts.scenario && !placesThis()) return api(arrived);
|
|
363
376
|
// the body is read once and the request made again from its bytes, so the turn's context and the API each read their
|
|
364
377
|
// own. Merge served with --scenario once answered no turn in 8 s (t_c0279b69), and this change came with the cure;
|
|
365
378
|
// putting the earlier read back (the served request cloned for the context, then forwarded) does not reproduce the
|
|
@@ -369,25 +382,52 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
369
382
|
const request = new Request(arrived.url, { method: arrived.method, headers: arrived.headers, signal: arrived.signal, ...(bytes ? { body: bytes } : {}) });
|
|
370
383
|
const url = new URL(request.url);
|
|
371
384
|
const matched = matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers);
|
|
372
|
-
if (!matched
|
|
373
|
-
|
|
374
|
-
if (!
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
385
|
+
if (!matched) return api(request);
|
|
386
|
+
const turn = parts.scenario?.operations.includes(matched.operation.id) === true;
|
|
387
|
+
if (!turn && !placesThis()) return api(request);
|
|
388
|
+
const ctx = await contextFor(m, { request: request.clone(), operation: matched.operation, params: matched.params }, scope);
|
|
389
|
+
const ledger = scenarioLedgerOf(ctx);
|
|
390
|
+
const asked = turn ? parts.scenario!.request(ctx) : undefined;
|
|
391
|
+
/** A fault's answer in the vendor's own envelope: the model wire's, where the pack renders a turn's, else the manifest's
|
|
392
|
+
* error; null when it holds the request (`slow`) and the answer follows. */
|
|
393
|
+
const answerFault = async (fault: PlacedFault): Promise<Response | null> => {
|
|
394
|
+
const placed = kernelFault(fault);
|
|
395
|
+
const adapter = parts.scenario?.adapter;
|
|
396
|
+
if (placed.kind !== 'status' || (asked !== undefined && adapter?.renderFault)) return faultAnswer(fault, asked !== undefined && adapter?.renderFault ? () => adapter.renderFault!(placed as ScenarioStatusFault, asked) : undefined, request.signal);
|
|
397
|
+
const refused = vendorError(m, { status: placed.status, code: placed.errorType ?? 'twin_fault', message: placed.message! });
|
|
398
|
+
if (placed.retryAfterSeconds !== undefined) refused.headers.set('retry-after', String(placed.retryAfterSeconds));
|
|
399
|
+
return refused;
|
|
400
|
+
};
|
|
379
401
|
try {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
402
|
+
// a rejection at the vendor's edge comes before its credential is read; a rate limit counts every request
|
|
403
|
+
const fault = requestFaults(ledger).find((f) => f.on === undefined && f.point === undefined && (f.operation === undefined || f.operation === matched.operation.id));
|
|
404
|
+
if (fault?.kind === 'rejected') { await recordFault(ledger, fault, { operation: matched.operation.id }); return (await answerFault(fault))!; }
|
|
405
|
+
const limited = await rateLimited(ledger, matched.operation.id);
|
|
406
|
+
if (limited) return (await answerFault(limited))!;
|
|
407
|
+
// the credential is the vendor's first question: a request it refuses takes no turn and meets no other fault
|
|
408
|
+
if ((turn || fault) && !m.auth?.exempt?.includes(matched.operation.id)) {
|
|
409
|
+
const refused = authRefusal(m, request, options.root);
|
|
410
|
+
if (refused) return refused;
|
|
411
|
+
}
|
|
412
|
+
if (fault) {
|
|
413
|
+
await recordFault(ledger, fault, { operation: matched.operation.id });
|
|
414
|
+
// committed, and its answer lost: the write lands, and the app is told it failed
|
|
415
|
+
if (fault.kind === 'lost') { await (await answer(request, asked, ledger)).arrayBuffer(); return (await answerFault({ ...fault, kind: 'failed' }))!; }
|
|
416
|
+
const answered = await answerFault(fault);
|
|
417
|
+
if (answered) return answered;
|
|
418
|
+
}
|
|
419
|
+
return await answer(request, asked, ledger);
|
|
385
420
|
} catch (error) {
|
|
386
421
|
if (!(error instanceof ScenarioError)) throw error;
|
|
387
422
|
return vendorError(m, { status: 500, code: 'scenario_error', message: `the World's scenario: ${error.message}` });
|
|
388
423
|
}
|
|
389
|
-
|
|
390
|
-
|
|
424
|
+
};
|
|
425
|
+
/** The request answered by the API, a turn first decided by the World's scenario. */
|
|
426
|
+
const answer = async (request: Request, asked: unknown, ledger: ScenarioLedger): Promise<Response> => {
|
|
427
|
+
if (asked === undefined) return api(request);
|
|
428
|
+
const served = await serveTurn(asked, ledger, parts.scenario!.adapter, request.signal);
|
|
429
|
+
if (served.kind === 'fault') return Response.json(served.result.body, { status: served.result.status, headers: served.result.headers ?? {} });
|
|
430
|
+
return api(withHeaders(request, new Headers([...request.headers, [SCENARIO_DECISION_HEADER, scenarioDecisionHeader(served)]])));
|
|
391
431
|
};
|
|
392
432
|
for (const d of m.doors ?? []) if (!parts.doors?.[d.id]) throw new Error(`${m.vendor}: the manifest declares the door ${d.id}, and semantics/doors.ts exports no ${d.id}`);
|
|
393
433
|
const door = worldDoorMatcher(m.doors ?? []);
|
|
@@ -540,17 +580,19 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
540
580
|
const body = twinManifest({
|
|
541
581
|
vendor: m.vendor, twinOf: d.twinOf,
|
|
542
582
|
stateSentence: `Stateful: it stores ${d.stores}. Create state through the vendor's OWN API with the real SDK or plain fetch pointed here${discoveryDoors.length ? "; what the vendor does on its own pages (not its API) is a door of the World, listed under doors" : ''}.`,
|
|
543
|
-
behaviorSentence: d.behavior, exampleHandler: d.exampleHandler ?? null,
|
|
583
|
+
behaviorSentence: d.behavior, exampleHandler: d.exampleHandler ?? null,
|
|
544
584
|
});
|
|
545
585
|
const declared = Object.fromEntries(discoveryDoors.map((door) => [`${door.method} ${doorPath(door.path)}`, door.note ?? 'a door of the World']));
|
|
546
586
|
return Response.json({ ...shown, ...body, program: { ...(body.program as Record<string, unknown>), ...(identity ? { identity } : {}) }, doors: { ...(body.doors as Record<string, string>), ...declared } });
|
|
547
587
|
}
|
|
548
588
|
return Response.json({ ...statefulTwinManifest({ vendor: m.vendor, twinOf: d.twinOf, stores: d.stores, ...(identity ? { identity } : {}), ...(d.behavior ? { behavior: d.behavior } : {}), ...(d.notes ? { notes: d.notes } : {}), ...(discoveryDoors.length ? { doors: discoveryDoors.map((door) => ({ ...door, path: doorPath(door.path) })) } : {}) }), ...shown });
|
|
549
589
|
}
|
|
550
|
-
// the
|
|
590
|
+
// the World's clock has moved: do what it made due, and answer once it is done (ADR 0036 §4)
|
|
591
|
+
if (request.method === 'POST' && path === '/twin/due') return Response.json({ next: await due() });
|
|
592
|
+
// what the World's scenario placed on this vendor, and what the twin consumed of it (its `_decision` and `_fault` records)
|
|
551
593
|
if (request.method === 'GET' && path === '/twin/scenario') {
|
|
552
594
|
const lanes = await laneDoor(request, '/twin/scenario');
|
|
553
|
-
|
|
595
|
+
return Response.json({ ...scenarioRecords(m, options.root), ...(Object.keys(lanes).length ? { lanes } : {}) });
|
|
554
596
|
}
|
|
555
597
|
// the vendor's own moves, not the caller's: its context carries none of the request's headers (a Stripe-Account
|
|
556
598
|
// header would make a payout time makes the caller's), but a tenant's, when the vendor keeps tenants apart
|
|
@@ -723,7 +765,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
723
765
|
const ownUpgrade = m.sockets?.length ? socketUpgrade(m.service, options.root, m.sockets, parts.sockets!,
|
|
724
766
|
(request, d, params) => open(request, `socket:${d.id}`, 'GET', d.path, params), hostOf, (request) => new URL(routed(request).url).pathname,
|
|
725
767
|
// a turn a session speaks (a voice agent's reply) is the World's scenario's, as a turn over HTTP is
|
|
726
|
-
async (asked) =>
|
|
768
|
+
async (asked, context) => (parts.scenario ? serveTurn(asked, scenarioLedgerOf(await context()), parts.scenario.adapter) : undefined)) : undefined;
|
|
727
769
|
const rawUpgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
|
|
728
770
|
const opening = new WeakMap<WebSocketPeer, Promise<void>>();
|
|
729
771
|
const upgrade: WebSocketUpgrade | undefined = rawUpgrade && m.tenant?.storage === 'namespaced' && parts.tenant ? {
|
package/src/runtime.ts
CHANGED
|
@@ -9,7 +9,10 @@ export { clearRegistry, getPack, hasPack, listPacks, packOf, registerPack, pullP
|
|
|
9
9
|
export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrite, TwinPack } from './packRegistry.ts';
|
|
10
10
|
// The DELIVER verb (`emit`) — vendor-agnostic engine + CLI glue; packs declare a TwinEmitter
|
|
11
11
|
// (their event catalog, endpoints-from-state, and signed synthesis) on `TwinPack.emitter`.
|
|
12
|
-
export {
|
|
12
|
+
export { scenarioFaultResult, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.ts';
|
|
13
|
+
export { durationMs, MACHINE_LAYERS, parseScenario, resolveScenario, ScenarioTimelineError } from './scenario-timeline.ts';
|
|
14
|
+
export type { ResolvedScenario, ScenarioAction, ScenarioCall, ScenarioFaultInput } from './scenario-timeline.ts';
|
|
15
|
+
export { completenessMark, completenessSince, noteCompleteness, type Completeness } from './completeness.ts';
|
|
13
16
|
export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
|
|
14
17
|
export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
|
|
15
18
|
export { ORIGINAL_PATH_HEADER } from './sigv4.ts';
|
|
@@ -28,7 +31,7 @@ export type { EventDelivery, EventRender, EventScheme, EventsDecl, EventTranspor
|
|
|
28
31
|
export type { Actor, CoreScope, DerivedManifest, ErrorSpec, FieldRule, Handler, HandlerContext, ResourceDecl, WriteHookContext, ScreenDecl, Semantics, SemanticsContext, StateField, Transition, TransitionObserver, SeedCall } from './derived-core.ts';
|
|
29
32
|
export type { RemoteExecute, RemoteExecuteRequest, RemoteExecuteResponse } from './remote-execute.ts';
|
|
30
33
|
export type { TwinFetchAdapterConfig, TwinFetchHandlerRequest, TwinFetchHandlerResult, TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
|
|
31
|
-
export type { PackScenarioAdapter, ScenarioDecision,
|
|
34
|
+
export type { PackScenarioAdapter, ScenarioDecision, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord } from './scenario.ts';
|
|
32
35
|
export { emitTwinEvent, eventSubscriptionMatches, listEmittable, runEmitCli } from './emit.ts';
|
|
33
36
|
export type { EmitDeliveryResult, EmitEndpoint, EmitReport, EmittableEvent, SynthesizedDelivery, TwinEmitter } from './emit.ts';
|
|
34
37
|
// The vendor-agnostic CLIENT-SIDE RATE BUDGET — the fail-closed backstop a pack's guarded client
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// How a twin consumes its World's scenario (architecture, "Scenarios"; ADR 0036 §2-4, §10): a decision where its vendor
|
|
2
|
+
// decides, a fault where it lands, a World fact where the vendor answers with one. Every consumption is recorded in the
|
|
3
|
+
// twin's own log as bookkeeping (`_decision`, `_fault`, `_fact`), so the state a run reaches holds what was decided and
|
|
4
|
+
// what failed, a `once` decision or a counted fault is used up by the log rather than by a process's memory, and a branch
|
|
5
|
+
// or a replay agrees. A twin whose head is real consults no scenario: its vendor decides and fails on its own.
|
|
6
|
+
import { headOf } from './head.ts';
|
|
7
|
+
import { applyTwinWriteAtomic, twinResources } from './serve.ts';
|
|
8
|
+
import { durationMs, placedDecisions, placedFaults, worldJsonFile, type PlacedDecision, type PlacedFault } from './scenario-timeline.ts';
|
|
9
|
+
|
|
10
|
+
const WORLD_FACTS_ENV = 'TWIN_WORLD_FACTS_FILE';
|
|
11
|
+
|
|
12
|
+
/** What a consumption reads and writes: the twin's own bookkeeping rows, at the request's World time. `recording` is
|
|
13
|
+
* false for a read-only request, which consumes nothing. */
|
|
14
|
+
export type ScenarioLedger = {
|
|
15
|
+
vendor: string;
|
|
16
|
+
service: string;
|
|
17
|
+
root?: string;
|
|
18
|
+
at: string;
|
|
19
|
+
recording: boolean;
|
|
20
|
+
rows(type: '_decision' | '_fault' | '_fact' | '_rate'): Array<Record<string, unknown>>;
|
|
21
|
+
record(type: '_decision' | '_fault' | '_fact' | '_rate', fields: Record<string, unknown>, id?: string): Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const simulated = (ledger: ScenarioLedger): boolean => headOf(ledger.service, ledger.root).kind !== 'real';
|
|
25
|
+
const usedTimes = (ledger: ScenarioLedger, type: '_decision' | '_fault', index: number): number => ledger.rows(type).filter((r) => r.entry === index).length;
|
|
26
|
+
const available = (ledger: ScenarioLedger, type: '_decision' | '_fault', index: number, limit: number | undefined): boolean => limit === undefined || usedTimes(ledger, type, index) < limit;
|
|
27
|
+
const decisionLeft = (ledger: ScenarioLedger, d: PlacedDecision): boolean => available(ledger, '_decision', d.index, d.once ? 1 : undefined);
|
|
28
|
+
const faultLeft = (ledger: ScenarioLedger, f: PlacedFault): boolean => available(ledger, '_fault', f.index, f.times);
|
|
29
|
+
|
|
30
|
+
/** The vendor's judgement at `point` for `subject`: the first decision the timeline placed for it, else `byDefault`;
|
|
31
|
+
* recorded either way. */
|
|
32
|
+
export async function decide<T>(ledger: ScenarioLedger, point: string, subject: string, byDefault: T): Promise<T> {
|
|
33
|
+
if (!simulated(ledger)) return byDefault;
|
|
34
|
+
const chosen = placedDecisions(ledger.vendor, point, ledger.at).find((d) => d.on === undefined && (d.subject === undefined || d.subject === subject) && decisionLeft(ledger, d));
|
|
35
|
+
const outcome = (chosen ? chosen.outcome : byDefault) as T;
|
|
36
|
+
if (ledger.recording) await ledger.record('_decision', { point, subject, outcome, entry: chosen?.index ?? 'default', decidedAt: ledger.at });
|
|
37
|
+
return outcome;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The model turns the timeline placed for this vendor and not yet used up, in order: what the kernel matches a turn
|
|
41
|
+
* against with the pack's matchers. */
|
|
42
|
+
export function turnDecisions(ledger: ScenarioLedger): PlacedDecision[] {
|
|
43
|
+
if (!simulated(ledger)) return [];
|
|
44
|
+
return placedDecisions(ledger.vendor, 'turn', ledger.at).filter((d) => decisionLeft(ledger, d));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Record a turn's decision: the placed one that answered it, or the labelled default. */
|
|
48
|
+
export async function recordTurn(ledger: ScenarioLedger, chosen: PlacedDecision | undefined, outcome: unknown): Promise<void> {
|
|
49
|
+
if (ledger.recording) await ledger.record('_decision', { point: 'turn', outcome, entry: chosen?.index ?? 'default', decidedAt: ledger.at });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The request faults placed on this vendor that are not used up, in order. */
|
|
53
|
+
export function requestFaults(ledger: ScenarioLedger): PlacedFault[] {
|
|
54
|
+
if (!simulated(ledger)) return [];
|
|
55
|
+
return placedFaults(ledger.vendor, 'request', ledger.at).filter((f) => faultLeft(ledger, f));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Record a fault that landed. */
|
|
59
|
+
export async function recordFault(ledger: ScenarioLedger, fault: PlacedFault, where: Record<string, unknown>): Promise<void> {
|
|
60
|
+
if (ledger.recording) await ledger.record('_fault', { ...where, layer: fault.layer, kind: fault.kind, entry: fault.index, landedAt: ledger.at });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fault the timeline placed at a point of the pack's own (one item of a batch), for `subject`; recorded when it lands. */
|
|
64
|
+
export async function faultAt(ledger: ScenarioLedger, point: string, subject: string): Promise<PlacedFault | undefined> {
|
|
65
|
+
const fault = requestFaults(ledger).find((f) => f.point === point && (f.subject === undefined || f.subject === subject));
|
|
66
|
+
if (fault) await recordFault(ledger, fault, { point, subject });
|
|
67
|
+
return fault;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A ledger over a twin's store alone, for the kernel's own paths that open no context (an event's delivery, a list the
|
|
71
|
+
* core answers). */
|
|
72
|
+
export function serviceLedger(input: { vendor: string; service: string; root?: string; at: string; recording: boolean }): ScenarioLedger {
|
|
73
|
+
const rows = (type: string) => twinResources(input.service, input.root).filter((r) => r.type === type && r.deleted !== true) as Array<Record<string, unknown>>;
|
|
74
|
+
return {
|
|
75
|
+
...input,
|
|
76
|
+
rows,
|
|
77
|
+
record: async (type, fields, id) => {
|
|
78
|
+
await applyTwinWriteAtomic(input.service, (resources) => {
|
|
79
|
+
const prefix = `${type.slice(1)}_`;
|
|
80
|
+
const next = id ?? `${prefix}${1 + Math.max(0, ...resources.filter((r) => r.type === type && r.id.startsWith(prefix)).map((r) => Number(r.id.slice(prefix.length)) || 0))}`;
|
|
81
|
+
return { kind: 'write', value: next, write: { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: next, fields, occurredAt: input.at, actor: { kind: 'system' } } };
|
|
82
|
+
}, input.root);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The first webhook fault placed on this vendor for an event type, not used up. */
|
|
88
|
+
export function webhookFault(ledger: ScenarioLedger, type: string): PlacedFault | undefined {
|
|
89
|
+
return overTimeFaults(ledger).find((f) => f.kind.startsWith('webhook-') && (f.event === undefined || f.event === type) && faultLeft(ledger, f));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `lag` the timeline placed on this vendor's lists (an `operation` names one list), if any. */
|
|
93
|
+
export function listLag(ledger: ScenarioLedger, operation: string): PlacedFault | undefined {
|
|
94
|
+
return overTimeFaults(ledger).find((f) => f.kind === 'lag' && (f.operation === undefined || f.operation === operation));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The over-time faults placed on this vendor, in order. */
|
|
98
|
+
function overTimeFaults(ledger: Pick<ScenarioLedger, 'vendor' | 'service' | 'root' | 'at'>): PlacedFault[] {
|
|
99
|
+
if (headOf(ledger.service, ledger.root).kind === 'real') return [];
|
|
100
|
+
return placedFaults(ledger.vendor, 'over-time', ledger.at);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A rate limit the timeline placed, counted: this request's count in its window, and whether it is past the limit. */
|
|
104
|
+
export async function rateLimited(ledger: ScenarioLedger, operation: string): Promise<PlacedFault | undefined> {
|
|
105
|
+
if (!ledger.recording) return undefined;
|
|
106
|
+
for (const fault of overTimeFaults(ledger)) {
|
|
107
|
+
if (fault.kind !== 'rate-limit' || (fault.operation !== undefined && fault.operation !== operation)) continue;
|
|
108
|
+
const per = durationMs(fault.per!);
|
|
109
|
+
const window = Math.floor((Date.parse(ledger.at) - Date.parse(fault.at)) / per);
|
|
110
|
+
const id = `rate_${fault.index}_${window}`;
|
|
111
|
+
const count = Number(ledger.rows('_rate').find((r) => r.id === id)?.count ?? 0) + 1;
|
|
112
|
+
await ledger.record('_rate', { count, entry: fault.index, window }, id);
|
|
113
|
+
if (count > fault.limit!) { await recordFault(ledger, fault, { operation, count }); return fault; }
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A World fact the twin answers with, recorded as answered (undefined when the World holds none). */
|
|
119
|
+
export async function fact(ledger: ScenarioLedger, kind: string, key: string): Promise<unknown> {
|
|
120
|
+
const value = worldJsonFile<Record<string, Record<string, unknown>>>(WORLD_FACTS_ENV)?.[kind]?.[key];
|
|
121
|
+
if (ledger.recording) await ledger.record('_fact', { kind, key, value: value ?? null, answeredAt: ledger.at });
|
|
122
|
+
return value;
|
|
123
|
+
}
|