@volter/twin-standard 1.0.161 → 1.0.162
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/clients/anthropic-messages.js +6 -6
- package/dist/src/clients/gemini.js +8 -8
- package/dist/src/clients/harness.d.ts +14 -3
- package/dist/src/clients/harness.js +9 -2
- package/dist/src/clients/openai-compatible.js +6 -6
- package/dist/src/clients/openai.js +4 -4
- package/dist/src/clients/run.js +2 -2
- package/dist/src/clients/xai.js +1 -1
- package/dist/src/conformance.js +27 -13
- package/dist/src/create-pack.js +1 -1
- package/dist/src/walk/behavior-journey.d.ts +2 -2
- package/dist/src/walk/behavior-journey.js +17 -7
- package/dist/src/walk/journey-kit.d.ts +3 -2
- package/dist/src/walk/serve-pack.d.ts +0 -1
- package/dist/src/walk/vendor-fetch.d.ts +1 -2
- package/dist/src/walk/world-scenario.d.ts +8 -0
- package/dist/src/walk/world-scenario.js +26 -0
- package/package.json +3 -3
- package/src/clients/anthropic-messages.ts +6 -6
- package/src/clients/gemini.ts +7 -7
- package/src/clients/harness.ts +18 -4
- package/src/clients/openai-compatible.ts +6 -6
- package/src/clients/openai.ts +4 -4
- package/src/clients/run.ts +3 -3
- package/src/clients/xai.ts +1 -1
- package/src/conformance.ts +17 -13
- package/src/create-pack.ts +1 -1
- package/src/walk/behavior-journey.ts +15 -8
- package/src/walk/journey-kit.ts +3 -2
- package/src/walk/serve-pack.ts +2 -2
- package/src/walk/vendor-fetch.ts +3 -3
- package/src/walk/world-scenario.ts +23 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
const MESSAGES = 'https://platform.claude.com/docs/en/api/messages';
|
|
2
2
|
const TOOLS = [{ name: 'lookup', input_schema: { type: 'object', properties: {} } }];
|
|
3
3
|
export function messagesCases(v) {
|
|
4
|
-
const SCRIPT =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
const SCRIPT = [
|
|
5
|
+
{ on: { hasTool: 'lookup' }, outcome: v.toolTurn },
|
|
6
|
+
{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
|
|
7
|
+
];
|
|
8
8
|
const ask = async (c, extra = {}) => {
|
|
9
9
|
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
10
10
|
const client = new Anthropic({ apiKey: c.env[v.key] ?? '', baseURL: v.base(c.host), fetch: c.fetch, maxRetries: 0, timeout: 10_000 });
|
|
@@ -41,9 +41,9 @@ export function messagesCases(v) {
|
|
|
41
41
|
asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
|
|
42
42
|
source: 'architecture "The scenario is read from the World\'s store at every turn"',
|
|
43
43
|
async run(c) {
|
|
44
|
-
c.scenario(
|
|
44
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
|
|
45
45
|
const first = await ask(c).then(text, (e) => `error ${e.message}`);
|
|
46
|
-
c.scenario(
|
|
46
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
|
|
47
47
|
const second = await ask(c).then(text, (e) => `error ${e.message}`);
|
|
48
48
|
return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
|
|
49
49
|
},
|
|
@@ -22,7 +22,7 @@ export const cases = [
|
|
|
22
22
|
source: `${REFERENCE} "a stream of GenerateContentResponse instances"`,
|
|
23
23
|
async run(c) {
|
|
24
24
|
return routed(c, async () => {
|
|
25
|
-
c.scenario(
|
|
25
|
+
c.scenario([{ on: { userTextIncludes: 'workshop' }, outcome: { text: 'A scripted workshop reply.' } }]);
|
|
26
26
|
const result = await (await chat(c)).generateContentStream('Plan the workshop.');
|
|
27
27
|
let text = '';
|
|
28
28
|
for await (const chunk of result.stream)
|
|
@@ -40,10 +40,10 @@ export const cases = [
|
|
|
40
40
|
async run(c) {
|
|
41
41
|
return routed(c, async () => {
|
|
42
42
|
const { SchemaType } = await import('@google/generative-ai');
|
|
43
|
-
c.scenario(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
c.scenario([
|
|
44
|
+
{ on: { toolResultFor: 'lookup_company', lastMessageIsToolResult: true }, outcome: { text: 'Tidewater renewal is in October.' } },
|
|
45
|
+
{ on: { hasTool: 'lookup_company' }, outcome: { functionCalls: [{ name: 'lookup_company', args: { company: 'Tidewater' } }] } },
|
|
46
|
+
]);
|
|
47
47
|
const session = (await chat(c)).startChat({ tools: [{ functionDeclarations: [{ name: 'lookup_company', parameters: { type: SchemaType.OBJECT, properties: { company: { type: SchemaType.STRING } }, required: ['company'] } }] }] });
|
|
48
48
|
const first = await session.sendMessage('Find the company renewal.');
|
|
49
49
|
const call = first.response.functionCalls()?.[0];
|
|
@@ -78,9 +78,9 @@ export const cases = [
|
|
|
78
78
|
async run(c) {
|
|
79
79
|
return routed(c, async () => {
|
|
80
80
|
const { GoogleGenAI } = await import('@google/genai');
|
|
81
|
-
c.scenario(
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
c.scenario([{ on: { userTextIncludes: 'square illustration' }, outcome: {
|
|
82
|
+
text: 'A synthetic square illustration.', parts: [{ inlineData: { mimeType: 'image/png', data: PNG } }],
|
|
83
|
+
} }]);
|
|
84
84
|
const ai = new GoogleGenAI({ apiKey: c.env.GEMINI_API_KEY ?? '', httpOptions: { baseUrl: `https://${c.host}` } });
|
|
85
85
|
const response = await ai.models.generateContent({ model: 'gemini-3.1-flash-image', contents: 'Make a square illustration.', config: { responseModalities: ['TEXT', 'IMAGE'] } });
|
|
86
86
|
const image = response.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData;
|
|
@@ -14,8 +14,16 @@ export type ClientContext = {
|
|
|
14
14
|
/** an HTTPS agent as a World routes it, for a client that takes an agent (Slack's, Stripe's): its connections reach the
|
|
15
15
|
* served port, the client's URL and Host unchanged */
|
|
16
16
|
agent: HttpsAgent;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/** plays the World's scenario (read at the pack's next request): the model vendor's turns, each a decision at its
|
|
18
|
+
* `turn` point keyed by `on` (the pack's turn matchers) with `outcome` its answer, holding from the scenario's start;
|
|
19
|
+
* playing another replaces it */
|
|
20
|
+
scenario: (turns: ScriptedTurn[]) => void;
|
|
21
|
+
};
|
|
22
|
+
/** A model vendor's scripted turn: a decision `{ point: 'turn', on, outcome, once? }` (ADR 0036) of the case's vendor. */
|
|
23
|
+
export type ScriptedTurn = {
|
|
24
|
+
on: Record<string, unknown>;
|
|
25
|
+
outcome: unknown;
|
|
26
|
+
once?: boolean;
|
|
19
27
|
};
|
|
20
28
|
/** One expectation of the vendor's documented behaviour, driven through its client. */
|
|
21
29
|
export type ClientCase = {
|
|
@@ -59,4 +67,7 @@ export declare function servedHttpFetch(port: number): typeof fetch;
|
|
|
59
67
|
/** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
|
|
60
68
|
/** `raw`: a raw protocol's served port (SMTP's listener), which a connection reaches directly, as a World passes a raw TCP
|
|
61
69
|
* connection through untouched; the proxy speaks HTTP only. */
|
|
62
|
-
export declare function clientContext(port: number, host: string, env: Record<string, string>,
|
|
70
|
+
export declare function clientContext(port: number, host: string, env: Record<string, string>, scenario: {
|
|
71
|
+
vendor: string;
|
|
72
|
+
file: string;
|
|
73
|
+
}, raw?: number): ClientContext;
|
|
@@ -8,6 +8,8 @@ import { createServer, request as httpRequest } from 'node:http';
|
|
|
8
8
|
import { Agent as HttpsAgent } from 'node:https';
|
|
9
9
|
import { connect } from 'node:net';
|
|
10
10
|
import { writeFileSync } from 'node:fs';
|
|
11
|
+
/** Where a case's scenario starts, every entry with it: long before any clock the served pack keeps, so each holds at once. */
|
|
12
|
+
const HELD_FROM = '2000-01-01T00:00:00.000Z';
|
|
11
13
|
/** The World's proxy in front of a pack served on `served`: it ends the caller's TLS (here, none was begun) and forwards
|
|
12
14
|
* each request and socket upgrade to the pack with `x-forwarded-proto: https`, the vendor's Host unchanged, as a World's
|
|
13
15
|
* proxy forwards a caller of the vendor's https host (twin-fetch.ts, twinPublicBase: the links and socket URLs the pack
|
|
@@ -249,13 +251,18 @@ export function servedHttpFetch(port) {
|
|
|
249
251
|
/** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
|
|
250
252
|
/** `raw`: a raw protocol's served port (SMTP's listener), which a connection reaches directly, as a World passes a raw TCP
|
|
251
253
|
* connection through untouched; the proxy speaks HTTP only. */
|
|
252
|
-
export function clientContext(port, host, env,
|
|
254
|
+
export function clientContext(port, host, env, scenario, raw) {
|
|
253
255
|
const agent = new (class extends HttpsAgent {
|
|
254
256
|
createConnection() { return connect(port, '127.0.0.1'); }
|
|
255
257
|
})({ keepAlive: false });
|
|
256
258
|
return {
|
|
257
259
|
host, env, fetch: servedHttpFetch(port), agent,
|
|
258
260
|
connect: () => connect(raw ?? port, '127.0.0.1'),
|
|
259
|
-
|
|
261
|
+
// written as a World resolves a document whose every entry is at `0s`: the cases run in Node, whose type stripping
|
|
262
|
+
// does not load the kernel's source entry (its parameter properties), so they write the resolved form it reads
|
|
263
|
+
scenario: (turns) => writeFileSync(scenario.file, JSON.stringify({
|
|
264
|
+
name: 'client-case', start: HELD_FROM, random: 'client-case',
|
|
265
|
+
timeline: turns.map((t, i) => ({ at: HELD_FROM, decision: { vendor: scenario.vendor, point: 'turn', on: t.on, outcome: t.outcome, ...(t.once === undefined ? {} : { once: t.once }) }, index: i + 1 })),
|
|
266
|
+
})),
|
|
260
267
|
};
|
|
261
268
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
const CHAT = 'https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create';
|
|
2
2
|
const TOOLS = [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
|
|
3
|
-
const SCRIPT =
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
const SCRIPT = [
|
|
4
|
+
{ on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
|
|
5
|
+
{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
|
|
6
|
+
];
|
|
7
7
|
export async function openaiClient(c, v) {
|
|
8
8
|
const { default: OpenAI } = await import('openai');
|
|
9
9
|
return new OpenAI({ apiKey: c.env[v.key] ?? '', baseURL: v.base(c.host), fetch: c.fetch, maxRetries: 0, timeout: 10_000 });
|
|
@@ -40,9 +40,9 @@ export function chatCases(v) {
|
|
|
40
40
|
asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
|
|
41
41
|
source: 'architecture "The scenario is read from the World\'s store at every turn"',
|
|
42
42
|
async run(c) {
|
|
43
|
-
c.scenario(
|
|
43
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
|
|
44
44
|
const first = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e) => `error ${e.message}`);
|
|
45
|
-
c.scenario(
|
|
45
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
|
|
46
46
|
const second = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e) => `error ${e.message}`);
|
|
47
47
|
return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
|
|
48
48
|
},
|
|
@@ -8,10 +8,10 @@ async function client(c) {
|
|
|
8
8
|
}
|
|
9
9
|
const TOOLS = [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
|
|
10
10
|
// a turn that calls the tool wherever one is offered, and one that answers in words
|
|
11
|
-
const SCRIPT =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const SCRIPT = [
|
|
12
|
+
{ on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
|
|
13
|
+
{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
|
|
14
|
+
];
|
|
15
15
|
/** A Realtime session through the client (openai-node's server-side Realtime client, on the ws library, as a Node
|
|
16
16
|
* application opens it), until its response is done: its first event and the output items' types, or what went wrong. */
|
|
17
17
|
async function realtimeTurn(c, toolChoice) {
|
package/dist/src/clients/run.js
CHANGED
|
@@ -7,7 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
// The vendor's client cases, run in Node as an application runs its SDK: `node run.ts <job.json>`. The job names the
|
|
10
|
-
// vendor, the served port, the host, the issued credentials and the scenario file; each case's failures are printed as
|
|
10
|
+
// vendor, the served port, the host, the issued credentials and the World's scenario file; each case's failures are printed as
|
|
11
11
|
// one JSON line, `{ id, asks, failures }`.
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
@@ -28,7 +28,7 @@ try {
|
|
|
28
28
|
const facts = JSON.parse(readFileSync(job.packFacts, 'utf8')).packs[job.vendor];
|
|
29
29
|
const keys = [...new Set((facts.hosts ?? []).map((rule) => rule.key ?? job.vendor))];
|
|
30
30
|
require('@volter/world-core/inject').install(Object.fromEntries(keys.map((key) => [key, `http://127.0.0.1:${proxy.port}`])));
|
|
31
|
-
const context = clientContext(proxy.port, job.host, job.env, job.
|
|
31
|
+
const context = clientContext(proxy.port, job.host, job.env, { vendor: job.vendor, file: job.scenarioFile }, job.raw ? job.port : undefined);
|
|
32
32
|
for (const c of cases) {
|
|
33
33
|
const failures = await Promise.race([
|
|
34
34
|
c.run(context).catch((e) => [`the case threw: ${e.message}`]),
|
package/dist/src/clients/xai.js
CHANGED
|
@@ -7,7 +7,7 @@ export const cases = [
|
|
|
7
7
|
asks: "a reasoning turn streamed through the SDK's Responses stream completes with its reasoning item and its message",
|
|
8
8
|
source: 'https://developers.openai.com/api/reference/resources/responses/streaming-events "response.reasoning_summary_part.added"',
|
|
9
9
|
async run(c) {
|
|
10
|
-
c.scenario(
|
|
10
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words', reasoning: 'thinking it through' } }]);
|
|
11
11
|
const client = await openaiClient(c, XAI);
|
|
12
12
|
try {
|
|
13
13
|
const stream = client.responses.stream({ model: XAI.model, input: 'hello', reasoning: { summary: 'auto' } });
|
package/dist/src/conformance.js
CHANGED
|
@@ -34,6 +34,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
34
34
|
import { parseEnv } from 'node:util';
|
|
35
35
|
import { openWebSocket, readHttpResponse, servedHttpFetch, worldProxy } from "./clients/harness.js";
|
|
36
36
|
import { servePack as serve } from "./walk/serve-pack.js";
|
|
37
|
+
import { playScenario, resolvedScenario } from "./walk/world-scenario.js";
|
|
37
38
|
import { walkPackDirectory } from "./walk/pack-directory.js";
|
|
38
39
|
import { formBody, sigv4Headers } from "./walk/journey-kit.js";
|
|
39
40
|
import { consumerClientProof, consumerRecipe, runConsumerCommand } from "./consumer.js";
|
|
@@ -479,7 +480,7 @@ async function consumerAuthorization(pack, scratch, root, server, descriptor, ou
|
|
|
479
480
|
}
|
|
480
481
|
}
|
|
481
482
|
/** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
|
|
482
|
-
* runs it: once plainly and once with a scenario
|
|
483
|
+
* runs it: once plainly and once with a scenario played (an empty one, as a World that places nothing plays it). Each
|
|
483
484
|
* answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
|
|
484
485
|
* bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
|
|
485
486
|
* refresh list a fresh account can read (one with no parameter in its path) succeeds or remains unread under the
|
|
@@ -521,11 +522,12 @@ async function surfaceSwept(pack, scratch, runtime, consumer) {
|
|
|
521
522
|
return { failures: out, note: 'no unit serves an operation on a path of its own' };
|
|
522
523
|
const tally = { calls: 0, ok: 0, refused: 0, unread: 0, removed: 0 };
|
|
523
524
|
const replayedHeaders = new Set();
|
|
524
|
-
|
|
525
|
-
|
|
525
|
+
// the scenario mode plays an empty timeline as the World's, as a World that places nothing on the vendor plays it
|
|
526
|
+
const empty = resolvedScenario('surface', { start: '2000-01-01T00:00:00.000Z', timeline: [] });
|
|
526
527
|
for (const mode of ['plain', 'scenario']) {
|
|
527
528
|
const root = join(scratch, `surface-${mode}`);
|
|
528
|
-
const
|
|
529
|
+
const restoreScenario = playScenario(join(scratch, 'surface-scenario.json'), mode === 'scenario' ? empty : undefined);
|
|
530
|
+
const server = await serve(pack, { root }).catch((error) => { restoreScenario(); throw error; });
|
|
529
531
|
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
530
532
|
const send = async (request) => {
|
|
531
533
|
await server.managedDatabaseUrl?.();
|
|
@@ -768,7 +770,12 @@ async function surfaceSwept(pack, scratch, runtime, consumer) {
|
|
|
768
770
|
}
|
|
769
771
|
}
|
|
770
772
|
finally {
|
|
771
|
-
|
|
773
|
+
try {
|
|
774
|
+
await server.stop();
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
restoreScenario();
|
|
778
|
+
}
|
|
772
779
|
}
|
|
773
780
|
}
|
|
774
781
|
// one defect repeated over a surface reads as one: the first 40 findings, and how many more
|
|
@@ -805,7 +812,7 @@ function lineConnection(port) {
|
|
|
805
812
|
};
|
|
806
813
|
}
|
|
807
814
|
/** The life walked as an application calls the pack: its server.ts served as a World runs it (boot id set, the World's
|
|
808
|
-
* root
|
|
815
|
+
* root its option, the life's scenario played as the World's), every step over HTTP with the vendor's host, every check of the walk held
|
|
809
816
|
* (status, expectations, captures, SHAPE, LEAK over answers, events and frames). Slack's events once carried the
|
|
810
817
|
* kernel's updatedAt; a request the served pack does not answer in 10 s fails its step. */
|
|
811
818
|
async function lifeServed(pack) {
|
|
@@ -814,8 +821,8 @@ async function lifeServed(pack) {
|
|
|
814
821
|
return ['journeys/customer-life.json is missing'];
|
|
815
822
|
const { inMemoryWorld, usePacks, walk } = await import("./walk/behavior-journey.js");
|
|
816
823
|
usePacks(dirname(pack), { dependenciesFrom: pack });
|
|
817
|
-
const served = async (root
|
|
818
|
-
const server = await serve(pack, { root
|
|
824
|
+
const served = async (root) => {
|
|
825
|
+
const server = await serve(pack, { root });
|
|
819
826
|
const port = server.inspectPort ?? server.port;
|
|
820
827
|
const http = servedHttpFetch(port);
|
|
821
828
|
return {
|
|
@@ -868,9 +875,11 @@ async function clientCases(pack, scratch, consumer) {
|
|
|
868
875
|
const host = descriptor?.hosts?.flatMap((h) => 'host' in h && h.exclude !== true ? [h.host] : [])[0] ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
|
|
869
876
|
if (!host)
|
|
870
877
|
return { failures: ['the descriptor names no host for its client to address'] };
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const
|
|
878
|
+
// the World's scenario, empty until a case plays the turns it scripts (the harness writes the file this process's pack
|
|
879
|
+
// reads at each request)
|
|
880
|
+
const scenarioFile = join(scratch, 'client-scenario.json');
|
|
881
|
+
const restoreScenario = playScenario(scenarioFile, resolvedScenario('client-case', { start: '2000-01-01T00:00:00.000Z', timeline: [] }));
|
|
882
|
+
const server = await serve(pack, { root: join(scratch, 'client-world') }).catch((error) => { restoreScenario(); throw error; });
|
|
874
883
|
try {
|
|
875
884
|
// the World's credentials: what the pack's credential door issues, by the variable a World sets each in
|
|
876
885
|
const issued = {};
|
|
@@ -890,7 +899,7 @@ async function clientCases(pack, scratch, consumer) {
|
|
|
890
899
|
packFacts = join(scratch, 'pack-facts.json');
|
|
891
900
|
writeFileSync(packFacts, renderPackFacts(compiled));
|
|
892
901
|
}
|
|
893
|
-
writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued,
|
|
902
|
+
writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioFile, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
|
|
894
903
|
// Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
|
|
895
904
|
const out = await new Promise((resolve) => {
|
|
896
905
|
const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -907,7 +916,12 @@ async function clientCases(pack, scratch, consumer) {
|
|
|
907
916
|
return { failures: results.flatMap((r) => r.failures.map((f) => `${r.id} (${r.asks}): ${f}`)), note: `${results.length} case(s) through the vendor's client` };
|
|
908
917
|
}
|
|
909
918
|
finally {
|
|
910
|
-
|
|
919
|
+
try {
|
|
920
|
+
await server.stop();
|
|
921
|
+
}
|
|
922
|
+
finally {
|
|
923
|
+
restoreScenario();
|
|
924
|
+
}
|
|
911
925
|
}
|
|
912
926
|
}
|
|
913
927
|
/** The gate over every unit of the pack: the form section of the grade, which holds the rules an author's code keeps
|
package/dist/src/create-pack.js
CHANGED
|
@@ -372,7 +372,7 @@ export function cliFile(vendor, live = false) {
|
|
|
372
372
|
const Name = pascal(vendor);
|
|
373
373
|
const ingest = live ? `// the RTMP ingest's side port, which the World allocates (the manifest's sidePorts)\nconst ingestPort = Number(optionValue(rest, '--ingest-port', '0')) || undefined;\n` : '';
|
|
374
374
|
const served = live ? ` process.stdout.write(s.ingestPort ? \`${vendor} twin ingest at rtmp://127.0.0.1:\${s.ingestPort}\\n\` : '${vendor} twin: no RTMP ingest\\n');\n` : '';
|
|
375
|
-
return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n
|
|
375
|
+
return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
|
|
376
376
|
}
|
|
377
377
|
/** A vendor id as a name in code: `npm-registry` is `NpmRegistry`. */
|
|
378
378
|
const pascal = (vendor) => vendor.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
@@ -42,11 +42,11 @@ export type StepHooks = {
|
|
|
42
42
|
before?: (step: JourneyStep | LineStep | GitStep | RespStep, path: string, body: unknown) => void;
|
|
43
43
|
after?: (step: JourneyStep | LineStep | GitStep | RespStep, path: string, answer: StepAnswer, failures: Failure[]) => void;
|
|
44
44
|
};
|
|
45
|
-
/** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root
|
|
45
|
+
/** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root its option, its scenario
|
|
46
|
+
* the World's file):
|
|
46
47
|
* the walk's requests go to it over HTTP, the vendor's host in their Host header, instead of to the fetch in process;
|
|
47
48
|
* its sockets are WebSockets to that port, and a line connection (`stream`) is a TCP connection to its protocol port. */
|
|
48
49
|
export type ServedTwin = (root: string, options: {
|
|
49
|
-
scenarioPath?: string;
|
|
50
50
|
application?: import('@volter/world-core').PackFetchOptions['application'];
|
|
51
51
|
}) => Promise<{
|
|
52
52
|
scheme?: 'http' | 'https';
|
|
@@ -123,6 +123,7 @@ export function inMemoryWorld(label) {
|
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
125
|
import { vendorAnswerViews, vendorFetch as vendorFetchFrom, vendorStream } from "./vendor-fetch.js";
|
|
126
|
+
import { playScenario, resolvedScenario } from "./world-scenario.js";
|
|
126
127
|
import { gitClone, gitPush } from "./git-client.js";
|
|
127
128
|
/** 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
|
|
128
129
|
* for any other string (an upload's bytes). */
|
|
@@ -287,14 +288,21 @@ export async function walk(journey, root, collect, opts) {
|
|
|
287
288
|
const ownKeys = routingKeys(journey.vendor);
|
|
288
289
|
/** The companion that serves a step's URL: one whose host rules claim it where the life's own vendor's do not. */
|
|
289
290
|
const companionOf = (url) => (claims(ownKeys, url) ? undefined : companions.find((c) => claims(c.keys, url)));
|
|
290
|
-
// the life's scenario is the World's: written
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
291
|
+
// the life's scenario is the World's: resolved here, and played (written beside the walk's root in its World store and
|
|
292
|
+
// named by TWIN_WORLD_SCENARIO_FILE, which every pack of the walk reads at each request as a World's twins do) from its
|
|
293
|
+
// first step to its end; a life naming none plays none, whatever World the walk itself runs in
|
|
294
|
+
let scenario;
|
|
295
|
+
try {
|
|
296
|
+
if (journey.scenario)
|
|
297
|
+
scenario = resolvedScenario(journey.scenario, parseExactJson(readFileSync(join(directoryFor(journey.vendor), 'journeys', journey.scenario), 'utf8')));
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
collect.push({ step: '(setup)', beat: 'SCENARIO', detail: `journeys/${journey.scenario}: ${error.message}` });
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
295
303
|
const shape = await shapeOf(journey.vendor);
|
|
296
|
-
const served = opts.served ? await opts.served(root, {
|
|
297
|
-
const twin = served ? served.fetch : factory(root, {
|
|
304
|
+
const served = opts.served ? await opts.served(root, { application }) : undefined;
|
|
305
|
+
const twin = served ? served.fetch : factory(root, { application });
|
|
298
306
|
const host = journey.host ?? 'twin.local';
|
|
299
307
|
const origin = `${served?.scheme ?? 'http'}://${host}`;
|
|
300
308
|
// a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
|
|
@@ -1046,6 +1054,7 @@ export async function walk(journey, root, collect, opts) {
|
|
|
1046
1054
|
...ownKeys.map((key) => [key, twin]),
|
|
1047
1055
|
]));
|
|
1048
1056
|
const previousMailServers = setInProcessMailServers(mailServers);
|
|
1057
|
+
const restoreScenario = playScenario(`${root}.scenario.json`, scenario);
|
|
1049
1058
|
try {
|
|
1050
1059
|
for (const step of journey.steps) {
|
|
1051
1060
|
await settleEventDeliveries(root);
|
|
@@ -1380,6 +1389,7 @@ export async function walk(journey, root, collect, opts) {
|
|
|
1380
1389
|
finally {
|
|
1381
1390
|
if (clockFile && startedAt !== undefined)
|
|
1382
1391
|
store.write(clockFile, startedAt);
|
|
1392
|
+
restoreScenario();
|
|
1383
1393
|
}
|
|
1384
1394
|
}
|
|
1385
1395
|
}
|
|
@@ -193,8 +193,9 @@ export type Journey = {
|
|
|
193
193
|
/** The instant the story begins, where it is before the World's day one (2026-01-01): an account whose story starts
|
|
194
194
|
* while an API it used still made what it no longer makes. Its waits move the clock on from there. */
|
|
195
195
|
startsAt?: string;
|
|
196
|
-
/** The World's scenario
|
|
197
|
-
*
|
|
196
|
+
/** The World's scenario the life is walked under (a scenario document beside it, `journeys/<name>.json`: ADR 0036,
|
|
197
|
+
* `{ start, timeline }`): the walk plays it as the World's, so the decisions and faults it places, and the pack's code
|
|
198
|
+
* that consumes them, are walked. */
|
|
198
199
|
scenario?: string;
|
|
199
200
|
/** The packs of the same catalog, by vendor, that the vendor calls as its own behaviour (`ctx.vendorFetch`: a sign-in
|
|
200
201
|
* trading a code at its provider): the walk loads each over the walk's own World and root, the vendor's calls reach
|
|
@@ -2,10 +2,9 @@ import { type ManagedDatabaseWalk } from './walk-database.js';
|
|
|
2
2
|
import { type TwinStream } from '@volter/world-core/runtime';
|
|
3
3
|
import type { PackFetchOptions } from '@volter/world-core';
|
|
4
4
|
type ApplicationStandIn = NonNullable<PackFetchOptions['application']>;
|
|
5
|
-
/** A pack's fetch over a World's root
|
|
5
|
+
/** A pack's fetch over a World's root (the World's scenario it reads itself, TWIN_WORLD_SCENARIO_FILE), and a
|
|
6
6
|
* life's application answers stand in for the application the vendor asks (`application`, this fetch's alone). */
|
|
7
7
|
export type VendorFetchFactory = ((root: string, options?: {
|
|
8
|
-
scenarioPath?: string;
|
|
9
8
|
application?: ApplicationStandIn;
|
|
10
9
|
}) => ((request: Request) => Response | Promise<Response>) & ManagedDatabaseWalk) & {
|
|
11
10
|
managedDatabaseDoor?: string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ResolvedScenario } from '@volter/world-core';
|
|
2
|
+
export type { ResolvedScenario };
|
|
3
|
+
/** A scenario document as a World plays it; one the grammar refuses throws its ScenarioTimelineError, which names the
|
|
4
|
+
* entry and the vocabulary it expected. */
|
|
5
|
+
export declare function resolvedScenario(name: string, document: unknown): ResolvedScenario;
|
|
6
|
+
/** Play `scenario` as the World's at `file` until the returned restore, which puts the variable back as it was. Playing
|
|
7
|
+
* none clears the variable, so what the standard serves inside a World plays nothing of that World's. */
|
|
8
|
+
export declare function playScenario(file: string, scenario: ResolvedScenario | undefined): () => void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The World's scenario as the standard plays it for a walk or a served pack (ADR 0036; architecture, "Scenarios"): the
|
|
2
|
+
// document parsed and resolved as a World resolves it, written through the active World store, and named by
|
|
3
|
+
// TWIN_WORLD_SCENARIO_FILE, where every twin in this process reads it at each request as it reads the clock. A pack takes
|
|
4
|
+
// no scenario option: what it plays is the World's one scenario, so the standard sets the World's.
|
|
5
|
+
import { parseScenario, resolveScenario, WORLD_SCENARIO_ENV } from '@volter/world-core';
|
|
6
|
+
import { getActiveWorldStore } from '@volter/world-core/runtime';
|
|
7
|
+
/** A scenario document as a World plays it; one the grammar refuses throws its ScenarioTimelineError, which names the
|
|
8
|
+
* entry and the vocabulary it expected. */
|
|
9
|
+
export function resolvedScenario(name, document) {
|
|
10
|
+
return resolveScenario(name, parseScenario(document));
|
|
11
|
+
}
|
|
12
|
+
/** Play `scenario` as the World's at `file` until the returned restore, which puts the variable back as it was. Playing
|
|
13
|
+
* none clears the variable, so what the standard serves inside a World plays nothing of that World's. */
|
|
14
|
+
export function playScenario(file, scenario) {
|
|
15
|
+
if (scenario)
|
|
16
|
+
getActiveWorldStore().write(file, JSON.stringify(scenario));
|
|
17
|
+
const previous = process.env[WORLD_SCENARIO_ENV];
|
|
18
|
+
if (scenario)
|
|
19
|
+
process.env[WORLD_SCENARIO_ENV] = file;
|
|
20
|
+
else
|
|
21
|
+
delete process.env[WORLD_SCENARIO_ENV];
|
|
22
|
+
return () => { if (previous === undefined)
|
|
23
|
+
delete process.env[WORLD_SCENARIO_ENV];
|
|
24
|
+
else
|
|
25
|
+
process.env[WORLD_SCENARIO_ENV] = previous; };
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-standard",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.162",
|
|
4
4
|
"description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -77,8 +77,8 @@
|
|
|
77
77
|
"yaml": "2.9.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
|
-
"@volter/world-core": "3.0.
|
|
81
|
-
"@volter/world-runtime": "^3.0.
|
|
80
|
+
"@volter/world-core": "3.0.167",
|
|
81
|
+
"@volter/world-runtime": "^3.0.192",
|
|
82
82
|
"playwright": "^1.63.0"
|
|
83
83
|
},
|
|
84
84
|
"peerDependenciesMeta": {
|
|
@@ -11,10 +11,10 @@ const TOOLS = [{ name: 'lookup', input_schema: { type: 'object' as const, proper
|
|
|
11
11
|
export type MessagesVendor = { key: string; base: (host: string) => string; model: string; toolTurn: Record<string, unknown> };
|
|
12
12
|
|
|
13
13
|
export function messagesCases(v: MessagesVendor): ClientCase[] {
|
|
14
|
-
const SCRIPT =
|
|
15
|
-
{
|
|
16
|
-
{
|
|
17
|
-
]
|
|
14
|
+
const SCRIPT = [
|
|
15
|
+
{ on: { hasTool: 'lookup' }, outcome: v.toolTurn },
|
|
16
|
+
{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
|
|
17
|
+
];
|
|
18
18
|
const ask = async (c: ClientContext, extra: Record<string, unknown> = {}) => {
|
|
19
19
|
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
20
20
|
const client = new Anthropic({ apiKey: c.env[v.key] ?? '', baseURL: v.base(c.host), fetch: c.fetch, maxRetries: 0, timeout: 10_000 });
|
|
@@ -49,9 +49,9 @@ export function messagesCases(v: MessagesVendor): ClientCase[] {
|
|
|
49
49
|
asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
|
|
50
50
|
source: 'architecture "The scenario is read from the World\'s store at every turn"',
|
|
51
51
|
async run(c) {
|
|
52
|
-
c.scenario(
|
|
52
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
|
|
53
53
|
const first = await ask(c).then(text, (e: Error) => `error ${e.message}`);
|
|
54
|
-
c.scenario(
|
|
54
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
|
|
55
55
|
const second = await ask(c).then(text, (e: Error) => `error ${e.message}`);
|
|
56
56
|
return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
|
|
57
57
|
},
|
package/src/clients/gemini.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const cases: ClientCase[] = [
|
|
|
25
25
|
source: `${REFERENCE} "a stream of GenerateContentResponse instances"`,
|
|
26
26
|
async run(c) {
|
|
27
27
|
return routed(c, async () => {
|
|
28
|
-
c.scenario(
|
|
28
|
+
c.scenario([{ on: { userTextIncludes: 'workshop' }, outcome: { text: 'A scripted workshop reply.' } }]);
|
|
29
29
|
const result = await (await chat(c)).generateContentStream('Plan the workshop.');
|
|
30
30
|
let text = '';
|
|
31
31
|
for await (const chunk of result.stream) text += chunk.text();
|
|
@@ -42,10 +42,10 @@ export const cases: ClientCase[] = [
|
|
|
42
42
|
async run(c) {
|
|
43
43
|
return routed(c, async () => {
|
|
44
44
|
const { SchemaType } = await import('@google/generative-ai');
|
|
45
|
-
c.scenario(
|
|
46
|
-
{
|
|
47
|
-
{
|
|
48
|
-
]
|
|
45
|
+
c.scenario([
|
|
46
|
+
{ on: { toolResultFor: 'lookup_company', lastMessageIsToolResult: true }, outcome: { text: 'Tidewater renewal is in October.' } },
|
|
47
|
+
{ on: { hasTool: 'lookup_company' }, outcome: { functionCalls: [{ name: 'lookup_company', args: { company: 'Tidewater' } }] } },
|
|
48
|
+
]);
|
|
49
49
|
const session = (await chat(c)).startChat({ tools: [{ functionDeclarations: [{ name: 'lookup_company', parameters: { type: SchemaType.OBJECT, properties: { company: { type: SchemaType.STRING } }, required: ['company'] } }] }] });
|
|
50
50
|
const first = await session.sendMessage('Find the company renewal.');
|
|
51
51
|
const call = first.response.functionCalls()?.[0];
|
|
@@ -75,9 +75,9 @@ export const cases: ClientCase[] = [
|
|
|
75
75
|
async run(c) {
|
|
76
76
|
return routed(c, async () => {
|
|
77
77
|
const { GoogleGenAI } = await import('@google/genai');
|
|
78
|
-
c.scenario(
|
|
78
|
+
c.scenario([{ on: { userTextIncludes: 'square illustration' }, outcome: {
|
|
79
79
|
text: 'A synthetic square illustration.', parts: [{ inlineData: { mimeType: 'image/png', data: PNG } }],
|
|
80
|
-
} }]
|
|
80
|
+
} }]);
|
|
81
81
|
const ai = new GoogleGenAI({ apiKey: c.env.GEMINI_API_KEY ?? '', httpOptions: { baseUrl: `https://${c.host}` } });
|
|
82
82
|
const response = await ai.models.generateContent({ model: 'gemini-3.1-flash-image', contents: 'Make a square illustration.', config: { responseModalities: ['TEXT', 'IMAGE'] } });
|
|
83
83
|
const image = response.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData;
|
package/src/clients/harness.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { createServer, request as httpRequest, type ClientRequest } from 'node:h
|
|
|
8
8
|
import { Agent as HttpsAgent } from 'node:https';
|
|
9
9
|
import { connect, type AddressInfo, type Socket } from 'node:net';
|
|
10
10
|
import { writeFileSync } from 'node:fs';
|
|
11
|
+
import type { ResolvedScenario } from '@volter/world-core';
|
|
11
12
|
|
|
12
13
|
/** What a case is given. */
|
|
13
14
|
export type ClientContext = {
|
|
@@ -23,10 +24,18 @@ export type ClientContext = {
|
|
|
23
24
|
/** an HTTPS agent as a World routes it, for a client that takes an agent (Slack's, Stripe's): its connections reach the
|
|
24
25
|
* served port, the client's URL and Host unchanged */
|
|
25
26
|
agent: HttpsAgent;
|
|
26
|
-
/**
|
|
27
|
-
|
|
27
|
+
/** plays the World's scenario (read at the pack's next request): the model vendor's turns, each a decision at its
|
|
28
|
+
* `turn` point keyed by `on` (the pack's turn matchers) with `outcome` its answer, holding from the scenario's start;
|
|
29
|
+
* playing another replaces it */
|
|
30
|
+
scenario: (turns: ScriptedTurn[]) => void;
|
|
28
31
|
};
|
|
29
32
|
|
|
33
|
+
/** A model vendor's scripted turn: a decision `{ point: 'turn', on, outcome, once? }` (ADR 0036) of the case's vendor. */
|
|
34
|
+
export type ScriptedTurn = { on: Record<string, unknown>; outcome: unknown; once?: boolean };
|
|
35
|
+
|
|
36
|
+
/** Where a case's scenario starts, every entry with it: long before any clock the served pack keeps, so each holds at once. */
|
|
37
|
+
const HELD_FROM = '2000-01-01T00:00:00.000Z';
|
|
38
|
+
|
|
30
39
|
/** One expectation of the vendor's documented behaviour, driven through its client. */
|
|
31
40
|
export type ClientCase = {
|
|
32
41
|
id: string;
|
|
@@ -231,13 +240,18 @@ export function servedHttpFetch(port: number): typeof fetch {
|
|
|
231
240
|
/** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
|
|
232
241
|
/** `raw`: a raw protocol's served port (SMTP's listener), which a connection reaches directly, as a World passes a raw TCP
|
|
233
242
|
* connection through untouched; the proxy speaks HTTP only. */
|
|
234
|
-
export function clientContext(port: number, host: string, env: Record<string, string>,
|
|
243
|
+
export function clientContext(port: number, host: string, env: Record<string, string>, scenario: { vendor: string; file: string }, raw?: number): ClientContext {
|
|
235
244
|
const agent = new (class extends HttpsAgent {
|
|
236
245
|
createConnection(): Socket { return connect(port, '127.0.0.1'); }
|
|
237
246
|
})({ keepAlive: false });
|
|
238
247
|
return {
|
|
239
248
|
host, env, fetch: servedHttpFetch(port), agent,
|
|
240
249
|
connect: () => connect(raw ?? port, '127.0.0.1'),
|
|
241
|
-
|
|
250
|
+
// written as a World resolves a document whose every entry is at `0s`: the cases run in Node, whose type stripping
|
|
251
|
+
// does not load the kernel's source entry (its parameter properties), so they write the resolved form it reads
|
|
252
|
+
scenario: (turns) => writeFileSync(scenario.file, JSON.stringify({
|
|
253
|
+
name: 'client-case', start: HELD_FROM, random: 'client-case',
|
|
254
|
+
timeline: turns.map((t, i) => ({ at: HELD_FROM, decision: { vendor: scenario.vendor, point: 'turn', on: t.on, outcome: t.outcome, ...(t.once === undefined ? {} : { once: t.once }) }, index: i + 1 })),
|
|
255
|
+
} satisfies ResolvedScenario)),
|
|
242
256
|
};
|
|
243
257
|
}
|
|
@@ -5,10 +5,10 @@ import type { ClientCase, ClientContext } from './harness.ts';
|
|
|
5
5
|
|
|
6
6
|
const CHAT = 'https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create';
|
|
7
7
|
const TOOLS = [{ type: 'function' as const, function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
|
|
8
|
-
const SCRIPT =
|
|
9
|
-
{
|
|
10
|
-
{
|
|
11
|
-
]
|
|
8
|
+
const SCRIPT = [
|
|
9
|
+
{ on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
|
|
10
|
+
{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
|
|
11
|
+
];
|
|
12
12
|
|
|
13
13
|
/** A vendor's chat as openai-node reaches it: the variable its key is issued in, the base URL on its host, a model it serves. */
|
|
14
14
|
export type Compatible = { key: string; base: (host: string) => string; model: string };
|
|
@@ -48,9 +48,9 @@ export function chatCases(v: Compatible): ClientCase[] {
|
|
|
48
48
|
asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
|
|
49
49
|
source: 'architecture "The scenario is read from the World\'s store at every turn"',
|
|
50
50
|
async run(c) {
|
|
51
|
-
c.scenario(
|
|
51
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
|
|
52
52
|
const first = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e: Error) => `error ${e.message}`);
|
|
53
|
-
c.scenario(
|
|
53
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
|
|
54
54
|
const second = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e: Error) => `error ${e.message}`);
|
|
55
55
|
return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
|
|
56
56
|
},
|
package/src/clients/openai.ts
CHANGED
|
@@ -13,10 +13,10 @@ async function client(c: ClientContext) {
|
|
|
13
13
|
}
|
|
14
14
|
const TOOLS = [{ type: 'function' as const, function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
|
|
15
15
|
// a turn that calls the tool wherever one is offered, and one that answers in words
|
|
16
|
-
const SCRIPT =
|
|
17
|
-
{
|
|
18
|
-
{
|
|
19
|
-
]
|
|
16
|
+
const SCRIPT = [
|
|
17
|
+
{ on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
|
|
18
|
+
{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
|
|
19
|
+
];
|
|
20
20
|
|
|
21
21
|
/** A Realtime session through the client (openai-node's server-side Realtime client, on the ws library, as a Node
|
|
22
22
|
* application opens it), until its response is done: its first event and the output items' types, or what went wrong. */
|
package/src/clients/run.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The vendor's client cases, run in Node as an application runs its SDK: `node run.ts <job.json>`. The job names the
|
|
2
|
-
// vendor, the served port, the host, the issued credentials and the scenario file; each case's failures are printed as
|
|
2
|
+
// vendor, the served port, the host, the issued credentials and the World's scenario file; each case's failures are printed as
|
|
3
3
|
// one JSON line, `{ id, asks, failures }`.
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
@@ -7,7 +7,7 @@ import { extname } from 'node:path';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { clientContext, worldProxy, type ClientCase } from './harness.ts';
|
|
9
9
|
|
|
10
|
-
const job = JSON.parse(readFileSync(process.argv[2]!, 'utf8')) as { vendor: string; port: number; host: string; env: Record<string, string>;
|
|
10
|
+
const job = JSON.parse(readFileSync(process.argv[2]!, 'utf8')) as { vendor: string; port: number; host: string; env: Record<string, string>; scenarioFile: string; packFacts: string; raw?: boolean };
|
|
11
11
|
const { cases } = (await import(`./${job.vendor}${extname(fileURLToPath(import.meta.url))}`)) as { cases: ClientCase[] };
|
|
12
12
|
if (!Array.isArray(cases) || cases.length === 0) throw new Error(`${job.vendor} client module declares no cases; there is no SDK execution evidence`);
|
|
13
13
|
// every connection a case makes goes through the World's proxy, as an application's does in a World
|
|
@@ -20,7 +20,7 @@ try {
|
|
|
20
20
|
const facts = JSON.parse(readFileSync(job.packFacts, 'utf8')).packs[job.vendor];
|
|
21
21
|
const keys: string[] = [...new Set<string>((facts.hosts ?? []).map((rule: { key?: string }) => rule.key ?? job.vendor))];
|
|
22
22
|
require('@volter/world-core/inject').install(Object.fromEntries(keys.map((key) => [key, `http://127.0.0.1:${proxy.port}`])));
|
|
23
|
-
const context = clientContext(proxy.port, job.host, job.env, job.
|
|
23
|
+
const context = clientContext(proxy.port, job.host, job.env, { vendor: job.vendor, file: job.scenarioFile }, job.raw ? job.port : undefined);
|
|
24
24
|
for (const c of cases) {
|
|
25
25
|
const failures = await Promise.race([
|
|
26
26
|
c.run(context).catch((e: Error) => [`the case threw: ${e.message}`]),
|
package/src/clients/xai.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const cases: ClientCase[] = [
|
|
|
12
12
|
asks: "a reasoning turn streamed through the SDK's Responses stream completes with its reasoning item and its message",
|
|
13
13
|
source: 'https://developers.openai.com/api/reference/resources/responses/streaming-events "response.reasoning_summary_part.added"',
|
|
14
14
|
async run(c) {
|
|
15
|
-
c.scenario(
|
|
15
|
+
c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words', reasoning: 'thinking it through' } }]);
|
|
16
16
|
const client = await openaiClient(c, XAI);
|
|
17
17
|
try {
|
|
18
18
|
const stream = client.responses.stream({ model: XAI.model, input: 'hello', reasoning: { summary: 'auto' } } as never);
|
package/src/conformance.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
28
|
import { parseEnv } from 'node:util';
|
|
29
29
|
import { openWebSocket, readHttpResponse, servedHttpFetch, worldProxy } from './clients/harness.ts';
|
|
30
30
|
import { servePack as serve, type ServedPack as Served } from './walk/serve-pack.ts';
|
|
31
|
+
import { playScenario, resolvedScenario } from './walk/world-scenario.ts';
|
|
31
32
|
import { walkPackDirectory } from './walk/pack-directory.ts';
|
|
32
33
|
import { formBody, sigv4Headers } from './walk/journey-kit.ts';
|
|
33
34
|
import { consumerClientProof, consumerRecipe, runConsumerCommand, type ConsumerResult } from './consumer.ts';
|
|
@@ -420,7 +421,7 @@ async function consumerAuthorization(pack: string, scratch: string, root: string
|
|
|
420
421
|
}
|
|
421
422
|
|
|
422
423
|
/** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
|
|
423
|
-
* runs it: once plainly and once with a scenario
|
|
424
|
+
* runs it: once plainly and once with a scenario played (an empty one, as a World that places nothing plays it). Each
|
|
424
425
|
* answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
|
|
425
426
|
* bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
|
|
426
427
|
* refresh list a fresh account can read (one with no parameter in its path) succeeds or remains unread under the
|
|
@@ -458,11 +459,12 @@ async function surfaceSwept(pack: string, scratch: string, runtime: Runtime, con
|
|
|
458
459
|
if (!units.length) return { failures: out, note: 'no unit serves an operation on a path of its own' };
|
|
459
460
|
const tally = { calls: 0, ok: 0, refused: 0, unread: 0, removed: 0 };
|
|
460
461
|
const replayedHeaders = new Set<string>();
|
|
461
|
-
|
|
462
|
-
|
|
462
|
+
// the scenario mode plays an empty timeline as the World's, as a World that places nothing on the vendor plays it
|
|
463
|
+
const empty = resolvedScenario('surface', { start: '2000-01-01T00:00:00.000Z', timeline: [] });
|
|
463
464
|
for (const mode of ['plain', 'scenario'] as const) {
|
|
464
465
|
const root = join(scratch, `surface-${mode}`);
|
|
465
|
-
const
|
|
466
|
+
const restoreScenario = playScenario(join(scratch, 'surface-scenario.json'), mode === 'scenario' ? empty : undefined);
|
|
467
|
+
const server = await serve(pack, { root }).catch((error: unknown) => { restoreScenario(); throw error; });
|
|
466
468
|
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
467
469
|
const send = async (request: Request): Promise<Awaited<ReturnType<typeof readHttpResponse>> | Error> => {
|
|
468
470
|
await server.managedDatabaseUrl?.();
|
|
@@ -626,7 +628,7 @@ async function surfaceSwept(pack: string, scratch: string, runtime: Runtime, con
|
|
|
626
628
|
if (status === 403 && readSomething) { tally.unread++; continue; }
|
|
627
629
|
if (status !== undefined && (status < 200 || status >= 300)) out.push(`${u.name}: ${r.resource}'s refresh lists by ${r.id}, which answered a fresh account ${status}: a World rooted at this twin reads nothing back`);
|
|
628
630
|
}
|
|
629
|
-
} finally { await server.stop(); }
|
|
631
|
+
} finally { try { await server.stop(); } finally { restoreScenario(); } }
|
|
630
632
|
}
|
|
631
633
|
// one defect repeated over a surface reads as one: the first 40 findings, and how many more
|
|
632
634
|
return { failures: out.length > 40 ? [...out.slice(0, 40), `… ${out.length - 40} more`] : out, note: `${tally.calls} served operations called, twice each: ${tally.ok} answered successfully, ${tally.refused} refused, unscripted; ${tally.unread} refresh resources unread (403), ${tally.removed} match declared native removals${replayedHeaders.size ? `; replayed application headers (values redacted): ${[...replayedHeaders].sort().join(', ')}` : ''}` };
|
|
@@ -661,7 +663,7 @@ function lineConnection(port: number): TwinStream {
|
|
|
661
663
|
}
|
|
662
664
|
|
|
663
665
|
/** The life walked as an application calls the pack: its server.ts served as a World runs it (boot id set, the World's
|
|
664
|
-
* root
|
|
666
|
+
* root its option, the life's scenario played as the World's), every step over HTTP with the vendor's host, every check of the walk held
|
|
665
667
|
* (status, expectations, captures, SHAPE, LEAK over answers, events and frames). Slack's events once carried the
|
|
666
668
|
* kernel's updatedAt; a request the served pack does not answer in 10 s fails its step. */
|
|
667
669
|
async function lifeServed(pack: string): Promise<string[]> {
|
|
@@ -669,8 +671,8 @@ async function lifeServed(pack: string): Promise<string[]> {
|
|
|
669
671
|
if (!existsSync(lifePath)) return ['journeys/customer-life.json is missing'];
|
|
670
672
|
const { inMemoryWorld, usePacks, walk } = await import('./walk/behavior-journey.ts');
|
|
671
673
|
usePacks(dirname(pack), { dependenciesFrom: pack });
|
|
672
|
-
const served = async (root: string
|
|
673
|
-
const server = await serve(pack, { root
|
|
674
|
+
const served = async (root: string) => {
|
|
675
|
+
const server = await serve(pack, { root });
|
|
674
676
|
const port = server.inspectPort ?? server.port;
|
|
675
677
|
const http = servedHttpFetch(port);
|
|
676
678
|
return {
|
|
@@ -718,9 +720,11 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
|
|
|
718
720
|
// connection alone; its client names a stand-in host the connection never resolves
|
|
719
721
|
const host = descriptor?.hosts?.flatMap((h) => 'host' in h && h.exclude !== true ? [h.host] : [])[0] ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
|
|
720
722
|
if (!host) return { failures: ['the descriptor names no host for its client to address'] };
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const
|
|
723
|
+
// the World's scenario, empty until a case plays the turns it scripts (the harness writes the file this process's pack
|
|
724
|
+
// reads at each request)
|
|
725
|
+
const scenarioFile = join(scratch, 'client-scenario.json');
|
|
726
|
+
const restoreScenario = playScenario(scenarioFile, resolvedScenario('client-case', { start: '2000-01-01T00:00:00.000Z', timeline: [] }));
|
|
727
|
+
const server = await serve(pack, { root: join(scratch, 'client-world') }).catch((error: unknown) => { restoreScenario(); throw error; });
|
|
724
728
|
try {
|
|
725
729
|
// the World's credentials: what the pack's credential door issues, by the variable a World sets each in
|
|
726
730
|
const issued: Record<string, string> = {};
|
|
@@ -739,7 +743,7 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
|
|
|
739
743
|
packFacts = join(scratch, 'pack-facts.json');
|
|
740
744
|
writeFileSync(packFacts, renderPackFacts(compiled));
|
|
741
745
|
}
|
|
742
|
-
writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued,
|
|
746
|
+
writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioFile, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
|
|
743
747
|
// Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
|
|
744
748
|
const out = await new Promise<string>((resolve) => {
|
|
745
749
|
const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -753,7 +757,7 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
|
|
|
753
757
|
const results = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l) as { id: string; asks: string; failures: string[] });
|
|
754
758
|
if (results.length === 0) return { failures: [`${vendor} client runner returned no case results; there is no SDK execution evidence`] };
|
|
755
759
|
return { failures: results.flatMap((r) => r.failures.map((f) => `${r.id} (${r.asks}): ${f}`)), note: `${results.length} case(s) through the vendor's client` };
|
|
756
|
-
} finally { await server.stop(); }
|
|
760
|
+
} finally { try { await server.stop(); } finally { restoreScenario(); } }
|
|
757
761
|
}
|
|
758
762
|
|
|
759
763
|
/** The gate over every unit of the pack: the form section of the grade, which holds the rules an author's code keeps
|
package/src/create-pack.ts
CHANGED
|
@@ -361,7 +361,7 @@ export function cliFile(vendor: string, live = false): string {
|
|
|
361
361
|
const Name = pascal(vendor);
|
|
362
362
|
const ingest = live ? `// the RTMP ingest's side port, which the World allocates (the manifest's sidePorts)\nconst ingestPort = Number(optionValue(rest, '--ingest-port', '0')) || undefined;\n` : '';
|
|
363
363
|
const served = live ? ` process.stdout.write(s.ingestPort ? \`${vendor} twin ingest at rtmp://127.0.0.1:\${s.ingestPort}\\n\` : '${vendor} twin: no RTMP ingest\\n');\n` : '';
|
|
364
|
-
return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n
|
|
364
|
+
return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
/** A vendor id as a name in code: `npm-registry` is `NpmRegistry`. */
|
|
@@ -110,6 +110,7 @@ export function inMemoryWorld(label: string): { root: () => string; restore: ()
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
import { vendorAnswerViews, vendorFetch as vendorFetchFrom, vendorStream, type VendorStreamFactory } from './vendor-fetch.ts';
|
|
113
|
+
import { playScenario, resolvedScenario, type ResolvedScenario } from './world-scenario.ts';
|
|
113
114
|
import { gitClone, gitPush } from './git-client.ts';
|
|
114
115
|
|
|
115
116
|
/** 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
|
|
@@ -188,10 +189,11 @@ function takeReplies(c: Connection): LineReply[] {
|
|
|
188
189
|
return out;
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
/** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root
|
|
192
|
+
/** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root its option, its scenario
|
|
193
|
+
* the World's file):
|
|
192
194
|
* the walk's requests go to it over HTTP, the vendor's host in their Host header, instead of to the fetch in process;
|
|
193
195
|
* its sockets are WebSockets to that port, and a line connection (`stream`) is a TCP connection to its protocol port. */
|
|
194
|
-
export type ServedTwin = (root: string, options: {
|
|
196
|
+
export type ServedTwin = (root: string, options: { application?: import('@volter/world-core').PackFetchOptions['application'] }) => Promise<{ scheme?: 'http' | 'https'; fetch: ((request: Request, options?: { credentials?: RequestCredentials }) => Promise<Response>) & ManagedDatabaseWalk; stop: () => void | Promise<void>; socket?: (url: string, headers: Record<string, string>) => WebSocket; stream?: TwinStream }>;
|
|
195
197
|
|
|
196
198
|
export async function walk(journey: Journey, root: string, collect: Failure[], opts: { beats: boolean; verbose?: boolean; hooks?: StepHooks; captured?: Captured; served?: ServedTwin }): Promise<Captured> {
|
|
197
199
|
attributionName(journey.author);
|
|
@@ -263,13 +265,16 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
|
|
|
263
265
|
const ownKeys = routingKeys(journey.vendor);
|
|
264
266
|
/** The companion that serves a step's URL: one whose host rules claim it where the life's own vendor's do not. */
|
|
265
267
|
const companionOf = (url: string) => (claims(ownKeys, url) ? undefined : companions.find((c) => claims(c.keys, url)));
|
|
266
|
-
// the life's scenario is the World's: written
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
// the life's scenario is the World's: resolved here, and played (written beside the walk's root in its World store and
|
|
269
|
+
// named by TWIN_WORLD_SCENARIO_FILE, which every pack of the walk reads at each request as a World's twins do) from its
|
|
270
|
+
// first step to its end; a life naming none plays none, whatever World the walk itself runs in
|
|
271
|
+
let scenario: ResolvedScenario | undefined;
|
|
272
|
+
try {
|
|
273
|
+
if (journey.scenario) scenario = resolvedScenario(journey.scenario, parseExactJson(readFileSync(join(directoryFor(journey.vendor), 'journeys', journey.scenario), 'utf8')));
|
|
274
|
+
} catch (error) { collect.push({ step: '(setup)', beat: 'SCENARIO', detail: `journeys/${journey.scenario}: ${(error as Error).message}` }); return {}; }
|
|
270
275
|
const shape = await shapeOf(journey.vendor);
|
|
271
|
-
const served = opts.served ? await opts.served(root, {
|
|
272
|
-
const twin = served ? served.fetch : factory(root, {
|
|
276
|
+
const served = opts.served ? await opts.served(root, { application }) : undefined;
|
|
277
|
+
const twin = served ? served.fetch : factory(root, { application });
|
|
273
278
|
const host = journey.host ?? 'twin.local';
|
|
274
279
|
const origin = `${served?.scheme ?? 'http'}://${host}`;
|
|
275
280
|
// a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
|
|
@@ -747,6 +752,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
|
|
|
747
752
|
...ownKeys.map((key) => [key, twin] as const),
|
|
748
753
|
]));
|
|
749
754
|
const previousMailServers = setInProcessMailServers(mailServers);
|
|
755
|
+
const restoreScenario = playScenario(`${root}.scenario.json`, scenario);
|
|
750
756
|
try {
|
|
751
757
|
for (const step of journey.steps) {
|
|
752
758
|
await settleEventDeliveries(root);
|
|
@@ -965,6 +971,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
|
|
|
965
971
|
if (failures.length) throw new AggregateError(failures, 'Walk native database retirement failed');
|
|
966
972
|
} finally {
|
|
967
973
|
if (clockFile && startedAt !== undefined) store.write(clockFile, startedAt);
|
|
974
|
+
restoreScenario();
|
|
968
975
|
}
|
|
969
976
|
}
|
|
970
977
|
}
|
package/src/walk/journey-kit.ts
CHANGED
|
@@ -305,8 +305,9 @@ export type Journey = {
|
|
|
305
305
|
/** The instant the story begins, where it is before the World's day one (2026-01-01): an account whose story starts
|
|
306
306
|
* while an API it used still made what it no longer makes. Its waits move the clock on from there. */
|
|
307
307
|
startsAt?: string;
|
|
308
|
-
/** The World's scenario
|
|
309
|
-
*
|
|
308
|
+
/** The World's scenario the life is walked under (a scenario document beside it, `journeys/<name>.json`: ADR 0036,
|
|
309
|
+
* `{ start, timeline }`): the walk plays it as the World's, so the decisions and faults it places, and the pack's code
|
|
310
|
+
* that consumes them, are walked. */
|
|
310
311
|
scenario?: string;
|
|
311
312
|
/** The packs of the same catalog, by vendor, that the vendor calls as its own behaviour (`ctx.vendorFetch`: a sign-in
|
|
312
313
|
* trading a code at its provider): the walk loads each over the walk's own World and root, the vendor's calls reach
|
package/src/walk/serve-pack.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type ServedPack = {
|
|
|
10
10
|
stop: () => void | Promise<void>;
|
|
11
11
|
} & Omit<ManagedDatabaseWalk, 'stopDatabase'>;
|
|
12
12
|
|
|
13
|
-
export async function servePack(pack: string, options: { root?: string
|
|
13
|
+
export async function servePack(pack: string, options: { root?: string }): Promise<ServedPack> {
|
|
14
14
|
const previousBoot = process.env.VOLTER_WORLD_BOOT_ID;
|
|
15
15
|
process.env.VOLTER_WORLD_BOOT_ID = 'conformance';
|
|
16
16
|
try { return await serve(pack, options); }
|
|
@@ -20,7 +20,7 @@ export async function servePack(pack: string, options: { root?: string; scenario
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
async function serve(pack: string, options: { root?: string
|
|
23
|
+
async function serve(pack: string, options: { root?: string }): Promise<ServedPack> {
|
|
24
24
|
const mod = await import(join(pack, 'src', 'server.ts')) as Record<string, unknown>;
|
|
25
25
|
const create = Object.entries(mod).find(([name, value]) => /^create\w+TwinServer$/.test(name) && typeof value === 'function')?.[1] as ((options: Record<string, unknown>) => Promise<ServedPack>) | undefined;
|
|
26
26
|
if (!create) throw new Error('src/server.ts exports no create<Vendor>TwinServer');
|
package/src/walk/vendor-fetch.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { walkPackDirectory } from './pack-directory.ts';
|
|
|
9
9
|
|
|
10
10
|
type ApplicationStandIn = NonNullable<PackFetchOptions['application']>;
|
|
11
11
|
|
|
12
|
-
/** A pack's fetch over a World's root
|
|
12
|
+
/** A pack's fetch over a World's root (the World's scenario it reads itself, TWIN_WORLD_SCENARIO_FILE), and a
|
|
13
13
|
* life's application answers stand in for the application the vendor asks (`application`, this fetch's alone). */
|
|
14
|
-
export type VendorFetchFactory = ((root: string, options?: {
|
|
14
|
+
export type VendorFetchFactory = ((root: string, options?: { application?: ApplicationStandIn }) => ((request: Request) => Response | Promise<Response>) & ManagedDatabaseWalk) & { managedDatabaseDoor?: string };
|
|
15
15
|
|
|
16
16
|
export async function vendorFetch(twinDir: string, vendor: string, dependenciesFrom = twinDir): Promise<VendorFetchFactory | undefined> {
|
|
17
17
|
const directory = walkPackDirectory(twinDir, vendor, dependenciesFrom);
|
|
@@ -26,7 +26,7 @@ export async function packFetch(directory: string): Promise<VendorFetchFactory |
|
|
|
26
26
|
// a pack exports create<Name>Fetch (its generated fetch.ts)
|
|
27
27
|
const name = Object.keys(mod).find((k) => /^create\w+?Fetch$/.test(k) && !/TwinFetch$/.test(k) && typeof mod[k] === 'function');
|
|
28
28
|
if (!name) return undefined;
|
|
29
|
-
const factory = mod[name] as (options: { root: string; database?: string;
|
|
29
|
+
const factory = mod[name] as (options: { root: string; database?: string; application?: ApplicationStandIn }) => ((request: Request) => Response | Promise<Response>) & Record<string, unknown>;
|
|
30
30
|
const descriptor = mod.pack as TwinPack | undefined;
|
|
31
31
|
if (!descriptor?.managedDatabase) return (root: string, options = {}) => factory({ root, ...options });
|
|
32
32
|
const managedDatabaseClock = descriptor.managedDatabase.clock;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The World's scenario as the standard plays it for a walk or a served pack (ADR 0036; architecture, "Scenarios"): the
|
|
2
|
+
// document parsed and resolved as a World resolves it, written through the active World store, and named by
|
|
3
|
+
// TWIN_WORLD_SCENARIO_FILE, where every twin in this process reads it at each request as it reads the clock. A pack takes
|
|
4
|
+
// no scenario option: what it plays is the World's one scenario, so the standard sets the World's.
|
|
5
|
+
import { parseScenario, resolveScenario, WORLD_SCENARIO_ENV, type ResolvedScenario } from '@volter/world-core';
|
|
6
|
+
import { getActiveWorldStore } from '@volter/world-core/runtime';
|
|
7
|
+
|
|
8
|
+
export type { ResolvedScenario };
|
|
9
|
+
|
|
10
|
+
/** A scenario document as a World plays it; one the grammar refuses throws its ScenarioTimelineError, which names the
|
|
11
|
+
* entry and the vocabulary it expected. */
|
|
12
|
+
export function resolvedScenario(name: string, document: unknown): ResolvedScenario {
|
|
13
|
+
return resolveScenario(name, parseScenario(document));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Play `scenario` as the World's at `file` until the returned restore, which puts the variable back as it was. Playing
|
|
17
|
+
* none clears the variable, so what the standard serves inside a World plays nothing of that World's. */
|
|
18
|
+
export function playScenario(file: string, scenario: ResolvedScenario | undefined): () => void {
|
|
19
|
+
if (scenario) getActiveWorldStore().write(file, JSON.stringify(scenario));
|
|
20
|
+
const previous = process.env[WORLD_SCENARIO_ENV];
|
|
21
|
+
if (scenario) process.env[WORLD_SCENARIO_ENV] = file; else delete process.env[WORLD_SCENARIO_ENV];
|
|
22
|
+
return () => { if (previous === undefined) delete process.env[WORLD_SCENARIO_ENV]; else process.env[WORLD_SCENARIO_ENV] = previous; };
|
|
23
|
+
}
|