@volter/twin-standard 1.0.160 → 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 +37 -26
- package/dist/src/create-pack.js +1 -1
- package/dist/src/protocol-3.js +1 -9
- 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 +26 -25
- package/src/create-pack.ts +1 -1
- package/src/protocol-3.ts +1 -7
- 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
|
@@ -28,11 +28,13 @@ import { basename, dirname, extname, join, matchesGlob, relative, resolve, sep }
|
|
|
28
28
|
const rel = (from, to) => relative(from, to).split(sep).join('/');
|
|
29
29
|
import { hostRules } from '@volter/world-core/host-routing';
|
|
30
30
|
import { twinEnvStem } from '@volter/world-core/vendor-hosts';
|
|
31
|
+
import { checkoutOf } from '@volter/world-core/checkout';
|
|
31
32
|
import { citations, sourceHeld } from "./check-sources.js";
|
|
32
33
|
import { fileURLToPath } from 'node:url';
|
|
33
34
|
import { parseEnv } from 'node:util';
|
|
34
35
|
import { openWebSocket, readHttpResponse, servedHttpFetch, worldProxy } from "./clients/harness.js";
|
|
35
36
|
import { servePack as serve } from "./walk/serve-pack.js";
|
|
37
|
+
import { playScenario, resolvedScenario } from "./walk/world-scenario.js";
|
|
36
38
|
import { walkPackDirectory } from "./walk/pack-directory.js";
|
|
37
39
|
import { formBody, sigv4Headers } from "./walk/journey-kit.js";
|
|
38
40
|
import { consumerClientProof, consumerRecipe, runConsumerCommand } from "./consumer.js";
|
|
@@ -82,19 +84,15 @@ const trackedJourneySources = new Map();
|
|
|
82
84
|
/** Table ownership follows one index snapshot per checkout/run. */
|
|
83
85
|
function hasJourneySource(unit) {
|
|
84
86
|
const dir = join(unit, 'journeys');
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
&& (entry.isFile() || (entry.isDirectory() && holdsFile(join(at, entry.name)))));
|
|
95
|
-
return holdsFile(dir);
|
|
96
|
-
}
|
|
97
|
-
checkout = parent;
|
|
87
|
+
const checkout = checkoutOf(unit);
|
|
88
|
+
if (!checkout) {
|
|
89
|
+
// An unpacked published package has no Git index; its delivered files
|
|
90
|
+
// are its source. Markers and empty directories are not table inputs.
|
|
91
|
+
if (!existsSync(dir))
|
|
92
|
+
return false;
|
|
93
|
+
const holdsFile = (at) => readdirSync(at, { withFileTypes: true }).some(entry => entry.name !== '.metadata_never_index'
|
|
94
|
+
&& (entry.isFile() || (entry.isDirectory() && holdsFile(join(at, entry.name)))));
|
|
95
|
+
return holdsFile(dir);
|
|
98
96
|
}
|
|
99
97
|
const env = { ...process.env };
|
|
100
98
|
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_PREFIX', 'GIT_INDEX_FILE'])
|
|
@@ -482,7 +480,7 @@ async function consumerAuthorization(pack, scratch, root, server, descriptor, ou
|
|
|
482
480
|
}
|
|
483
481
|
}
|
|
484
482
|
/** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
|
|
485
|
-
* 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
|
|
486
484
|
* answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
|
|
487
485
|
* bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
|
|
488
486
|
* refresh list a fresh account can read (one with no parameter in its path) succeeds or remains unread under the
|
|
@@ -524,11 +522,12 @@ async function surfaceSwept(pack, scratch, runtime, consumer) {
|
|
|
524
522
|
return { failures: out, note: 'no unit serves an operation on a path of its own' };
|
|
525
523
|
const tally = { calls: 0, ok: 0, refused: 0, unread: 0, removed: 0 };
|
|
526
524
|
const replayedHeaders = new Set();
|
|
527
|
-
|
|
528
|
-
|
|
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: [] });
|
|
529
527
|
for (const mode of ['plain', 'scenario']) {
|
|
530
528
|
const root = join(scratch, `surface-${mode}`);
|
|
531
|
-
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; });
|
|
532
531
|
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
533
532
|
const send = async (request) => {
|
|
534
533
|
await server.managedDatabaseUrl?.();
|
|
@@ -771,7 +770,12 @@ async function surfaceSwept(pack, scratch, runtime, consumer) {
|
|
|
771
770
|
}
|
|
772
771
|
}
|
|
773
772
|
finally {
|
|
774
|
-
|
|
773
|
+
try {
|
|
774
|
+
await server.stop();
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
restoreScenario();
|
|
778
|
+
}
|
|
775
779
|
}
|
|
776
780
|
}
|
|
777
781
|
// one defect repeated over a surface reads as one: the first 40 findings, and how many more
|
|
@@ -808,7 +812,7 @@ function lineConnection(port) {
|
|
|
808
812
|
};
|
|
809
813
|
}
|
|
810
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
|
|
811
|
-
* 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
|
|
812
816
|
* (status, expectations, captures, SHAPE, LEAK over answers, events and frames). Slack's events once carried the
|
|
813
817
|
* kernel's updatedAt; a request the served pack does not answer in 10 s fails its step. */
|
|
814
818
|
async function lifeServed(pack) {
|
|
@@ -817,8 +821,8 @@ async function lifeServed(pack) {
|
|
|
817
821
|
return ['journeys/customer-life.json is missing'];
|
|
818
822
|
const { inMemoryWorld, usePacks, walk } = await import("./walk/behavior-journey.js");
|
|
819
823
|
usePacks(dirname(pack), { dependenciesFrom: pack });
|
|
820
|
-
const served = async (root
|
|
821
|
-
const server = await serve(pack, { root
|
|
824
|
+
const served = async (root) => {
|
|
825
|
+
const server = await serve(pack, { root });
|
|
822
826
|
const port = server.inspectPort ?? server.port;
|
|
823
827
|
const http = servedHttpFetch(port);
|
|
824
828
|
return {
|
|
@@ -871,9 +875,11 @@ async function clientCases(pack, scratch, consumer) {
|
|
|
871
875
|
const host = descriptor?.hosts?.flatMap((h) => 'host' in h && h.exclude !== true ? [h.host] : [])[0] ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
|
|
872
876
|
if (!host)
|
|
873
877
|
return { failures: ['the descriptor names no host for its client to address'] };
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
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; });
|
|
877
883
|
try {
|
|
878
884
|
// the World's credentials: what the pack's credential door issues, by the variable a World sets each in
|
|
879
885
|
const issued = {};
|
|
@@ -893,7 +899,7 @@ async function clientCases(pack, scratch, consumer) {
|
|
|
893
899
|
packFacts = join(scratch, 'pack-facts.json');
|
|
894
900
|
writeFileSync(packFacts, renderPackFacts(compiled));
|
|
895
901
|
}
|
|
896
|
-
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 } : {}) }));
|
|
897
903
|
// Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
|
|
898
904
|
const out = await new Promise((resolve) => {
|
|
899
905
|
const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -910,7 +916,12 @@ async function clientCases(pack, scratch, consumer) {
|
|
|
910
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` };
|
|
911
917
|
}
|
|
912
918
|
finally {
|
|
913
|
-
|
|
919
|
+
try {
|
|
920
|
+
await server.stop();
|
|
921
|
+
}
|
|
922
|
+
finally {
|
|
923
|
+
restoreScenario();
|
|
924
|
+
}
|
|
914
925
|
}
|
|
915
926
|
}
|
|
916
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('');
|
package/dist/src/protocol-3.js
CHANGED
|
@@ -21,6 +21,7 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync }
|
|
|
21
21
|
import { tmpdir } from 'node:os';
|
|
22
22
|
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
23
23
|
import { gunzipSync } from 'node:zlib';
|
|
24
|
+
import { checkoutOf } from '@volter/world-core/checkout';
|
|
24
25
|
import ts from 'typescript';
|
|
25
26
|
import { fileURLToPath } from 'node:url';
|
|
26
27
|
import { sharingLanesIn } from "./lanes.js";
|
|
@@ -71,15 +72,6 @@ function specFiles(unit) {
|
|
|
71
72
|
const dir = join(unit.dir, 'spec');
|
|
72
73
|
return filesUnder(dir).filter((f) => !SPEC_METADATA.test(f) && !SPEC_TOOL.test(f)).map((f) => join(dir, f));
|
|
73
74
|
}
|
|
74
|
-
/** The checkout a directory is in: the nearest ancestor holding `.git` (a directory, or a worktree's file). */
|
|
75
|
-
function checkoutOf(dir) {
|
|
76
|
-
for (let d = dir;; d = dirname(d)) {
|
|
77
|
-
if (existsSync(join(d, '.git')))
|
|
78
|
-
return d;
|
|
79
|
-
if (dirname(d) === d)
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
75
|
// Grading fans out over a checkout's directories. A caller explicitly owns the immutable listing for one pass;
|
|
84
76
|
// nested gates share it, while concurrent passes have separate snapshots and direct checks always read git afresh.
|
|
85
77
|
const checkoutFiles = new AsyncLocalStorage();
|
|
@@ -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
|
@@ -21,12 +21,14 @@ const rel = (from: string, to: string): string => relative(from, to).split(sep).
|
|
|
21
21
|
import type { PackDescriptor, TwinStream } from '@volter/world-core';
|
|
22
22
|
import { hostRules } from '@volter/world-core/host-routing';
|
|
23
23
|
import { twinEnvStem } from '@volter/world-core/vendor-hosts';
|
|
24
|
+
import { checkoutOf } from '@volter/world-core/checkout';
|
|
24
25
|
import type { DerivedManifest, ErrorSpec, RemoteExecuteResponse, StateSystemAdapters, TwinAuthStrategy } from '@volter/world-core/runtime';
|
|
25
26
|
import { citations, sourceHeld, type SourceRecord } from './check-sources.ts';
|
|
26
27
|
import { fileURLToPath } from 'node:url';
|
|
27
28
|
import { parseEnv } from 'node:util';
|
|
28
29
|
import { openWebSocket, readHttpResponse, servedHttpFetch, worldProxy } from './clients/harness.ts';
|
|
29
30
|
import { servePack as serve, type ServedPack as Served } from './walk/serve-pack.ts';
|
|
31
|
+
import { playScenario, resolvedScenario } from './walk/world-scenario.ts';
|
|
30
32
|
import { walkPackDirectory } from './walk/pack-directory.ts';
|
|
31
33
|
import { formBody, sigv4Headers } from './walk/journey-kit.ts';
|
|
32
34
|
import { consumerClientProof, consumerRecipe, runConsumerCommand, type ConsumerResult } from './consumer.ts';
|
|
@@ -91,18 +93,14 @@ const trackedJourneySources = new Map<string, string[]>();
|
|
|
91
93
|
/** Table ownership follows one index snapshot per checkout/run. */
|
|
92
94
|
function hasJourneySource(unit: string): boolean {
|
|
93
95
|
const dir = join(unit, 'journeys');
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
&& (entry.isFile() || (entry.isDirectory() && holdsFile(join(at, entry.name)))));
|
|
103
|
-
return holdsFile(dir);
|
|
104
|
-
}
|
|
105
|
-
checkout = parent;
|
|
96
|
+
const checkout = checkoutOf(unit);
|
|
97
|
+
if (!checkout) {
|
|
98
|
+
// An unpacked published package has no Git index; its delivered files
|
|
99
|
+
// are its source. Markers and empty directories are not table inputs.
|
|
100
|
+
if (!existsSync(dir)) return false;
|
|
101
|
+
const holdsFile = (at: string): boolean => readdirSync(at, { withFileTypes: true }).some(entry => entry.name !== '.metadata_never_index'
|
|
102
|
+
&& (entry.isFile() || (entry.isDirectory() && holdsFile(join(at, entry.name)))));
|
|
103
|
+
return holdsFile(dir);
|
|
106
104
|
}
|
|
107
105
|
const env = { ...process.env };
|
|
108
106
|
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_PREFIX', 'GIT_INDEX_FILE']) delete env[key];
|
|
@@ -423,7 +421,7 @@ async function consumerAuthorization(pack: string, scratch: string, root: string
|
|
|
423
421
|
}
|
|
424
422
|
|
|
425
423
|
/** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
|
|
426
|
-
* 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
|
|
427
425
|
* answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
|
|
428
426
|
* bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
|
|
429
427
|
* refresh list a fresh account can read (one with no parameter in its path) succeeds or remains unread under the
|
|
@@ -461,11 +459,12 @@ async function surfaceSwept(pack: string, scratch: string, runtime: Runtime, con
|
|
|
461
459
|
if (!units.length) return { failures: out, note: 'no unit serves an operation on a path of its own' };
|
|
462
460
|
const tally = { calls: 0, ok: 0, refused: 0, unread: 0, removed: 0 };
|
|
463
461
|
const replayedHeaders = new Set<string>();
|
|
464
|
-
|
|
465
|
-
|
|
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: [] });
|
|
466
464
|
for (const mode of ['plain', 'scenario'] as const) {
|
|
467
465
|
const root = join(scratch, `surface-${mode}`);
|
|
468
|
-
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; });
|
|
469
468
|
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
470
469
|
const send = async (request: Request): Promise<Awaited<ReturnType<typeof readHttpResponse>> | Error> => {
|
|
471
470
|
await server.managedDatabaseUrl?.();
|
|
@@ -629,7 +628,7 @@ async function surfaceSwept(pack: string, scratch: string, runtime: Runtime, con
|
|
|
629
628
|
if (status === 403 && readSomething) { tally.unread++; continue; }
|
|
630
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`);
|
|
631
630
|
}
|
|
632
|
-
} finally { await server.stop(); }
|
|
631
|
+
} finally { try { await server.stop(); } finally { restoreScenario(); } }
|
|
633
632
|
}
|
|
634
633
|
// one defect repeated over a surface reads as one: the first 40 findings, and how many more
|
|
635
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(', ')}` : ''}` };
|
|
@@ -664,7 +663,7 @@ function lineConnection(port: number): TwinStream {
|
|
|
664
663
|
}
|
|
665
664
|
|
|
666
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
|
|
667
|
-
* 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
|
|
668
667
|
* (status, expectations, captures, SHAPE, LEAK over answers, events and frames). Slack's events once carried the
|
|
669
668
|
* kernel's updatedAt; a request the served pack does not answer in 10 s fails its step. */
|
|
670
669
|
async function lifeServed(pack: string): Promise<string[]> {
|
|
@@ -672,8 +671,8 @@ async function lifeServed(pack: string): Promise<string[]> {
|
|
|
672
671
|
if (!existsSync(lifePath)) return ['journeys/customer-life.json is missing'];
|
|
673
672
|
const { inMemoryWorld, usePacks, walk } = await import('./walk/behavior-journey.ts');
|
|
674
673
|
usePacks(dirname(pack), { dependenciesFrom: pack });
|
|
675
|
-
const served = async (root: string
|
|
676
|
-
const server = await serve(pack, { root
|
|
674
|
+
const served = async (root: string) => {
|
|
675
|
+
const server = await serve(pack, { root });
|
|
677
676
|
const port = server.inspectPort ?? server.port;
|
|
678
677
|
const http = servedHttpFetch(port);
|
|
679
678
|
return {
|
|
@@ -721,9 +720,11 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
|
|
|
721
720
|
// connection alone; its client names a stand-in host the connection never resolves
|
|
722
721
|
const host = descriptor?.hosts?.flatMap((h) => 'host' in h && h.exclude !== true ? [h.host] : [])[0] ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
|
|
723
722
|
if (!host) return { failures: ['the descriptor names no host for its client to address'] };
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
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; });
|
|
727
728
|
try {
|
|
728
729
|
// the World's credentials: what the pack's credential door issues, by the variable a World sets each in
|
|
729
730
|
const issued: Record<string, string> = {};
|
|
@@ -742,7 +743,7 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
|
|
|
742
743
|
packFacts = join(scratch, 'pack-facts.json');
|
|
743
744
|
writeFileSync(packFacts, renderPackFacts(compiled));
|
|
744
745
|
}
|
|
745
|
-
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 } : {}) }));
|
|
746
747
|
// Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
|
|
747
748
|
const out = await new Promise<string>((resolve) => {
|
|
748
749
|
const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -756,7 +757,7 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
|
|
|
756
757
|
const results = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l) as { id: string; asks: string; failures: string[] });
|
|
757
758
|
if (results.length === 0) return { failures: [`${vendor} client runner returned no case results; there is no SDK execution evidence`] };
|
|
758
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` };
|
|
759
|
-
} finally { await server.stop(); }
|
|
760
|
+
} finally { try { await server.stop(); } finally { restoreScenario(); } }
|
|
760
761
|
}
|
|
761
762
|
|
|
762
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`. */
|
package/src/protocol-3.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync }
|
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
15
15
|
import { gunzipSync } from 'node:zlib';
|
|
16
|
+
import { checkoutOf } from '@volter/world-core/checkout';
|
|
16
17
|
import ts from 'typescript';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { sharingLanesIn } from './lanes.ts';
|
|
@@ -62,13 +63,6 @@ function specFiles(unit: GradeUnit): string[] {
|
|
|
62
63
|
return filesUnder(dir).filter((f) => !SPEC_METADATA.test(f) && !SPEC_TOOL.test(f)).map((f) => join(dir, f));
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
/** The checkout a directory is in: the nearest ancestor holding `.git` (a directory, or a worktree's file). */
|
|
66
|
-
function checkoutOf(dir: string): string | undefined {
|
|
67
|
-
for (let d = dir; ; d = dirname(d)) {
|
|
68
|
-
if (existsSync(join(d, '.git'))) return d;
|
|
69
|
-
if (dirname(d) === d) return undefined;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
66
|
// Grading fans out over a checkout's directories. A caller explicitly owns the immutable listing for one pass;
|
|
73
67
|
// nested gates share it, while concurrent passes have separate snapshots and direct checks always read git afresh.
|
|
74
68
|
const checkoutFiles = new AsyncLocalStorage<Map<string, string[]>>();
|
|
@@ -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
|
+
}
|