@volter/twin-standard 1.0.29 → 1.0.31
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/dist/src/archive-source.d.ts +13 -0
- package/dist/src/archive-source.js +176 -0
- package/dist/src/check-sources.d.ts +19 -2
- package/dist/src/check-sources.js +95 -37
- package/dist/src/cli.js +52 -9
- package/dist/src/clients/ai-gateway.d.ts +1 -0
- package/dist/src/clients/ai-gateway.js +3 -0
- package/dist/src/clients/anthropic-messages.d.ts +10 -0
- package/dist/src/clients/anthropic-messages.js +52 -0
- package/dist/src/clients/anthropic.d.ts +1 -0
- package/dist/src/clients/anthropic.js +3 -0
- package/dist/src/clients/aws.d.ts +2 -0
- package/dist/src/clients/aws.js +137 -0
- package/dist/src/clients/cerebras.d.ts +1 -0
- package/dist/src/clients/cerebras.js +3 -0
- package/dist/src/clients/clerk.d.ts +2 -0
- package/dist/src/clients/clerk.js +99 -0
- package/dist/src/clients/cloudflare.d.ts +2 -0
- package/dist/src/clients/cloudflare.js +86 -0
- package/dist/src/clients/elevenlabs.d.ts +2 -0
- package/dist/src/clients/elevenlabs.js +39 -0
- package/dist/src/clients/gemini.d.ts +2 -0
- package/dist/src/clients/gemini.js +92 -0
- package/dist/src/clients/github.d.ts +2 -0
- package/dist/src/clients/github.js +71 -0
- package/dist/src/clients/groq.d.ts +1 -0
- package/dist/src/clients/groq.js +3 -0
- package/dist/src/clients/harness.d.ts +36 -0
- package/dist/src/clients/harness.js +74 -0
- package/dist/src/clients/merge.d.ts +1 -0
- package/dist/src/clients/merge.js +8 -0
- package/dist/src/clients/openai-compatible.d.ts +9 -0
- package/dist/src/clients/openai-compatible.js +51 -0
- package/dist/src/clients/openai.d.ts +2 -0
- package/dist/src/clients/openai.js +65 -0
- package/dist/src/clients/openrouter.d.ts +1 -0
- package/dist/src/clients/openrouter.js +3 -0
- package/dist/src/clients/perplexity.d.ts +1 -0
- package/dist/src/clients/perplexity.js +3 -0
- package/dist/src/clients/planetscale.d.ts +2 -0
- package/dist/src/clients/planetscale.js +109 -0
- package/dist/src/clients/posthog.d.ts +2 -0
- package/dist/src/clients/posthog.js +145 -0
- package/dist/src/clients/run.d.ts +1 -0
- package/dist/src/clients/run.js +25 -0
- package/dist/src/clients/sentry.d.ts +2 -0
- package/dist/src/clients/sentry.js +167 -0
- package/dist/src/clients/slack.d.ts +2 -0
- package/dist/src/clients/slack.js +107 -0
- package/dist/src/clients/stripe.d.ts +2 -0
- package/dist/src/clients/stripe.js +161 -0
- package/dist/src/clients/tinybird.d.ts +2 -0
- package/dist/src/clients/tinybird.js +71 -0
- package/dist/src/clients/tunnel.d.ts +2 -0
- package/dist/src/clients/tunnel.js +99 -0
- package/dist/src/clients/upstash.d.ts +2 -0
- package/dist/src/clients/upstash.js +143 -0
- package/dist/src/clients/volteridentity.d.ts +2 -0
- package/dist/src/clients/volteridentity.js +128 -0
- package/dist/src/clients/xai.d.ts +2 -0
- package/dist/src/clients/xai.js +23 -0
- package/dist/src/conformance.d.ts +33 -0
- package/dist/src/conformance.js +520 -0
- package/dist/src/derive.js +113 -22
- package/dist/src/gate.js +24 -17
- package/dist/src/index.d.ts +5 -3
- package/dist/src/index.js +5 -3
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/p3-rules.js +0 -2
- package/dist/src/pack-files.d.ts +2 -0
- package/dist/src/pack-files.js +4 -0
- package/dist/src/protocol-3.d.ts +1 -0
- package/dist/src/protocol-3.js +83 -24
- package/dist/src/published.d.ts +6 -0
- package/dist/src/published.js +65 -11
- package/dist/src/spec-documents.js +2 -1
- package/dist/src/spec-ir-client.d.ts +5 -1
- package/dist/src/spec-ir-client.js +12 -2
- package/dist/src/spec-ir.d.ts +7 -2
- package/dist/src/spec-ir.js +42 -10
- package/dist/src/verdict.d.ts +14 -0
- package/dist/src/verdict.js +30 -0
- package/dist/src/walk/behavior-journey.d.ts +47 -0
- package/dist/src/walk/behavior-journey.js +1138 -0
- package/dist/src/walk/git-client.d.ts +26 -0
- package/dist/src/walk/git-client.js +139 -0
- package/dist/src/walk/journey-kit.d.ts +445 -0
- package/dist/src/walk/journey-kit.js +743 -0
- package/dist/src/walk/shape.d.ts +54 -0
- package/dist/src/walk/shape.js +210 -0
- package/dist/src/walk/sse-example.d.ts +2 -0
- package/dist/src/walk/sse-example.js +32 -0
- package/dist/src/walk/vendor-fetch.d.ts +20 -0
- package/dist/src/walk/vendor-fetch.js +92 -0
- package/dist/src/walk/walk-database.d.ts +12 -0
- package/dist/src/walk/walk-database.js +96 -0
- package/dist/src/walk/xml-view.d.ts +2 -0
- package/dist/src/walk/xml-view.js +55 -0
- package/package.json +39 -3
- package/src/archive-source.ts +141 -0
- package/src/check-sources.ts +83 -33
- package/src/cli.ts +40 -8
- package/src/clients/ai-gateway.ts +4 -0
- package/src/clients/anthropic-messages.ts +60 -0
- package/src/clients/anthropic.ts +4 -0
- package/src/clients/aws.ts +119 -0
- package/src/clients/cerebras.ts +4 -0
- package/src/clients/clerk.ts +97 -0
- package/src/clients/cloudflare.ts +80 -0
- package/src/clients/elevenlabs.ts +38 -0
- package/src/clients/gemini.ts +89 -0
- package/src/clients/github.ts +67 -0
- package/src/clients/groq.ts +4 -0
- package/src/clients/harness.ts +101 -0
- package/src/clients/merge.ts +9 -0
- package/src/clients/openai-compatible.ts +59 -0
- package/src/clients/openai.ts +66 -0
- package/src/clients/openrouter.ts +4 -0
- package/src/clients/perplexity.ts +4 -0
- package/src/clients/planetscale.ts +98 -0
- package/src/clients/posthog.ts +132 -0
- package/src/clients/run.ts +18 -0
- package/src/clients/sentry.ts +157 -0
- package/src/clients/slack.ts +98 -0
- package/src/clients/stripe.ts +129 -0
- package/src/clients/tinybird.ts +69 -0
- package/src/clients/tunnel.ts +79 -0
- package/src/clients/upstash.ts +137 -0
- package/src/clients/volteridentity.ts +108 -0
- package/src/clients/xai.ts +25 -0
- package/src/conformance.ts +446 -0
- package/src/derive.ts +87 -16
- package/src/gate.ts +22 -15
- package/src/index.ts +5 -3
- package/src/lanes.ts +7 -0
- package/src/p3-rules.ts +0 -2
- package/src/pack-files.ts +4 -0
- package/src/protocol-3.ts +77 -22
- package/src/published.ts +59 -11
- package/src/spec-documents.ts +2 -1
- package/src/spec-ir-client.ts +15 -4
- package/src/spec-ir.ts +43 -5
- package/src/verdict.ts +28 -0
- package/src/walk/behavior-journey.ts +806 -0
- package/src/walk/git-client.ts +138 -0
- package/src/walk/journey-kit.ts +858 -0
- package/src/walk/shape.ts +208 -0
- package/src/walk/sse-example.ts +25 -0
- package/src/walk/vendor-fetch.ts +80 -0
- package/src/walk/walk-database.ts +97 -0
- package/src/walk/xml-view.ts +44 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
import { parseExactJson } from '@volter/world-core/exact-json';
|
|
2
|
+
import { attributionName } from "../verdict.js";
|
|
3
|
+
// THE WALK — a pack's life (journeys/customer-life.json) walked step by step against the pack's own fetch, on an
|
|
4
|
+
// in-memory World under a pinned clock, reporting what a consumer would hit. What a life IS: ./journey-kit.ts.
|
|
5
|
+
//
|
|
6
|
+
// Every step is checked as it is written (STATUS, EXPECT, CAPTURE), and every successful answer of a derived pack for
|
|
7
|
+
// SHAPE (./shape.ts): the fields and state values the spec gives what the operation answers, and nothing else.
|
|
8
|
+
// score-pack walks the life through it twice, on two fresh roots, and compares what they answer.
|
|
9
|
+
//
|
|
10
|
+
// The caller names the pack repository first (usePacks).
|
|
11
|
+
import { redis } from '@volter/world-core';
|
|
12
|
+
import { gzipSync } from 'node:zlib';
|
|
13
|
+
import { parse, visit } from 'graphql';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { databaseSession, getActiveWorldStore, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActiveWorldStore, setDeliveryObserver, setInProcessTwins } from '@volter/world-core/runtime';
|
|
17
|
+
import { VENDOR_HOSTS } from '@volter/world-core/vendor-hosts';
|
|
18
|
+
import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
|
|
19
|
+
import { isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from "./journey-kit.js";
|
|
20
|
+
import { lanesOf, lineShape, shapeChecker } from "./shape.js";
|
|
21
|
+
import { xmlMembers } from "./xml-view.js";
|
|
22
|
+
/** The pack repository the walk finds a vendor's pack in, set by the caller before its first walk. */
|
|
23
|
+
let packsRoot;
|
|
24
|
+
/** Where a socket step opens: an absolute `ws://` or `wss://` url on its own host, a path on the life's host otherwise. */
|
|
25
|
+
function socketAt(open, host) {
|
|
26
|
+
if (!/^wss?:\/\//.test(open))
|
|
27
|
+
return { host, path: open };
|
|
28
|
+
const u = new URL(open);
|
|
29
|
+
return { host: u.host, path: `${u.pathname}${u.search}` };
|
|
30
|
+
}
|
|
31
|
+
export function usePacks(dir) { packsRoot = dir; }
|
|
32
|
+
const packsDir = () => { if (packsRoot === undefined)
|
|
33
|
+
throw new Error('usePacks(<packs dir>) first'); return packsRoot; };
|
|
34
|
+
/** The walk's World: the twin's own code on its in-memory World and blob stores (the backends a serverless
|
|
35
|
+
* entry runs on), so a walk of any length is state changes in memory and never touches the disk; the World
|
|
36
|
+
* clock, pinned to 2026-01-01, is world state in the same store, and the World is sealed. Each root is a fresh key
|
|
37
|
+
* space in it.
|
|
38
|
+
* Restored after ONE run: this module is imported by journey-walk.test.ts, and a store or clock left active
|
|
39
|
+
* would leak into whichever suite the sweep runs next. */
|
|
40
|
+
export function inMemoryWorld(label) {
|
|
41
|
+
const previousStore = setActiveWorldStore(new MemoryWorldStore());
|
|
42
|
+
const previousBlobs = setActiveBlobStore(new MemoryBlobStore());
|
|
43
|
+
const previousClock = process.env.TWIN_WORLD_CLOCK_FILE;
|
|
44
|
+
// sealed: nothing a walk does reaches a host outside the World (a webhook to a customer's server is refused, not sent)
|
|
45
|
+
const previousEgress = process.env.VOLTER_TWIN_STRICT_EGRESS;
|
|
46
|
+
process.env.VOLTER_TWIN_STRICT_EGRESS = '1';
|
|
47
|
+
const clock = `/memory/${label}/world-clock`;
|
|
48
|
+
getActiveWorldStore().write(clock, '2026-01-01T00:00:00.000Z');
|
|
49
|
+
process.env.TWIN_WORLD_CLOCK_FILE = clock;
|
|
50
|
+
let n = 0;
|
|
51
|
+
return {
|
|
52
|
+
root: () => `/memory/${label}/world-${++n}`,
|
|
53
|
+
restore: () => {
|
|
54
|
+
setActiveWorldStore(previousStore);
|
|
55
|
+
setActiveBlobStore(previousBlobs);
|
|
56
|
+
if (previousClock === undefined)
|
|
57
|
+
delete process.env.TWIN_WORLD_CLOCK_FILE;
|
|
58
|
+
else
|
|
59
|
+
process.env.TWIN_WORLD_CLOCK_FILE = previousClock;
|
|
60
|
+
if (previousEgress === undefined)
|
|
61
|
+
delete process.env.VOLTER_TWIN_STRICT_EGRESS;
|
|
62
|
+
else
|
|
63
|
+
process.env.VOLTER_TWIN_STRICT_EGRESS = previousEgress;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
import { vendorAnswerViews, vendorFetch as vendorFetchFrom, vendorStream } from "./vendor-fetch.js";
|
|
68
|
+
import { gitClone, gitPush } from "./git-client.js";
|
|
69
|
+
/** The type a step's body goes with when it names none, as fetch labels it: JSON for a value or a JSON text, plain text
|
|
70
|
+
* for any other string (an upload's bytes). */
|
|
71
|
+
function defaultType(body) {
|
|
72
|
+
if (typeof body !== 'string')
|
|
73
|
+
return 'application/json';
|
|
74
|
+
try {
|
|
75
|
+
parseExactJson(body);
|
|
76
|
+
return 'application/json';
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return 'text/plain;charset=UTF-8';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const vendorFetch = (vendor) => vendorFetchFrom(packsDir(), vendor);
|
|
83
|
+
const shapes = new Map();
|
|
84
|
+
const shapeOf = (vendor) => { if (!shapes.has(vendor))
|
|
85
|
+
shapes.set(vendor, shapeChecker(join(packsDir(), vendor))); return shapes.get(vendor); };
|
|
86
|
+
/** The routing keys a pack answers under: its descriptor's host rules (pack-facts), each rule's `key` or the vendor's own
|
|
87
|
+
* name, as vendor-hosts.cjs compiles them. */
|
|
88
|
+
function routingKeys(vendor) {
|
|
89
|
+
const name = vendor.split('/')[0];
|
|
90
|
+
const hosts = (packFactsDocument.packs[name]?.hosts) ?? [];
|
|
91
|
+
return [...new Set(hosts.map((rule) => rule.key ?? name))].filter((key) => VENDOR_HOSTS[key] !== undefined);
|
|
92
|
+
}
|
|
93
|
+
/** Whether one of `keys`' host rules claims `url` (an absolute URL; a path is the life's own host). */
|
|
94
|
+
const claims = (keys, url) => {
|
|
95
|
+
if (!/^https?:\/\//.test(url) || !URL.canParse(url))
|
|
96
|
+
return false;
|
|
97
|
+
const u = new URL(url);
|
|
98
|
+
return keys.some((key) => VENDOR_HOSTS[key](u.hostname, u.pathname));
|
|
99
|
+
};
|
|
100
|
+
/** What a stored row keeps for the twin that no schema of the vendor's spec names, by path, in anything the World shows a
|
|
101
|
+
* client: the pack's `_`-prefixed bookkeeping (GitHub's `_links` is the vendor's), and, in what no answer's SHAPE
|
|
102
|
+
* reads (an event's delivery, a socket frame), the kernel's own `updatedAt`, which a raw row filtered on `_` alone
|
|
103
|
+
* carries (Slack's message events once did). The kernel's `deleted` is not read there: vendors send their own
|
|
104
|
+
* (Stripe's deleted customer's `deleted: true`). */
|
|
105
|
+
function leaked(v, vendorFields, at = '', unshaped = false) {
|
|
106
|
+
if (Array.isArray(v))
|
|
107
|
+
return v.flatMap((x, i) => leaked(x, vendorFields, `${at}[${i}]`, unshaped)).slice(0, 5);
|
|
108
|
+
if (!v || typeof v !== 'object')
|
|
109
|
+
return [];
|
|
110
|
+
return Object.entries(v).flatMap(([k, x]) => ((k.startsWith('_') || (unshaped && k === 'updatedAt')) && !vendorFields.has(k) ? [`${at}${at ? '.' : ''}${k}`] : leaked(x, vendorFields, `${at}${at ? '.' : ''}${k}`, unshaped))).slice(0, 5);
|
|
111
|
+
}
|
|
112
|
+
/** Whether a tool_choice forbids every tool call (OpenAI's 'none', Anthropic's { type: 'none' }). */
|
|
113
|
+
const forbidsTools = (choice) => choice === 'none' || (!!choice && typeof choice === 'object' && choice.type === 'none');
|
|
114
|
+
/** The tool calls an answer or frames carry, in any vendor's form: a chat message's `tool_calls`, a Responses or
|
|
115
|
+
* Realtime `function_call` item, an Anthropic `tool_use` block. */
|
|
116
|
+
function toolCallsIn(v, out = []) {
|
|
117
|
+
if (Array.isArray(v)) {
|
|
118
|
+
for (const x of v)
|
|
119
|
+
toolCallsIn(x, out);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
if (!v || typeof v !== 'object')
|
|
123
|
+
return out;
|
|
124
|
+
const o = v;
|
|
125
|
+
if (o.type === 'function_call' || o.type === 'tool_use')
|
|
126
|
+
out.push(String(o.name ?? o.type));
|
|
127
|
+
if (Array.isArray(o.tool_calls) && o.tool_calls.length)
|
|
128
|
+
out.push(...o.tool_calls.map((c) => String(c.function?.name ?? 'tool_calls')));
|
|
129
|
+
for (const [k, x] of Object.entries(o))
|
|
130
|
+
if (k !== 'tool_calls')
|
|
131
|
+
toolCallsIn(x, out);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/** Every field name the vendor's spec gives any resource (the derived surface), per vendor. */
|
|
135
|
+
const fieldsByVendor = new Map();
|
|
136
|
+
function specFields(vendor) {
|
|
137
|
+
if (!fieldsByVendor.has(vendor)) {
|
|
138
|
+
// the pack's surface and each of its lanes' (a walk reaches a lane through the pack's router)
|
|
139
|
+
const files = [join(packsDir(), vendor), ...lanesOf(join(packsDir(), vendor))].map((dir) => join(dir, 'src', 'generated', 'surface.gen.json')).filter((f) => existsSync(f));
|
|
140
|
+
const surfaces = files.map((f) => parseExactJson(readFileSync(f, 'utf8')));
|
|
141
|
+
fieldsByVendor.set(vendor, new Set(surfaces.flatMap((surface) => (surface.resources ?? []).flatMap((r) => r.fields.map((f) => f.name)))));
|
|
142
|
+
}
|
|
143
|
+
return fieldsByVendor.get(vendor);
|
|
144
|
+
}
|
|
145
|
+
/** The complete replies a connection has written since last read, leaving an unfinished one. */
|
|
146
|
+
function takeReplies(c) {
|
|
147
|
+
const out = [];
|
|
148
|
+
let reply;
|
|
149
|
+
let used = 0;
|
|
150
|
+
for (const m of c.out.matchAll(/(\d{3})([ -])([^\r\n]*)\r?\n/g)) {
|
|
151
|
+
(reply ??= { code: Number(m[1]), lines: [] }).lines.push(m[3]);
|
|
152
|
+
if (m[2] === ' ') {
|
|
153
|
+
out.push(reply);
|
|
154
|
+
reply = undefined;
|
|
155
|
+
used = m.index + m[0].length;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
c.out = c.out.slice(used);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
export async function walk(journey, root, collect, opts) {
|
|
162
|
+
attributionName(journey.author);
|
|
163
|
+
const declaredSteps = journey.steps;
|
|
164
|
+
journey = expandFills(journey);
|
|
165
|
+
// Observers name the original declaration; execution uses its expanded body.
|
|
166
|
+
const hooks = opts.hooks;
|
|
167
|
+
const declared = (step) => declaredSteps[journey.steps.indexOf(step)];
|
|
168
|
+
if (hooks)
|
|
169
|
+
opts = { ...opts, hooks: {
|
|
170
|
+
before: hooks.before ? (step, path, body) => hooks.before(declared(step), path, body) : undefined,
|
|
171
|
+
after: hooks.after ? (step, path, answer, failures) => hooks.after(declared(step), path, answer, failures) : undefined,
|
|
172
|
+
} };
|
|
173
|
+
// the life's host is a hostname (the vendor's, `api.x.ai`); a URL there would put a second scheme in every request
|
|
174
|
+
if (journey.host !== undefined && !/^[A-Za-z0-9.-]+(:\d+)?$/.test(journey.host)) {
|
|
175
|
+
collect.push({ step: '(setup)', beat: 'HOST', detail: `the life's host "${journey.host}" is no hostname: write the vendor's host alone (api.example.com), no scheme or path` });
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
const factory = await vendorFetch(journey.vendor);
|
|
179
|
+
const views = await vendorAnswerViews(packsDir(), journey.vendor);
|
|
180
|
+
if (!factory) {
|
|
181
|
+
collect.push({ step: '(setup)', beat: 'ADAPTER', detail: `no create<Name>TwinFetch adapter enrolled for ${journey.vendor}` });
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
// the life's companions (`companions`): the packs its vendor calls, each over this walk's root, on the same World and
|
|
185
|
+
// clock; reached in process by the vendor's ctx.vendorFetch and by a step to a URL their host rules claim
|
|
186
|
+
const companions = [];
|
|
187
|
+
for (const vendor of journey.companions ?? []) {
|
|
188
|
+
const keys = routingKeys(vendor);
|
|
189
|
+
const companion = vendor === journey.vendor ? undefined : await vendorFetch(vendor);
|
|
190
|
+
if (!companion || !keys.length) {
|
|
191
|
+
collect.push({ step: '(setup)', beat: 'ADAPTER', detail: vendor === journey.vendor ? `the life's own vendor ${vendor} is no companion of it` : !companion ? `no create<Name>Fetch adapter for the companion ${vendor}` : `the companion ${vendor} declares no host rules (pack facts) a call could reach it by` });
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
companions.push({ vendor, keys, fetch: companion(root) });
|
|
195
|
+
}
|
|
196
|
+
const ownKeys = routingKeys(journey.vendor);
|
|
197
|
+
/** The companion that serves a step's URL: one whose host rules claim it where the life's own vendor's do not. */
|
|
198
|
+
const companionOf = (url) => (claims(ownKeys, url) ? undefined : companions.find((c) => claims(c.keys, url)));
|
|
199
|
+
// the life's scenario is the World's: written into the walk's World store, which the pack reads it from as a World's
|
|
200
|
+
// handlers/<vendor>.json is read
|
|
201
|
+
const scenarioPath = journey.scenario ? join(packsDir(), journey.vendor, 'journeys', journey.scenario) : undefined;
|
|
202
|
+
if (scenarioPath)
|
|
203
|
+
getActiveWorldStore().write(scenarioPath, readFileSync(scenarioPath, 'utf8'));
|
|
204
|
+
// the application the vendor asks (ctx.ask) is the life's stand-in, this walk's fetch's alone: its answers, in order,
|
|
205
|
+
// by method and URL prefix, each request header it declares checked as sent; a question none takes is unreachable, as
|
|
206
|
+
// in a sealed World
|
|
207
|
+
const answers = [...(journey.application ?? [])];
|
|
208
|
+
// the step being walked, for what the stand-in and the delivery observer report
|
|
209
|
+
let stepNow = '(setup)';
|
|
210
|
+
const application = async (url, init) => {
|
|
211
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
212
|
+
const i = answers.findIndex((a) => (a.method ?? 'POST').toUpperCase() === method && url.startsWith(a.url));
|
|
213
|
+
if (i < 0)
|
|
214
|
+
return 'unreachable';
|
|
215
|
+
const [a] = answers.splice(i, 1);
|
|
216
|
+
const sent = new Headers(init.headers);
|
|
217
|
+
for (const [name, value] of Object.entries(a.requestHeaders ?? {})) {
|
|
218
|
+
if (sent.get(name) !== fill(value, captured))
|
|
219
|
+
collect.push({ step: stepNow, beat: 'APPLICATION', detail: `the vendor request to ${url} did not carry the declared ${name} header` });
|
|
220
|
+
}
|
|
221
|
+
return { status: a.status, ...(a.headers ? { headers: a.headers } : {}), ...(a.bodyError === undefined ? {} : { bodyError: a.bodyError }), body: typeof a.body === 'string' ? a.body : a.body === undefined ? '' : JSON.stringify(a.body) };
|
|
222
|
+
};
|
|
223
|
+
const served = opts.served ? await opts.served(root, scenarioPath ? { scenarioPath } : {}) : undefined;
|
|
224
|
+
const twin = served ? served.fetch : factory(root, { ...(scenarioPath ? { scenarioPath } : {}), application });
|
|
225
|
+
const shape = await shapeOf(journey.vendor);
|
|
226
|
+
const host = journey.host ?? 'twin.local';
|
|
227
|
+
// what an earlier walk captured, when this one continues it (the branch round-trip sends a life's write again)
|
|
228
|
+
const captured = { ...(opts.captured ?? {}) };
|
|
229
|
+
// a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
|
|
230
|
+
const typedValues = {};
|
|
231
|
+
const remember = (name, v) => {
|
|
232
|
+
captured[name] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
233
|
+
typedValues[name] = v;
|
|
234
|
+
};
|
|
235
|
+
const created = [];
|
|
236
|
+
const send = async (step, path, signal) => {
|
|
237
|
+
// raw bytes carry only the type the step names (a client that sends none, Dub's base64 image upload, sends none); a
|
|
238
|
+
// form its own multipart type and boundary
|
|
239
|
+
const headers = { ...(step.formed ? { 'content-type': step.formed.contentType } : step.bodyBase64 === undefined ? { 'content-type': defaultType(step.body) } : {}), ...(journey.headers ?? {}), ...(step.headers ?? {}) };
|
|
240
|
+
const init = { method: step.method, headers, signal };
|
|
241
|
+
if ([step.body, step.bodyBase64, step.formed].filter((b) => b !== undefined).length > 1)
|
|
242
|
+
throw new Error('a step carries one of body, bodyBase64 and form');
|
|
243
|
+
if (step.formed)
|
|
244
|
+
init.body = step.formed.bytes;
|
|
245
|
+
else if (step.bodyBase64 !== undefined)
|
|
246
|
+
init.body = Uint8Array.from(Buffer.from(step.bodyBase64, 'base64'));
|
|
247
|
+
else if (step.body !== undefined)
|
|
248
|
+
init.body = typeof step.body === 'string' ? step.body : JSON.stringify(step.body);
|
|
249
|
+
// a body the client compresses or encodes as it sends it (posthog-js's gzip-js, its base64 form), captures filled first
|
|
250
|
+
if (step.encode && typeof init.body === 'string')
|
|
251
|
+
init.body = step.encode === 'gzip' ? new Uint8Array(gzipSync(Buffer.from(init.body))) : `data=${encodeURIComponent(Buffer.from(init.body).toString('base64'))}`;
|
|
252
|
+
// a step may follow an absolute url an answer gave it (a hosted page's), as the application does
|
|
253
|
+
// a twin that throws answers 500, as a server would: the walk reports the step and goes on to the next
|
|
254
|
+
let res;
|
|
255
|
+
try {
|
|
256
|
+
res = await (companionOf(path)?.fetch ?? twin)(new Request(/^https?:\/\//.test(path) ? path : `http://${host}${path}`, init));
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
res = new Response(`twin threw: ${error instanceof Error ? error.message : String(error)}`, { status: 500 });
|
|
260
|
+
}
|
|
261
|
+
const text = await res.text();
|
|
262
|
+
let body;
|
|
263
|
+
try {
|
|
264
|
+
body = parseExactJson(text);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
body = text;
|
|
268
|
+
}
|
|
269
|
+
// an XML answer (S3's) is its root element's members (xml-view.ts), as a JSON answer is its object
|
|
270
|
+
if (typeof body === 'string' && /xml/.test(res.headers.get('content-type') ?? ''))
|
|
271
|
+
body = xmlMembers(text) ?? text;
|
|
272
|
+
// a hosted flow answers with a redirect: its target and query are what a step captures (redirect.code)
|
|
273
|
+
const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
|
274
|
+
if (location)
|
|
275
|
+
body = { location, redirect: Object.fromEntries(new URL(location, `http://${host}`).searchParams) };
|
|
276
|
+
// every cookie an answer sets, joined as one header: a sign-in sets several, and a map keeps only the last
|
|
277
|
+
const cookies = res.headers.getSetCookie();
|
|
278
|
+
return { status: res.status, body, text, headers: { ...Object.fromEntries(res.headers), ...(cookies.length ? { 'set-cookie': cookies.join(', ') } : {}) } };
|
|
279
|
+
};
|
|
280
|
+
// a wait moves the World clock; the walk puts it back where it found it, so a replay starts at the same instant
|
|
281
|
+
// a step's read-backs are read as the step's own person (its headers, captures filled)
|
|
282
|
+
const stepHeaders = (step) => {
|
|
283
|
+
if (!step.headers)
|
|
284
|
+
return undefined;
|
|
285
|
+
try {
|
|
286
|
+
return parseExactJson(fill(JSON.stringify(step.headers), captured));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
// A line step speaks on a connection of the pack's byte-stream wire, one line at a time, reading the
|
|
293
|
+
// replies each line drew; its answer is checked as a request's is (status, SHAPE, captures, expectations).
|
|
294
|
+
const connections = new Map();
|
|
295
|
+
let streams;
|
|
296
|
+
const lines = lineShape(join(packsDir(), journey.vendor));
|
|
297
|
+
const encoder = new TextEncoder();
|
|
298
|
+
const decoder = new TextDecoder();
|
|
299
|
+
const speak = async (step) => {
|
|
300
|
+
let sent;
|
|
301
|
+
try {
|
|
302
|
+
sent = (Array.isArray(step.send) ? step.send : step.send === undefined ? [] : [step.send]).map((l) => fill(l, captured));
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const failuresBefore = collect.length;
|
|
309
|
+
opts.hooks?.before?.(step, step.on, sent);
|
|
310
|
+
let answered = { status: 0, body: undefined };
|
|
311
|
+
try {
|
|
312
|
+
const drawn = [];
|
|
313
|
+
const shaped = (c, line, got) => {
|
|
314
|
+
drawn.push(...got);
|
|
315
|
+
for (const detail of lines?.check(c.shape, line, got.map((r) => r.code)) ?? [])
|
|
316
|
+
collect.push({ step: step.name, beat: 'SHAPE', detail });
|
|
317
|
+
};
|
|
318
|
+
if (step.connect) {
|
|
319
|
+
// served, a connection is the application's: TCP to the listener the server started with its own options. A step's
|
|
320
|
+
// connect options configure only the in-process stream, which a served walk does not use: they are a finding there,
|
|
321
|
+
// never a way back to the in-process stream (which would pass a listener wired apart from the World)
|
|
322
|
+
if (served?.stream && Object.keys(step.connect).length) {
|
|
323
|
+
collect.push({ step: step.name, beat: 'CONNECTION', detail: `a served walk opens "${step.on}" as the application does, over TCP to the listener; connect options (${Object.keys(step.connect).join(', ')}) configure only the in-process stream` });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const viaServed = served?.stream;
|
|
327
|
+
if (!viaServed)
|
|
328
|
+
streams ??= await vendorStream(packsDir(), journey.vendor);
|
|
329
|
+
if (!viaServed && !streams) {
|
|
330
|
+
collect.push({ step: step.name, beat: 'ADAPTER', detail: `no create<Name>TwinStream adapter enrolled for ${journey.vendor}` });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const opened = { conn: undefined, out: '', closed: false, shape: {} };
|
|
334
|
+
opened.conn = (viaServed ?? streams(root, step.connect))({ write: (bytes) => { opened.out += decoder.decode(bytes); }, end: () => { opened.closed = true; } }, '127.0.0.1');
|
|
335
|
+
await opened.conn.settled();
|
|
336
|
+
connections.set(step.on, opened);
|
|
337
|
+
shaped(opened, null, takeReplies(opened));
|
|
338
|
+
}
|
|
339
|
+
const c = connections.get(step.on);
|
|
340
|
+
if (!c) {
|
|
341
|
+
collect.push({ step: step.name, beat: 'CONNECTION', detail: `no connection "${step.on}" is open` });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
for (const line of sent) {
|
|
345
|
+
if (c.closed) {
|
|
346
|
+
collect.push({ step: step.name, beat: 'CONNECTION', detail: `the server closed "${step.on}" before "${line.slice(0, 40)}"` });
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
c.conn.data(encoder.encode(`${line}\r\n`));
|
|
350
|
+
await c.conn.settled();
|
|
351
|
+
shaped(c, line, takeReplies(c));
|
|
352
|
+
}
|
|
353
|
+
const last = drawn.at(-1);
|
|
354
|
+
const body = { code: last?.code ?? 0, lines: last?.lines ?? [], text: (last?.lines ?? []).join('\n'), replies: drawn };
|
|
355
|
+
answered = { status: body.code, body };
|
|
356
|
+
if (opts.verbose)
|
|
357
|
+
console.log(` ${String(body.code).padEnd(4)} ${step.on.padEnd(6)} ${(sent[0] ?? '(connect)').slice(0, 70).padEnd(70)} ${step.name}`);
|
|
358
|
+
const ok = step.status === undefined ? body.code > 0 && body.code < 400 : body.code === step.status;
|
|
359
|
+
if (!ok) {
|
|
360
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `${step.on} answered ${body.code}${step.status ? ` (wanted ${step.status})` : ' (wanted below 400)'}: ${body.text.slice(0, 160)}` });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
364
|
+
const v = captureAt(body, dot);
|
|
365
|
+
if (v === undefined || v === null) {
|
|
366
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
remember(name, v);
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
for (const f of step.expect ? expectFailures(step.expect, body, captured) : [])
|
|
373
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail: f });
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
opts.hooks?.after?.(step, step.on, answered, collect.slice(failuresBefore));
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
// Typed stream steps drive the same pack adapter as the owned TCP listener.
|
|
384
|
+
let respStream;
|
|
385
|
+
const respConnections = new Map();
|
|
386
|
+
const respStep = async (step) => {
|
|
387
|
+
const before = collect.length;
|
|
388
|
+
let answered = { status: 200, body: undefined };
|
|
389
|
+
opts.hooks?.before?.(step, step.connection, step.resp);
|
|
390
|
+
try {
|
|
391
|
+
if (step.connect) {
|
|
392
|
+
if (served?.stream && Object.keys(step.connect).length)
|
|
393
|
+
throw new Error('a served RESP walk configures its server, not the client connection');
|
|
394
|
+
if (!served?.stream)
|
|
395
|
+
streams ??= await vendorStream(packsDir(), journey.vendor);
|
|
396
|
+
const factory = served?.stream ?? (respStream ??= streams?.(root, step.connect));
|
|
397
|
+
if (!factory)
|
|
398
|
+
throw new Error('no byte-stream adapter');
|
|
399
|
+
const c = { conn: undefined, out: '', closed: false };
|
|
400
|
+
c.conn = factory({ write: (b) => { c.out += redis.binaryText(b); }, end: () => { c.closed = true; } }, '127.0.0.1');
|
|
401
|
+
respConnections.set(step.connection, c);
|
|
402
|
+
await c.conn.settled();
|
|
403
|
+
}
|
|
404
|
+
const c = respConnections.get(step.connection);
|
|
405
|
+
if (!c)
|
|
406
|
+
throw new Error(`no connection ${step.connection}`);
|
|
407
|
+
if (step.resp) {
|
|
408
|
+
const commands = Array.isArray(step.resp[0]) ? step.resp : [step.resp];
|
|
409
|
+
for (const command of commands) {
|
|
410
|
+
const args = command.map((a) => typeof a === 'string' ? redis.binaryText(encoder.encode(fill(a, captured))) : redis.binaryText(Uint8Array.from(atob(a.base64), (x) => x.charCodeAt(0))));
|
|
411
|
+
c.conn.data(redis.binaryBytes(redis.respReply(args))); // bulk arguments remain binary, including MessagePack
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
await c.conn.settled();
|
|
415
|
+
const replies = [];
|
|
416
|
+
const frameTypes = [];
|
|
417
|
+
let error = null;
|
|
418
|
+
while (c.out.length) {
|
|
419
|
+
const frame = redis.readResp(c.out);
|
|
420
|
+
if (!frame)
|
|
421
|
+
break;
|
|
422
|
+
c.out = c.out.slice(frame.end);
|
|
423
|
+
frameTypes.push(frame.type);
|
|
424
|
+
replies.push(frame.error ? { error: frame.value } : frame.value);
|
|
425
|
+
if (frame.error)
|
|
426
|
+
error = frame.value;
|
|
427
|
+
}
|
|
428
|
+
const body = { replies, last: replies.at(-1) ?? null, error, frameTypes };
|
|
429
|
+
answered = { status: error ? 400 : 200, body };
|
|
430
|
+
if (opts.verbose)
|
|
431
|
+
console.log(` RESP ${step.connection} ${step.name}`);
|
|
432
|
+
if (step.resp && !step.pending && replies.length === 0)
|
|
433
|
+
collect.push({ step: step.name, beat: 'RESP', detail: 'the command returned no reply (declare pending for a blocking command)' });
|
|
434
|
+
if (step.refused ? !error?.startsWith(step.refused) : Boolean(error))
|
|
435
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `RESP ${error ?? 'success'}, wanted ${step.refused ?? 'success'}` });
|
|
436
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
437
|
+
const value = captureAt(body, dot);
|
|
438
|
+
if (value === undefined || value === null)
|
|
439
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at ${dot}` });
|
|
440
|
+
else
|
|
441
|
+
remember(name, value);
|
|
442
|
+
}
|
|
443
|
+
for (const detail of expectFailures(step.expect ?? [], body, captured))
|
|
444
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail });
|
|
445
|
+
if (step.close) {
|
|
446
|
+
c.conn.close();
|
|
447
|
+
respConnections.delete(step.connection);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
catch (e) {
|
|
451
|
+
collect.push({ step: step.name, beat: 'RESP', detail: e.message });
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
opts.hooks?.after?.(step, step.connection, answered, collect.slice(before));
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
// A git step is a git client's clone or push on the pack's smart-HTTP plane (packages/twin-standard/src/walk/git-client.ts); its answer is
|
|
458
|
+
// checked as a request's is (status, captures, expectations).
|
|
459
|
+
const gitStep = async (step) => {
|
|
460
|
+
let headers;
|
|
461
|
+
let files;
|
|
462
|
+
try {
|
|
463
|
+
headers = parseExactJson(fill(JSON.stringify({ ...(journey.headers ?? {}), ...(step.headers ?? {}) }), captured));
|
|
464
|
+
files = step.files && parseExactJson(fill(JSON.stringify(step.files), captured));
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
delete headers['content-type'];
|
|
471
|
+
const where = `/${step.repo}.git`;
|
|
472
|
+
const failuresBefore = collect.length;
|
|
473
|
+
opts.hooks?.before?.(step, where, files);
|
|
474
|
+
let answered = { status: 0, body: undefined };
|
|
475
|
+
try {
|
|
476
|
+
const base = `http://${host}${where}`;
|
|
477
|
+
const res = step.git === 'clone'
|
|
478
|
+
? await gitClone(twin, base, { ...(step.ref ? { ref: step.ref } : {}) }, headers)
|
|
479
|
+
: await gitPush(twin, base, { ref: step.ref ?? 'refs/heads/main', ...(files ? { files } : {}), ...(step.message ? { message: step.message } : {}), ...(step.author ? { author: step.author } : {}), ...(step.amend ? { amend: true } : {}), ...(step.delete ? { delete: true } : {}) }, headers);
|
|
480
|
+
answered = { status: res.status, body: res.body };
|
|
481
|
+
if (opts.verbose)
|
|
482
|
+
console.log(` ${String(res.status).padEnd(4)} ${`git ${step.git}`.padEnd(10)} ${where.slice(0, 66).padEnd(66)} ${step.name}`);
|
|
483
|
+
if (res.status !== (step.status ?? 200)) {
|
|
484
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `git ${step.git} ${where} answered ${res.status} (wanted ${step.status ?? 200}): ${res.text.slice(0, 160)}` });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
488
|
+
const v = captureAt(res.body, dot);
|
|
489
|
+
if (v === undefined || v === null) {
|
|
490
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
remember(name, v);
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
for (const f of step.expect ? expectFailures(step.expect, res.body, captured) : [])
|
|
497
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail: f });
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
opts.hooks?.after?.(step, where, answered, collect.slice(failuresBefore));
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
const sockets = new Map();
|
|
508
|
+
// a session's frames arrive on its own queue, after the request that wrote them: the walk lets the queues run out
|
|
509
|
+
// (a context's reads are synchronous in a walk's in-memory World; a timer turn after the immediates lets a slower one land)
|
|
510
|
+
const settle = async () => { for (let i = 0; i < 25; i++)
|
|
511
|
+
await new Promise((r) => setImmediate(r)); await new Promise((r) => setTimeout(r, 5)); };
|
|
512
|
+
/** A socket step's answer: the frames that arrived, checked (LEAK, TOOLS) and read for its status, captures and
|
|
513
|
+
* expectations as an HTTP step's answer is. */
|
|
514
|
+
const answerSocket = (step, c) => {
|
|
515
|
+
const got = c.frames.splice(0);
|
|
516
|
+
for (const key of leaked(got, specFields(journey.vendor), '', true))
|
|
517
|
+
collect.push({ step: step.name, beat: 'LEAK', detail: `a frame on ${step.socket} carries the twin's own field ${key}` });
|
|
518
|
+
// the server's echo of an item the client made (conversation.item.created) is the client's, not an answer
|
|
519
|
+
if (c.noTools)
|
|
520
|
+
for (const call of toolCallsIn(got.filter((g) => !(g && typeof g === 'object' && g.type === 'conversation.item.created'))))
|
|
521
|
+
collect.push({ step: step.name, beat: 'TOOLS', detail: `${step.socket} answered a tool call (${call}) to a turn whose tool_choice is none` });
|
|
522
|
+
const body = { frames: got, last: got.at(-1) ?? null, closed: c.closed };
|
|
523
|
+
const status = c.closed ? c.closed.code : 101;
|
|
524
|
+
if (opts.verbose)
|
|
525
|
+
console.log(` ${String(status).padEnd(4)} ${`ws ${step.socket}`.padEnd(10)} ${`${got.length} frame(s)`.padEnd(66)} ${step.name}`);
|
|
526
|
+
if (status !== (step.status ?? 101)) {
|
|
527
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `${step.socket} is ${c.closed ? `closed ${status} ${c.closed.reason}` : 'open'} (wanted ${step.status ?? 101})` });
|
|
528
|
+
return { status, body };
|
|
529
|
+
}
|
|
530
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
531
|
+
const v = captureAt(body, dot);
|
|
532
|
+
if (v === undefined || v === null) {
|
|
533
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
remember(name, v);
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
for (const f of step.expect ? expectFailures(step.expect, body, captured) : [])
|
|
540
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail: f });
|
|
541
|
+
}
|
|
542
|
+
catch (e) {
|
|
543
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
544
|
+
}
|
|
545
|
+
return { status, body };
|
|
546
|
+
};
|
|
547
|
+
/** What a turn's frames say of tools: a session's or a response's `tool_choice` the client sets (none forbids a call). */
|
|
548
|
+
const toolsBeat = (_step, frames, c) => {
|
|
549
|
+
// the session's choice holds until changed; a response's own choice holds for that response alone
|
|
550
|
+
let response;
|
|
551
|
+
for (const f of frames) {
|
|
552
|
+
let o;
|
|
553
|
+
try {
|
|
554
|
+
o = parseExactJson(f);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const session = o.session;
|
|
560
|
+
if (session && typeof session === 'object' && 'tool_choice' in session)
|
|
561
|
+
c.sessionNoTools = forbidsTools(session.tool_choice);
|
|
562
|
+
const own = o.response;
|
|
563
|
+
if (o.type === 'response.create')
|
|
564
|
+
response = own && typeof own === 'object' && 'tool_choice' in own ? forbidsTools(own.tool_choice) : undefined;
|
|
565
|
+
}
|
|
566
|
+
c.noTools = response ?? c.sessionNoTools;
|
|
567
|
+
};
|
|
568
|
+
const servedSocketStep = async (step) => {
|
|
569
|
+
let frames;
|
|
570
|
+
try {
|
|
571
|
+
frames = (Array.isArray(step.send) ? step.send : step.send === undefined ? [] : [step.send]).map((f) => fill(typeof f === 'string' ? f : JSON.stringify(f), captured));
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
opts.hooks?.before?.(step, step.socket, frames);
|
|
578
|
+
if (step.open !== undefined) {
|
|
579
|
+
// an absolute ws(s):// url opens on its own host (a client that dials the vendor's socket host itself), as a step
|
|
580
|
+
// may follow an absolute https url
|
|
581
|
+
const at = socketAt(fill(step.open, captured), host);
|
|
582
|
+
const opening = { host: at.host };
|
|
583
|
+
for (const [k, v] of Object.entries(step.headers ?? {}))
|
|
584
|
+
opening[k] = fill(v, captured);
|
|
585
|
+
const opened = { frames: [], closed: null, peer: { send: () => undefined, close: () => undefined } };
|
|
586
|
+
const ws = served.socket(at.path, opening);
|
|
587
|
+
ws.onmessage = (e) => { const text = String(e.data); try {
|
|
588
|
+
opened.frames.push(parseExactJson(text));
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
opened.frames.push(text);
|
|
592
|
+
} };
|
|
593
|
+
ws.onclose = (e) => { opened.closed ??= { code: e.code, reason: e.reason }; };
|
|
594
|
+
const up = await new Promise((done) => { ws.onopen = () => done(true); ws.onerror = () => done(false); setTimeout(() => done(false), 5000); });
|
|
595
|
+
if (!up) {
|
|
596
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `no socket at ${step.open}` });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
sockets.set(step.socket, opened);
|
|
600
|
+
wire.set(step.socket, ws);
|
|
601
|
+
}
|
|
602
|
+
const c = sockets.get(step.socket);
|
|
603
|
+
const ws = wire.get(step.socket);
|
|
604
|
+
if (!c || !ws) {
|
|
605
|
+
collect.push({ step: step.name, beat: 'CONNECTION', detail: `no socket "${step.socket}" is open` });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
toolsBeat(step, frames, c);
|
|
609
|
+
for (const frame of frames) {
|
|
610
|
+
await settleWire(c);
|
|
611
|
+
if (c.closed) {
|
|
612
|
+
collect.push({ step: step.name, beat: 'CONNECTION', detail: `the server closed "${step.socket}" before "${frame.slice(0, 40)}"` });
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
ws.send(frame);
|
|
616
|
+
}
|
|
617
|
+
if (step.close) {
|
|
618
|
+
ws.close();
|
|
619
|
+
c.closed ??= { code: 1000, reason: '' };
|
|
620
|
+
}
|
|
621
|
+
await settleWire(c, step);
|
|
622
|
+
const failuresBefore = collect.length;
|
|
623
|
+
const answered = answerSocket(step, c);
|
|
624
|
+
opts.hooks?.after?.(step, step.socket, answered, collect.slice(failuresBefore));
|
|
625
|
+
};
|
|
626
|
+
// a served pack's socket is opened as an application opens it, a WebSocket to the served port; its frames are read as
|
|
627
|
+
// they arrive (the steps' settle waits until none has come for a moment)
|
|
628
|
+
const wire = new Map();
|
|
629
|
+
const settleWire = async (c, step) => {
|
|
630
|
+
const deadline = Date.now() + 10_000;
|
|
631
|
+
let seen = -1;
|
|
632
|
+
while (Date.now() < deadline) {
|
|
633
|
+
const body = { frames: c.frames, last: c.frames.at(-1) ?? null, closed: c.closed };
|
|
634
|
+
let expected = true;
|
|
635
|
+
try {
|
|
636
|
+
expected = (!step?.status || step.status === (c.closed?.code ?? 101))
|
|
637
|
+
&& !expectFailures(step?.expect ?? [], body, captured).length
|
|
638
|
+
&& Object.values(step?.capture ?? {}).every((path) => captureAt(body, path) != null);
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
expected = false;
|
|
642
|
+
}
|
|
643
|
+
if (c.frames.length === seen && (expected || c.closed))
|
|
644
|
+
return;
|
|
645
|
+
seen = c.frames.length;
|
|
646
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
const socketStep = async (step) => {
|
|
650
|
+
if (served?.socket)
|
|
651
|
+
return servedSocketStep(step);
|
|
652
|
+
const upgrade = twin.upgrade;
|
|
653
|
+
if (!upgrade) {
|
|
654
|
+
collect.push({ step: step.name, beat: 'ADAPTER', detail: `${journey.vendor} declares no socket` });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
let frames;
|
|
658
|
+
try {
|
|
659
|
+
frames = (Array.isArray(step.send) ? step.send : step.send === undefined ? [] : [step.send]).map((f) => fill(typeof f === 'string' ? f : JSON.stringify(f), captured));
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const failuresBefore = collect.length;
|
|
666
|
+
opts.hooks?.before?.(step, step.socket, frames);
|
|
667
|
+
let answered = { status: 0, body: undefined };
|
|
668
|
+
try {
|
|
669
|
+
if (step.open !== undefined) {
|
|
670
|
+
const opening = {};
|
|
671
|
+
for (const [k, v] of Object.entries(step.headers ?? {}))
|
|
672
|
+
opening[k] = fill(v, captured);
|
|
673
|
+
const at = socketAt(fill(step.open, captured), host);
|
|
674
|
+
const request = new Request(`http://${at.host}${at.path}`, { headers: { ...opening, upgrade: 'websocket', connection: 'Upgrade' } });
|
|
675
|
+
if (!upgrade.accepts(request)) {
|
|
676
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `no socket at ${step.open}` });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const opened = { frames: [], closed: null, peer: { send: () => undefined, close: () => undefined } };
|
|
680
|
+
opened.peer = {
|
|
681
|
+
send: (d) => { const text = typeof d === 'string' ? d : decoder.decode(d); try {
|
|
682
|
+
opened.frames.push(parseExactJson(text));
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
opened.frames.push(text);
|
|
686
|
+
} },
|
|
687
|
+
// a server's close ends the session as the serve seam's close does, so the kernel lets it go
|
|
688
|
+
close: (code, reason) => { if (!opened.closed) {
|
|
689
|
+
opened.closed = { code: code ?? 1000, reason: reason ?? '' };
|
|
690
|
+
upgrade.close(opened.peer);
|
|
691
|
+
} },
|
|
692
|
+
};
|
|
693
|
+
sockets.set(step.socket, opened);
|
|
694
|
+
upgrade.open(opened.peer, request);
|
|
695
|
+
}
|
|
696
|
+
const c = sockets.get(step.socket);
|
|
697
|
+
if (!c) {
|
|
698
|
+
collect.push({ step: step.name, beat: 'CONNECTION', detail: `no socket "${step.socket}" is open` });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
toolsBeat(step, frames, c);
|
|
702
|
+
for (const frame of frames) {
|
|
703
|
+
await settle();
|
|
704
|
+
if (c.closed) {
|
|
705
|
+
collect.push({ step: step.name, beat: 'CONNECTION', detail: `the server closed "${step.socket}" before "${frame.slice(0, 40)}"` });
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
await upgrade.message(c.peer, frame);
|
|
709
|
+
}
|
|
710
|
+
if (step.close) {
|
|
711
|
+
upgrade.close(c.peer);
|
|
712
|
+
c.closed ??= { code: 1000, reason: '' };
|
|
713
|
+
}
|
|
714
|
+
await settle();
|
|
715
|
+
answered = answerSocket(step, c);
|
|
716
|
+
}
|
|
717
|
+
finally {
|
|
718
|
+
opts.hooks?.after?.(step, step.socket, answered, collect.slice(failuresBefore));
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
// A SQL step is what the application sends the World's managed database itself (its migrations and queries): on one
|
|
722
|
+
// connection of its own to the database this walk's World binds (vendor-fetch.ts), held across the walk's SQL steps
|
|
723
|
+
// as the application holds its client; a refusal fails it with its SQLSTATE unless the step expects that one.
|
|
724
|
+
let sqlSession;
|
|
725
|
+
const sqlStep = async (step) => {
|
|
726
|
+
const failuresBefore = collect.length;
|
|
727
|
+
opts.hooks?.before?.(step, 'sql', step.sql);
|
|
728
|
+
let answered = { status: 0, body: undefined };
|
|
729
|
+
try {
|
|
730
|
+
const url = await twin.managedDatabaseUrl?.();
|
|
731
|
+
if (url === undefined) {
|
|
732
|
+
collect.push({ step: step.name, beat: 'ADAPTER', detail: `${journey.vendor} binds no managed database in this walk, so a SQL step has none to run on` });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
let results;
|
|
736
|
+
try {
|
|
737
|
+
sqlSession ??= databaseSession(url);
|
|
738
|
+
// the SQL is the application's own text, sent as written (a `{presets}` in it is a JSON path, not a capture)
|
|
739
|
+
results = await (await sqlSession).query(step.sql, step.values?.map((v) => (v === null ? null : fill(v, captured))));
|
|
740
|
+
}
|
|
741
|
+
catch (e) {
|
|
742
|
+
const code = e.code ?? 'XX000';
|
|
743
|
+
answered = { status: 1, body: { code, message: e.message } };
|
|
744
|
+
if (step.refused !== code)
|
|
745
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `Postgres refused it: ${code} ${e.message}${step.refused ? ` (wanted ${step.refused})` : ''}` });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const last = results.at(-1) ?? { fields: [], rows: [], command: '' };
|
|
749
|
+
const body = { command: last.command, rows: last.rows.map((row) => Object.fromEntries(last.fields.map((field, i) => [field, row[i] ?? null]))) };
|
|
750
|
+
answered = { status: 0, body };
|
|
751
|
+
if (step.refused !== undefined) {
|
|
752
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `Postgres completed it (${last.command}), wanted ${step.refused}` });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
756
|
+
const v = captureAt(body, dot);
|
|
757
|
+
if (v === undefined || v === null) {
|
|
758
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
remember(name, v);
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
for (const f of step.expect ? expectFailures(step.expect, body, captured) : [])
|
|
765
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail: f });
|
|
766
|
+
}
|
|
767
|
+
catch (e) {
|
|
768
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
finally {
|
|
772
|
+
opts.hooks?.after?.(step, 'sql', answered, collect.slice(failuresBefore));
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
const pending = new Map();
|
|
776
|
+
// the clock is world state, read and moved through the active World store (in memory for a walk)
|
|
777
|
+
const clockFile = process.env.TWIN_WORLD_CLOCK_FILE;
|
|
778
|
+
const store = getActiveWorldStore();
|
|
779
|
+
const startedAt = clockFile && store.exists(clockFile) ? (store.read(clockFile) ?? undefined) : undefined;
|
|
780
|
+
// a life that begins before the World's day one (`startsAt`: an account whose story starts while an API it used still
|
|
781
|
+
// made what it no longer makes) starts its clock there; its waits move it on, and the walk puts it back at the end
|
|
782
|
+
if (journey.startsAt !== undefined) {
|
|
783
|
+
if (!clockFile || startedAt === undefined)
|
|
784
|
+
collect.push({ step: '(setup)', beat: 'WAIT', detail: 'the life starts at its own instant (startsAt), and the walk has no World clock to set' });
|
|
785
|
+
else if (Number.isNaN(Date.parse(journey.startsAt)))
|
|
786
|
+
collect.push({ step: '(setup)', beat: 'WAIT', detail: `startsAt ${journey.startsAt} is no instant` });
|
|
787
|
+
else
|
|
788
|
+
store.write(clockFile, new Date(journey.startsAt).toISOString());
|
|
789
|
+
}
|
|
790
|
+
// what the World sends a receiver (an event's delivery) is read as the receiver reads it, for the twin's own fields
|
|
791
|
+
const previousObserver = setDeliveryObserver((d) => {
|
|
792
|
+
let body;
|
|
793
|
+
try {
|
|
794
|
+
body = parseExactJson(d.body);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
for (const key of leaked(body, specFields(journey.vendor), '', true))
|
|
800
|
+
collect.push({ step: stepNow, beat: 'LEAK', detail: `the event sent to ${d.url} carries the twin's own field ${key}` });
|
|
801
|
+
});
|
|
802
|
+
const previousTwins = setInProcessTwins(Object.fromEntries([
|
|
803
|
+
...companions.flatMap((c) => c.keys.map((key) => [key, c.fetch])),
|
|
804
|
+
...ownKeys.map((key) => [key, twin]),
|
|
805
|
+
]));
|
|
806
|
+
try {
|
|
807
|
+
for (const step of journey.steps) {
|
|
808
|
+
stepNow = step.name;
|
|
809
|
+
if (isResp(step)) {
|
|
810
|
+
await respStep(step);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (isLine(step)) {
|
|
814
|
+
await speak(step);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
if (isGit(step)) {
|
|
818
|
+
await gitStep(step);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (isSocket(step)) {
|
|
822
|
+
await socketStep(step);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (isSql(step)) {
|
|
826
|
+
await sqlStep(step);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (isWait(step)) {
|
|
830
|
+
if (!clockFile || startedAt === undefined) {
|
|
831
|
+
collect.push({ step: step.name, beat: 'WAIT', detail: 'the walk has no World clock to move' });
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
store.write(clockFile, new Date(Date.parse((store.read(clockFile) ?? '').trim()) + waitMs(step.wait)).toISOString());
|
|
836
|
+
}
|
|
837
|
+
catch (e) {
|
|
838
|
+
collect.push({ step: step.name, beat: 'WAIT', detail: e.message });
|
|
839
|
+
}
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
let path;
|
|
843
|
+
try {
|
|
844
|
+
path = fill(step.path, captured);
|
|
845
|
+
}
|
|
846
|
+
catch (e) {
|
|
847
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
// a presigned URL is signed again with the key that presigned it, as its client's SDK signed it
|
|
851
|
+
if (step.presign) {
|
|
852
|
+
try {
|
|
853
|
+
path = presignedPath(step.presign, captured, step.method, path, host, fillJson({ ...(step.bodyBase64 === undefined ? { 'content-type': defaultType(step.body) } : {}), ...(journey.headers ?? {}), ...(step.headers ?? {}) }, captured));
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const serving = companionOf(path)?.vendor ?? journey.vendor;
|
|
861
|
+
const graphqlBody = existsSync(join(packsDir(), serving, 'src', 'generated', 'graphql-sdl.gen.json'));
|
|
862
|
+
let body;
|
|
863
|
+
let headers;
|
|
864
|
+
// a form's bytes, built before anything signs the request, so a signature covers what is sent
|
|
865
|
+
let formed;
|
|
866
|
+
// a header carries captures as a path or a body does: the token an earlier step was issued
|
|
867
|
+
try {
|
|
868
|
+
body = fillJson(step.body, captured, typedValues, graphqlBody);
|
|
869
|
+
if (step.form)
|
|
870
|
+
formed = formBody(step.form, captured);
|
|
871
|
+
headers = step.headers === undefined ? undefined : fillJson(step.headers, captured);
|
|
872
|
+
// an OAuth 1.0a step is signed as its client signs it, over the URL it sends to, at the World clock's instant
|
|
873
|
+
if (step.oauth1) {
|
|
874
|
+
const o = parseExactJson(fill(JSON.stringify(step.oauth1), captured));
|
|
875
|
+
const url = /^https?:\/\//.test(path) ? path : `http://${host}${path}`;
|
|
876
|
+
// a form is signed as it is sent: its multipart type, whose parts OAuth 1.0a's base string never carries
|
|
877
|
+
const sent = formed ? undefined : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
|
|
878
|
+
const contentType = Object.entries(headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1] ?? (formed ? formed.contentType : step.bodyBase64 === undefined ? defaultType(body) : undefined);
|
|
879
|
+
const instant = clockFile && store.exists(clockFile) ? Date.parse((store.read(clockFile) ?? '').trim()) : Number.NaN;
|
|
880
|
+
headers = { ...(headers ?? {}), authorization: oauth1Authorization(o, step.method, url, sent, contentType, Number.isNaN(instant) ? 0 : instant) };
|
|
881
|
+
}
|
|
882
|
+
if (step.jwt)
|
|
883
|
+
headers = { ...(headers ?? {}), authorization: jwtAuthorization(step.jwt, captured) };
|
|
884
|
+
// an AWS SDK's request is signed as it sends it, at the World clock's instant
|
|
885
|
+
if (step.sigv4) {
|
|
886
|
+
const url = /^https?:\/\//.test(path) ? path : `http://${host}${path}`;
|
|
887
|
+
// the bytes and the type sent: a form's multipart body and boundary, raw bytes, or the body's text
|
|
888
|
+
const sent = formed ? formed.bytes : step.bodyBase64 !== undefined ? Uint8Array.from(Buffer.from(step.bodyBase64, 'base64')) : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
|
|
889
|
+
const instant = clockFile && store.exists(clockFile) ? Date.parse((store.read(clockFile) ?? '').trim()) : Number.NaN;
|
|
890
|
+
const base = { ...(formed ? { 'content-type': formed.contentType } : step.bodyBase64 === undefined ? { 'content-type': defaultType(body) } : {}), ...(journey.headers ?? {}), ...(headers ?? {}) };
|
|
891
|
+
headers = { ...(headers ?? {}), ...sigv4Headers(step.sigv4, captured, step.method, url, base, sent, Number.isNaN(instant) ? 0 : instant) };
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch (e) {
|
|
895
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (step.defer) {
|
|
899
|
+
if (step.await || pending.has(step.defer) || step.expect?.length || step.capture || step.status !== undefined || step.creates || step.changes || step.check) {
|
|
900
|
+
collect.push({ step: step.name, beat: 'PENDING', detail: 'a deferred request needs a unique key and no response checks or state beats' });
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const abort = new AbortController();
|
|
904
|
+
const timer = setTimeout(() => abort.abort(new Error('deferred request exceeded 10 s')), 10_000);
|
|
905
|
+
const answer = Promise.race([
|
|
906
|
+
send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path, abort.signal),
|
|
907
|
+
new Promise((_, reject) => abort.signal.addEventListener('abort', () => reject(abort.signal.reason), { once: true })),
|
|
908
|
+
]).finally(() => clearTimeout(timer));
|
|
909
|
+
// A rejected body read must not become an unhandled promise while socket steps run.
|
|
910
|
+
void answer.catch(() => undefined);
|
|
911
|
+
pending.set(step.defer, { method: step.method, path, body, headers, answer, abort, timer });
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const deferred = step.await ? pending.get(step.await) : undefined;
|
|
915
|
+
if (step.await && (!deferred || deferred.method !== step.method || deferred.path !== path)) {
|
|
916
|
+
collect.push({ step: step.name, beat: 'PENDING', detail: 'no deferred request with this key, method and path' });
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
if (deferred) {
|
|
920
|
+
if (step.creates || step.changes || (step.body !== undefined && JSON.stringify(body) !== JSON.stringify(deferred.body)) || (step.headers !== undefined && JSON.stringify(headers) !== JSON.stringify(deferred.headers))) {
|
|
921
|
+
collect.push({ step: step.name, beat: 'PENDING', detail: 'deferred collects cannot change dispatched body or headers, or request state beats' });
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
body = deferred.body;
|
|
925
|
+
headers = deferred.headers;
|
|
926
|
+
}
|
|
927
|
+
// LANDED beat, part one: what the write is supposed to change, as it stands BEFORE the write.
|
|
928
|
+
let beforeChange;
|
|
929
|
+
let changesPath;
|
|
930
|
+
if (step.changes && opts.beats) {
|
|
931
|
+
try {
|
|
932
|
+
changesPath = fill(step.changes, captured);
|
|
933
|
+
}
|
|
934
|
+
catch (e) {
|
|
935
|
+
collect.push({ step: step.name, beat: 'LANDED', detail: e.message });
|
|
936
|
+
}
|
|
937
|
+
if (changesPath)
|
|
938
|
+
beforeChange = (await send({ method: 'GET', headers: stepHeaders(step) }, changesPath)).text;
|
|
939
|
+
}
|
|
940
|
+
const failuresBefore = collect.length;
|
|
941
|
+
opts.hooks?.before?.(step, path, body);
|
|
942
|
+
let answered = { status: 0, body: undefined };
|
|
943
|
+
// every step that ran reports, however it ended: a scorer counts the failing ones too
|
|
944
|
+
try {
|
|
945
|
+
let res;
|
|
946
|
+
if (deferred) {
|
|
947
|
+
pending.delete(step.await);
|
|
948
|
+
try {
|
|
949
|
+
res = await deferred.answer;
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
collect.push({ step: step.name, beat: 'PENDING', detail: String(error) });
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
else
|
|
957
|
+
res = await send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path);
|
|
958
|
+
answered = { status: res.status, body: res.body, headers: res.headers };
|
|
959
|
+
if (opts.verbose)
|
|
960
|
+
console.log(` ${String(res.status).padEnd(4)} ${step.method.padEnd(6)} ${path.slice(0, 70).padEnd(70)} ${step.name}`);
|
|
961
|
+
const wanted = step.status;
|
|
962
|
+
const ok = wanted === undefined ? res.status >= 200 && res.status < 300 : res.status === wanted;
|
|
963
|
+
if (!ok) {
|
|
964
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: `${step.method} ${path} answered ${res.status}${wanted ? ` (wanted ${wanted})` : ' (wanted 2xx)'}: ${res.text.slice(0, 160)}` });
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
// a step a companion served is checked against the companion's spec
|
|
968
|
+
const stepShape = serving === journey.vendor ? shape : await shapeOf(serving);
|
|
969
|
+
const answering = stepShape && res.status >= 200 && res.status < 300 && !stepShape.pinsOlder({ ...(journey.headers ?? {}), ...(headers ?? {}) }) ? stepShape.operationOf(step.method, path, { ...(journey.headers ?? {}), ...(headers ?? {}) }) : undefined;
|
|
970
|
+
if (answering)
|
|
971
|
+
for (const detail of stepShape.failures(answering.op, res.body, res.headers['content-type'], new Headers({ ...(journey.headers ?? {}), ...(headers ?? {}) }).get('accept') ?? undefined, res.status, { method: step.method, responseText: res.text }))
|
|
972
|
+
collect.push({ step: step.name, beat: 'SHAPE', detail });
|
|
973
|
+
// what a pack keeps for itself is `_`-prefixed and never answered, under any name (Protocol 3, State)
|
|
974
|
+
const responseFields = new Set(specFields(serving));
|
|
975
|
+
if (body && typeof body === 'object' && typeof body.query === 'string' && graphqlBody) {
|
|
976
|
+
try {
|
|
977
|
+
visit(parse(body.query), { Field: (field) => { responseFields.add(field.alias?.value ?? field.name.value); } });
|
|
978
|
+
}
|
|
979
|
+
catch { /* malformed documents have no selected response keys */ }
|
|
980
|
+
}
|
|
981
|
+
if (res.status >= 200 && res.status < 300)
|
|
982
|
+
for (const key of leaked(res.body, responseFields))
|
|
983
|
+
collect.push({ step: step.name, beat: 'LEAK', detail: `answers the twin's own field ${key}` });
|
|
984
|
+
// a request that forbids tools (tool_choice none) is answered without a tool call, in any vendor's form
|
|
985
|
+
if (res.status >= 200 && res.status < 300 && step.body && typeof step.body === 'object' && forbidsTools(step.body.tool_choice))
|
|
986
|
+
for (const call of toolCallsIn(res.body))
|
|
987
|
+
collect.push({ step: step.name, beat: 'TOOLS', detail: `answered a tool call (${call}) to a request whose tool_choice is none` });
|
|
988
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
989
|
+
// `view:<path>` captures from the pack's view of the answer (`step.view`), as a client reads a value it decodes
|
|
990
|
+
// (an Upstash stream id the SDK decodes from base64 and sends back); any other path reads the answer as it came
|
|
991
|
+
const viewDot = dot.startsWith('view:') ? dot.slice('view:'.length) : undefined;
|
|
992
|
+
const v = viewDot === undefined ? captureAt(res.body, dot, res.headers, res.text) : step.view !== undefined && views[step.view] ? captureAt(views[step.view](res.body), viewDot) : undefined;
|
|
993
|
+
if (v === undefined || v === null) {
|
|
994
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
remember(name, v);
|
|
998
|
+
}
|
|
999
|
+
if (step.check) {
|
|
1000
|
+
const verdict = step.check({ status: res.status, body: res.body }, captured);
|
|
1001
|
+
if (verdict !== true)
|
|
1002
|
+
collect.push({ step: step.name, beat: 'CHECK', detail: verdict });
|
|
1003
|
+
}
|
|
1004
|
+
// an expectation naming a capture an earlier failed step never made is this step's failure, not the walk's end
|
|
1005
|
+
try {
|
|
1006
|
+
// a step's `view` asserts what the answer holds as the pack decodes it (a psdb result's rows)
|
|
1007
|
+
const viewed = step.view === undefined ? res.body : views[step.view] ? views[step.view](res.body) : undefined;
|
|
1008
|
+
if (step.view !== undefined && !views[step.view])
|
|
1009
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail: `the pack has no answer view "${step.view}" (answerViews in its index)` });
|
|
1010
|
+
else
|
|
1011
|
+
for (const f of step.expect ? expectFailures(step.expect, viewed, captured, res.headers, step.view === undefined ? res.text : undefined) : [])
|
|
1012
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail: f });
|
|
1013
|
+
}
|
|
1014
|
+
catch (e) {
|
|
1015
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
1016
|
+
}
|
|
1017
|
+
// LANDED beat, part two: a write that answered success must have moved something.
|
|
1018
|
+
if (changesPath !== undefined && beforeChange !== undefined) {
|
|
1019
|
+
const after = await send({ method: 'GET', headers: stepHeaders(step) }, changesPath);
|
|
1020
|
+
if (after.text === beforeChange) {
|
|
1021
|
+
collect.push({ step: step.name, beat: 'LANDED', detail: `${step.method} ${path} answered ${res.status} and GET ${changesPath} is byte-identical to what it was before — the write reported success and stored nothing` });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (step.creates && opts.beats) {
|
|
1025
|
+
const except = step.creates.echoExcept ?? [];
|
|
1026
|
+
const payload = (b) => (step.creates.at ? at(b, step.creates.at) : b);
|
|
1027
|
+
if ((step.creates.echoOn ?? 'both') === 'both') {
|
|
1028
|
+
for (const f of echoFailures(body, payload(res.body), except))
|
|
1029
|
+
collect.push({ step: step.name, beat: 'ECHO (create)', detail: f });
|
|
1030
|
+
}
|
|
1031
|
+
let readPath;
|
|
1032
|
+
try {
|
|
1033
|
+
readPath = fill(step.creates.readBack, captured);
|
|
1034
|
+
}
|
|
1035
|
+
catch (e) {
|
|
1036
|
+
collect.push({ step: step.name, beat: 'ECHO (read back)', detail: e.message });
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const back = await send({ method: 'GET', headers: stepHeaders(step) }, readPath);
|
|
1040
|
+
if (back.status < 200 || back.status >= 300)
|
|
1041
|
+
collect.push({ step: step.name, beat: 'ECHO (read back)', detail: `GET ${readPath} answered ${back.status} right after the create` });
|
|
1042
|
+
else
|
|
1043
|
+
for (const f of echoFailures(body, payload(back.body), except))
|
|
1044
|
+
collect.push({ step: step.name, beat: 'ECHO (read back)', detail: `${f} — reading it back at ${readPath}` });
|
|
1045
|
+
created.push({ step: step.name, readBack: readPath, sent: body, except, at: step.creates.at });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
opts.hooks?.after?.(step, path, answered, collect.slice(failuresBefore));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
// PERSISTS — after the whole walk, everything created early must still be there.
|
|
1053
|
+
if (opts.beats) {
|
|
1054
|
+
for (const c of created) {
|
|
1055
|
+
const back = await send({ method: 'GET' }, c.readBack);
|
|
1056
|
+
if (back.status < 200 || back.status >= 300) {
|
|
1057
|
+
collect.push({ step: c.step, beat: 'PERSISTS', detail: `GET ${c.readBack} answered ${back.status} at the END of the walk — it was created and did not survive` });
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
for (const f of echoFailures(c.sent, c.at ? at(back.body, c.at) : back.body, c.except))
|
|
1061
|
+
collect.push({ step: c.step, beat: 'PERSISTS', detail: `${f} — at the END of the walk` });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
for (const key of pending.keys())
|
|
1065
|
+
collect.push({ step: '(teardown)', beat: 'PENDING', detail: `deferred request ${key} was never collected` });
|
|
1066
|
+
return captured;
|
|
1067
|
+
}
|
|
1068
|
+
finally {
|
|
1069
|
+
for (const request of pending.values()) {
|
|
1070
|
+
clearTimeout(request.timer);
|
|
1071
|
+
request.abort.abort();
|
|
1072
|
+
}
|
|
1073
|
+
for (const ws of wire.values())
|
|
1074
|
+
ws.close();
|
|
1075
|
+
const upgrade = twin.upgrade;
|
|
1076
|
+
for (const socket of sockets.values())
|
|
1077
|
+
if (!socket.closed)
|
|
1078
|
+
upgrade?.close(socket.peer);
|
|
1079
|
+
await Promise.allSettled([...pending.values()].map((request) => request.answer));
|
|
1080
|
+
setDeliveryObserver(previousObserver);
|
|
1081
|
+
setInProcessTwins(previousTwins);
|
|
1082
|
+
served?.stop();
|
|
1083
|
+
if (sqlSession)
|
|
1084
|
+
await sqlSession.then((session) => session.end(), () => { });
|
|
1085
|
+
// a World's managed database ends with its walk (vendor-fetch.ts), so the next World declares it at the same port
|
|
1086
|
+
await twin.stopDatabase?.();
|
|
1087
|
+
if (clockFile && startedAt !== undefined)
|
|
1088
|
+
store.write(clockFile, startedAt);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
/** Run `fn` with every draw the kernel makes from the platform's random source (the World's seed and keys: a caller in
|
|
1092
|
+
* world-core's src) taken from one fixed stream, a pack's own draws left random: two Worlds walked so share the World's
|
|
1093
|
+
* own randomness, and what differs between them is randomness beyond it (R9; the board's `clone-distinct-ids` card). */
|
|
1094
|
+
// Key pairs belong to the measurement, never to a served World. An algorithm's nth kernel draw
|
|
1095
|
+
// gets the same pair in each measured walk; a pack calling generateKey directly stays random.
|
|
1096
|
+
const pinnedKeyPairs = new Map();
|
|
1097
|
+
export async function withPinnedWorldDraws(fn) {
|
|
1098
|
+
const originalRandom = globalThis.crypto.getRandomValues;
|
|
1099
|
+
const real = originalRandom.bind(globalThis.crypto);
|
|
1100
|
+
const originalGenerate = globalThis.crypto.subtle.generateKey;
|
|
1101
|
+
const generate = originalGenerate.bind(globalThis.crypto.subtle);
|
|
1102
|
+
const keyDraws = new Map();
|
|
1103
|
+
let n = 0;
|
|
1104
|
+
globalThis.crypto.getRandomValues = ((array) => {
|
|
1105
|
+
const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
|
|
1106
|
+
if (!/world-core\/src\//.test(caller))
|
|
1107
|
+
return real(array);
|
|
1108
|
+
if (array) {
|
|
1109
|
+
const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
|
|
1110
|
+
for (let i = 0; i < bytes.length; i++)
|
|
1111
|
+
bytes[i] = (n++ * 2654435761 + 97) >>> 24 & 0xff;
|
|
1112
|
+
}
|
|
1113
|
+
return array;
|
|
1114
|
+
});
|
|
1115
|
+
globalThis.crypto.subtle.generateKey = ((algorithm, extractable, usages) => {
|
|
1116
|
+
const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
|
|
1117
|
+
if (!/world-core\/src\//.test(caller))
|
|
1118
|
+
return generate(algorithm, extractable, usages);
|
|
1119
|
+
const shape = JSON.stringify([algorithm, extractable, usages]);
|
|
1120
|
+
const draw = keyDraws.get(shape) ?? 0;
|
|
1121
|
+
keyDraws.set(shape, draw + 1);
|
|
1122
|
+
const key = `${shape}:${draw}`;
|
|
1123
|
+
let held = pinnedKeyPairs.get(key);
|
|
1124
|
+
if (!held) {
|
|
1125
|
+
held = generate(algorithm, extractable, usages);
|
|
1126
|
+
pinnedKeyPairs.set(key, held);
|
|
1127
|
+
void held.catch(() => pinnedKeyPairs.delete(key));
|
|
1128
|
+
}
|
|
1129
|
+
return held;
|
|
1130
|
+
});
|
|
1131
|
+
try {
|
|
1132
|
+
return await fn();
|
|
1133
|
+
}
|
|
1134
|
+
finally {
|
|
1135
|
+
globalThis.crypto.getRandomValues = originalRandom;
|
|
1136
|
+
globalThis.crypto.subtle.generateKey = originalGenerate;
|
|
1137
|
+
}
|
|
1138
|
+
}
|