sparta_worlds 0.6.0
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/LICENSE.txt +15 -0
- package/bin/worlds.js +53 -0
- package/dist/cli/argv.js +27 -0
- package/dist/cli/claims.js +250 -0
- package/dist/cli/commands.js +67 -0
- package/dist/cli/contract.js +142 -0
- package/dist/cli/demo.js +114 -0
- package/dist/cli/doctor.js +299 -0
- package/dist/cli/expect.js +197 -0
- package/dist/cli/home.js +18 -0
- package/dist/cli/import.js +530 -0
- package/dist/cli/index.js +1014 -0
- package/dist/cli/init.js +725 -0
- package/dist/cli/judge-command.js +184 -0
- package/dist/cli/judge.js +1495 -0
- package/dist/cli/load.js +8 -0
- package/dist/cli/packs.js +82 -0
- package/dist/cli/passk.js +1256 -0
- package/dist/cli/policy-starters.js +83 -0
- package/dist/cli/preload.js +45 -0
- package/dist/cli/report.js +579 -0
- package/dist/cli/rubric.js +727 -0
- package/dist/cli/run.js +266 -0
- package/dist/cli/scoreboard.js +432 -0
- package/dist/cli/selftest.js +157 -0
- package/dist/cli/stdio.js +25 -0
- package/dist/cli/tasks.js +1246 -0
- package/dist/cli/try.js +156 -0
- package/dist/cli/version.js +17 -0
- package/package.json +38 -0
- package/packs/cancellations.yaml +201 -0
- package/packs/credits.yaml +274 -0
- package/packs/invoices.yaml +206 -0
- package/packs/plan-changes.yaml +170 -0
- package/packs/refunds.yaml +437 -0
- package/preload/node-hooks.mjs +28 -0
- package/preload/node.mjs +19 -0
- package/preload/python/sitecustomize.py +88 -0
- package/preload/wrap.mjs +81 -0
- package/rubrics/acme-prod-library.json +1427 -0
- package/rubrics/acme-prod-shift.json +499 -0
- package/server/dist/admin/auth.js +145 -0
- package/server/dist/admin/routes.js +469 -0
- package/server/dist/admin/schemas.js +243 -0
- package/server/dist/app.js +112 -0
- package/server/dist/core/canonical.js +22 -0
- package/server/dist/core/db.js +294 -0
- package/server/dist/core/errors.js +136 -0
- package/server/dist/core/manager.js +337 -0
- package/server/dist/core/money.js +43 -0
- package/server/dist/core/nondeterministic.js +39 -0
- package/server/dist/core/paths.js +33 -0
- package/server/dist/core/prng.js +19 -0
- package/server/dist/core/protocol.js +1 -0
- package/server/dist/core/publicUrl.js +49 -0
- package/server/dist/core/seed.js +286 -0
- package/server/dist/core/seedExport.js +169 -0
- package/server/dist/core/seedLoader.js +156 -0
- package/server/dist/core/time.js +45 -0
- package/server/dist/core/world.js +361 -0
- package/server/dist/index.js +17 -0
- package/server/dist/main.js +42 -0
- package/server/dist/scenario/engine.js +172 -0
- package/server/dist/scenario/library.js +49 -0
- package/server/dist/twin/build.js +372 -0
- package/server/dist/twin/pagination.js +32 -0
- package/server/dist/twin/params.js +118 -0
- package/server/dist/twin/resources/charges.js +24 -0
- package/server/dist/twin/resources/customers.js +151 -0
- package/server/dist/twin/resources/events.js +26 -0
- package/server/dist/twin/resources/invoices.js +217 -0
- package/server/dist/twin/resources/payment_intents.js +146 -0
- package/server/dist/twin/resources/products.js +92 -0
- package/server/dist/twin/resources/refunds.js +103 -0
- package/server/dist/twin/resources/shared.js +113 -0
- package/server/dist/twin/resources/subscriptions.js +325 -0
- package/server/dist/twin/resources/tickets.js +128 -0
- package/server/dist/twin/router.js +279 -0
- package/server/dist/twin/unsupported.js +212 -0
- package/server/dist/webhooks/dispatcher.js +148 -0
- package/server/scenarios/auth-expiry.yaml +7 -0
- package/server/scenarios/intermittent-500s.yaml +9 -0
- package/server/scenarios/lock-timeouts.yaml +12 -0
- package/server/scenarios/rate-limit-storm.yaml +10 -0
- package/server/scenarios/slow-stripe.yaml +9 -0
- package/server/scenarios/stripe-storm-shift.yaml +15 -0
- package/server/scenarios/tickets-storm.yaml +10 -0
- package/server/scenarios/webhook-chaos.yaml +7 -0
- package/server/seeds/acme-prod-library.json +40470 -0
- package/server/seeds/acme-prod-shift.json +36544 -0
- package/server/seeds/acme-prod.json +35412 -0
- package/server/seeds/saas-billing-small.json +4792 -0
- package/server/seeds/saas-billing-smoke.json +160 -0
package/dist/cli/run.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createServer as createNetServer } from "node:net";
|
|
5
|
+
import { constants, tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { buildServer } from "#server";
|
|
8
|
+
import { createAdminCall } from "./passk.js";
|
|
9
|
+
import { preloadEnabled, preloadEnv } from "./preload.js";
|
|
10
|
+
export class RunSetupError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(message, code = 3) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const HEALTH_TIMEOUT_MS = 15_000;
|
|
18
|
+
export function pidOnPort(port) {
|
|
19
|
+
try {
|
|
20
|
+
const res = spawnSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { encoding: "utf8", timeout: 3000 });
|
|
21
|
+
const first = res.stdout.trim().split("\n")[0];
|
|
22
|
+
return first && /^\d+$/.test(first) ? Number(first) : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function checkPortFree(port, host) {
|
|
29
|
+
return new Promise((resolvePromise, reject) => {
|
|
30
|
+
const probe = createNetServer();
|
|
31
|
+
probe.once("error", (err) => {
|
|
32
|
+
if (err.code === "EADDRINUSE") {
|
|
33
|
+
const pid = pidOnPort(port);
|
|
34
|
+
reject(new RunSetupError(`port ${port} is in use${pid !== undefined ? ` by pid ${pid}` : ""}`));
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
reject(new RunSetupError(`port ${port} cannot be bound on ${host}: ${err.message}`));
|
|
38
|
+
});
|
|
39
|
+
probe.listen(port, host, () => probe.close(() => resolvePromise()));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export async function startServer(opts = {}) {
|
|
43
|
+
const host = opts.host ?? "127.0.0.1";
|
|
44
|
+
const log = opts.log ?? (() => { });
|
|
45
|
+
for (const [flag, dir] of [
|
|
46
|
+
["--seeds-dir", opts.seedsDir],
|
|
47
|
+
["--scenarios-dir", opts.scenariosDir],
|
|
48
|
+
])
|
|
49
|
+
if (dir !== undefined && !existsSync(resolve(dir)))
|
|
50
|
+
throw new RunSetupError(`${flag} ${resolve(dir)} does not exist`);
|
|
51
|
+
if (opts.port !== undefined)
|
|
52
|
+
await checkPortFree(opts.port, host);
|
|
53
|
+
const dataDir = opts.dataDir !== undefined ? resolve(opts.dataDir) : mkdtempSync(join(tmpdir(), "worlds-run-"));
|
|
54
|
+
mkdirSync(dataDir, { recursive: true });
|
|
55
|
+
const logFile = join(dataDir, "run.log");
|
|
56
|
+
writeFileSync(logFile, "");
|
|
57
|
+
const note = (line) => {
|
|
58
|
+
appendFileSync(logFile, `${line}\n`);
|
|
59
|
+
log(line);
|
|
60
|
+
};
|
|
61
|
+
const token = `tlr_${randomBytes(32).toString("base64url")}`;
|
|
62
|
+
const server = buildServer({
|
|
63
|
+
dataDir,
|
|
64
|
+
seedsDir: opts.seedsDir,
|
|
65
|
+
scenariosDir: opts.scenariosDir,
|
|
66
|
+
auth: { mode: "required", operatorToken: token },
|
|
67
|
+
});
|
|
68
|
+
let url;
|
|
69
|
+
try {
|
|
70
|
+
url = await server.listen(opts.port ?? 0, host);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
await server.close().catch(() => { });
|
|
74
|
+
if (opts.dataDir === undefined)
|
|
75
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
76
|
+
const code = err.code;
|
|
77
|
+
const pid = code === "EADDRINUSE" && opts.port !== undefined ? pidOnPort(opts.port) : undefined;
|
|
78
|
+
throw new RunSetupError(code === "EADDRINUSE"
|
|
79
|
+
? `port ${opts.port} is in use${pid !== undefined ? ` by pid ${pid}` : ""}`
|
|
80
|
+
: `the server could not start: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
|
83
|
+
for (;;) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`${url}/admin/health`);
|
|
86
|
+
if (res.ok)
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
}
|
|
91
|
+
if (Date.now() > deadline) {
|
|
92
|
+
await server.close();
|
|
93
|
+
throw new RunSetupError(`the server did not answer ${url}/admin/health within ${HEALTH_TIMEOUT_MS / 1000}s`);
|
|
94
|
+
}
|
|
95
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
96
|
+
}
|
|
97
|
+
note(`run: worlds server at ${url} (auth required; data ${dataDir})`);
|
|
98
|
+
let closed = false;
|
|
99
|
+
return {
|
|
100
|
+
url,
|
|
101
|
+
token,
|
|
102
|
+
dataDir,
|
|
103
|
+
logFile,
|
|
104
|
+
admin: createAdminCall(url, token),
|
|
105
|
+
note,
|
|
106
|
+
close: async () => {
|
|
107
|
+
if (closed)
|
|
108
|
+
return;
|
|
109
|
+
closed = true;
|
|
110
|
+
await server.close();
|
|
111
|
+
if (opts.keep)
|
|
112
|
+
log(`run: kept ${dataDir} (the worlds' files; worlds serve --data-dir ${dataDir} reopens them)`);
|
|
113
|
+
else if (opts.dataDir === undefined)
|
|
114
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export async function exportEvidence(s, dir) {
|
|
119
|
+
const written = [];
|
|
120
|
+
mkdirSync(dir, { recursive: true });
|
|
121
|
+
let worlds = [];
|
|
122
|
+
try {
|
|
123
|
+
worlds = (await s.admin("GET", "/admin/worlds")).worlds;
|
|
124
|
+
writeFileSync(join(dir, "worlds.json"), `${JSON.stringify(worlds, null, 2)}\n`);
|
|
125
|
+
written.push("worlds.json");
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
s.note(`run: could not list the worlds for the evidence: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
}
|
|
130
|
+
for (const w of worlds) {
|
|
131
|
+
for (const [file, path] of [
|
|
132
|
+
["dump.json", `/admin/worlds/${w.world_id}/dump`],
|
|
133
|
+
["requests.json", `/admin/worlds/${w.world_id}/requests`],
|
|
134
|
+
]) {
|
|
135
|
+
try {
|
|
136
|
+
const body = await s.admin("GET", path);
|
|
137
|
+
mkdirSync(join(dir, w.world_id), { recursive: true });
|
|
138
|
+
writeFileSync(join(dir, w.world_id, file), `${JSON.stringify(body, null, 2)}\n`);
|
|
139
|
+
written.push(`${w.world_id}/${file}`);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
s.note(`run: could not capture ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(join(dir, "run.log"), `${readLog(s.logFile)}`);
|
|
148
|
+
written.push("run.log");
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
}
|
|
152
|
+
return written;
|
|
153
|
+
}
|
|
154
|
+
const readLog = (file) => {
|
|
155
|
+
try {
|
|
156
|
+
return readFileSync(file, "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
function killTree(child, signal) {
|
|
163
|
+
const pid = child.pid;
|
|
164
|
+
if (pid === undefined)
|
|
165
|
+
return;
|
|
166
|
+
if (process.platform === "win32") {
|
|
167
|
+
child.kill(signal === "SIGKILL" ? "SIGKILL" : "SIGTERM");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
process.kill(-pid, signal);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
child.kill(signal);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export async function runCommand(opts) {
|
|
178
|
+
const s = await startServer({
|
|
179
|
+
port: opts.port,
|
|
180
|
+
seedsDir: opts.seedsDir,
|
|
181
|
+
scenariosDir: opts.scenariosDir,
|
|
182
|
+
keep: opts.keep,
|
|
183
|
+
log: opts.log,
|
|
184
|
+
});
|
|
185
|
+
const env = { WORLDS_URL: s.url, WORLDS_ADMIN_TOKEN: s.token, WORLDS_PUBLIC_URL: s.url };
|
|
186
|
+
let code = 1;
|
|
187
|
+
try {
|
|
188
|
+
if (opts.seed !== undefined) {
|
|
189
|
+
let world;
|
|
190
|
+
try {
|
|
191
|
+
world = await s.admin("POST", "/admin/worlds", { seed: opts.seed });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
throw new RunSetupError(`--seed ${opts.seed}: ${err instanceof Error ? err.message : String(err)}`, 2);
|
|
195
|
+
}
|
|
196
|
+
Object.assign(env, {
|
|
197
|
+
WORLDS_BASE_URL: world.base_url,
|
|
198
|
+
WORLDS_API_KEY: world.api_key,
|
|
199
|
+
STRIPE_SECRET_KEY: world.api_key,
|
|
200
|
+
STRIPE_API_KEY: world.api_key,
|
|
201
|
+
WORLDS_WORLD_ID: world.world_id,
|
|
202
|
+
});
|
|
203
|
+
s.note(`run: world ${world.world_id} from ${opts.seed} — WORLDS_BASE_URL, WORLDS_API_KEY, WORLDS_WORLD_ID exported (the key as STRIPE_SECRET_KEY / STRIPE_API_KEY too)`);
|
|
204
|
+
if (preloadEnabled(opts.preload)) {
|
|
205
|
+
Object.assign(env, preloadEnv(process.env));
|
|
206
|
+
s.note("run: the SDK preload is on — a stock stripe-node or stripe-python in the command reaches the world with no host set (NODE_OPTIONS --import, PYTHONPATH; --no-preload turns it off)");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
s.note(`run: ${opts.command.join(" ")}`);
|
|
210
|
+
const child = spawn(opts.command[0], opts.command.slice(1), {
|
|
211
|
+
stdio: "inherit",
|
|
212
|
+
env: { ...process.env, ...env },
|
|
213
|
+
detached: process.platform !== "win32",
|
|
214
|
+
});
|
|
215
|
+
let timedOut = false;
|
|
216
|
+
let signalled = null;
|
|
217
|
+
const timer = opts.timeoutSec !== undefined
|
|
218
|
+
? setTimeout(() => {
|
|
219
|
+
timedOut = true;
|
|
220
|
+
s.note(`run: the command did not finish within ${opts.timeoutSec}s — killing its tree`);
|
|
221
|
+
killTree(child, "SIGKILL");
|
|
222
|
+
}, opts.timeoutSec * 1000)
|
|
223
|
+
: undefined;
|
|
224
|
+
const onSignal = (signal) => {
|
|
225
|
+
if (signalled)
|
|
226
|
+
return;
|
|
227
|
+
signalled = signal;
|
|
228
|
+
s.note(`run: ${signal} — stopping the command, then the server`);
|
|
229
|
+
killTree(child, signal);
|
|
230
|
+
};
|
|
231
|
+
const handlers = ["SIGINT", "SIGTERM"].map((sig) => [sig, () => onSignal(sig)]);
|
|
232
|
+
for (const [sig, h] of handlers)
|
|
233
|
+
process.on(sig, h);
|
|
234
|
+
try {
|
|
235
|
+
code = await new Promise((resolvePromise, reject) => {
|
|
236
|
+
child.on("error", (err) => reject(new RunSetupError(`could not run '${opts.command[0]}': ${err.message}`, 2)));
|
|
237
|
+
child.on("exit", (exit, signal) => {
|
|
238
|
+
if (timedOut)
|
|
239
|
+
resolvePromise(124);
|
|
240
|
+
else if (exit !== null)
|
|
241
|
+
resolvePromise(exit);
|
|
242
|
+
else
|
|
243
|
+
resolvePromise(128 + (signal ? (constants.signals[signal] ?? 0) : 0));
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
if (timer)
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
for (const [sig, h] of handlers)
|
|
251
|
+
process.removeListener(sig, h);
|
|
252
|
+
}
|
|
253
|
+
s.note(`run: the command exited ${code}${timedOut ? " (timed out)" : signalled ? ` (${signalled})` : ""}`);
|
|
254
|
+
if (code !== 0 && !signalled) {
|
|
255
|
+
const dir = opts.evidence !== undefined ? resolve(opts.evidence) : mkdtempSync(join(tmpdir(), "worlds-run-evidence-"));
|
|
256
|
+
const files = await exportEvidence(s, dir);
|
|
257
|
+
opts.log(`run: evidence in ${dir} (${files.length} file${files.length === 1 ? "" : "s"}: ${files.slice(0, 4).join(", ")}${files.length > 4 ? ", …" : ""})`);
|
|
258
|
+
}
|
|
259
|
+
if (signalled)
|
|
260
|
+
code = 128 + (constants.signals[signalled] ?? 0);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await s.close();
|
|
264
|
+
}
|
|
265
|
+
return code;
|
|
266
|
+
}
|