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
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import { authOptionsFromEnv, buildServer, createLayeredSeedLibrary, describeAuth, parseSeed, resolveLibraryDirs, resolveUserDir, SeedValidationError, seedHash, } from "#server";
|
|
6
|
+
import { globalOptionAfterSubcommand, shellLine } from "./argv.js";
|
|
7
|
+
import { commandAllowlist } from "./commands.js";
|
|
8
|
+
import { readDoc } from "./load.js";
|
|
9
|
+
import { installStdioGuard } from "./stdio.js";
|
|
10
|
+
import { cliVersion } from "./version.js";
|
|
11
|
+
installStdioGuard();
|
|
12
|
+
const program = new Command();
|
|
13
|
+
const DEFAULT_URL = process.env.WORLDS_URL ?? "http://127.0.0.1:4242";
|
|
14
|
+
function adminUrl() {
|
|
15
|
+
return (program.opts().url ?? DEFAULT_URL).replace(/\/$/, "");
|
|
16
|
+
}
|
|
17
|
+
function adminToken() {
|
|
18
|
+
return program.opts().adminToken ?? (process.env.WORLDS_ADMIN_TOKEN || undefined);
|
|
19
|
+
}
|
|
20
|
+
async function admin(method, path, body) {
|
|
21
|
+
const headers = { "content-type": "application/json" };
|
|
22
|
+
const token = adminToken();
|
|
23
|
+
if (token)
|
|
24
|
+
headers.authorization = `Bearer ${token}`;
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(`${adminUrl()}${path}`, {
|
|
28
|
+
method,
|
|
29
|
+
headers,
|
|
30
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
fail(`could not reach a worlds server at ${adminUrl()} — start one with \`worlds serve\` (or pass --url)`);
|
|
35
|
+
}
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
const parsed = text ? JSON.parse(text) : undefined;
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const msg = parsed?.error?.message ?? text;
|
|
40
|
+
const hint = res.status === 401
|
|
41
|
+
? "\n hint: this server requires an admin token — pass --admin-token or set WORLDS_ADMIN_TOKEN (worlds serve prints an auto-provisioned token on the start that mints it, and its file path on every later start)"
|
|
42
|
+
: res.status === 403
|
|
43
|
+
? "\n hint: a world token only drives its own world; server-level commands need the operator token"
|
|
44
|
+
: "";
|
|
45
|
+
fail(`${method} ${path} → ${res.status}: ${msg}${hint}`);
|
|
46
|
+
}
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
function fail(message, code = 1) {
|
|
50
|
+
console.error(`error: ${message}`);
|
|
51
|
+
process.exit(code);
|
|
52
|
+
}
|
|
53
|
+
function out(value) {
|
|
54
|
+
console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
55
|
+
}
|
|
56
|
+
program
|
|
57
|
+
.name("worlds")
|
|
58
|
+
.description("worlds — a flight simulator for AI agents that touch money.\nRun a high-fidelity fake Stripe, point your agent at it, and grade the world afterward.")
|
|
59
|
+
.option("--url <url>", `admin API URL of a running server (default: $WORLDS_URL or ${DEFAULT_URL})`)
|
|
60
|
+
.option("--admin-token <token>", "operator or world token for the admin API (default: $WORLDS_ADMIN_TOKEN; not needed on loopback with auth off)")
|
|
61
|
+
.option("--commands-json", "print the commands the docs and the site may show (the allowlist), as JSON, and exit")
|
|
62
|
+
.enablePositionalOptions()
|
|
63
|
+
.version(cliVersion());
|
|
64
|
+
program
|
|
65
|
+
.command("serve")
|
|
66
|
+
.description("start the worlds server (twin API at /v1/*, admin API at /admin/*)")
|
|
67
|
+
.option("-p, --port <port>", "port to listen on", "4242")
|
|
68
|
+
.option("--host <host>", "host to bind", "127.0.0.1")
|
|
69
|
+
.option("--data-dir <dir>", "directory for world SQLite files (default: $WORLDS_DATA_DIR, else ./.worlds)")
|
|
70
|
+
.option("--seeds-dir <dir>", "your seeds directory, layered over the bundled library (default: $WORLDS_SEEDS_DIR, else ./seeds)")
|
|
71
|
+
.option("--scenarios-dir <dir>", "your scenarios directory, layered over the bundled library (default: $WORLDS_SCENARIOS_DIR, else ./scenarios)")
|
|
72
|
+
.option("--public-url <url>", "origin handed back as base_url (default: $WORLDS_PUBLIC_URL, else derived from each request's Host)")
|
|
73
|
+
.option("--trust-proxy", "honor X-Forwarded-Host/Proto when deriving base_url (default: $WORLDS_TRUST_PROXY=1)")
|
|
74
|
+
.option("--no-trust-proxy", "ignore X-Forwarded-* even when $WORLDS_TRUST_PROXY=1")
|
|
75
|
+
.option("--admin-token <token>", "operator token for /admin/* (16+ chars; default: $WORLDS_ADMIN_TOKEN; auto-provisioned when required and unset)")
|
|
76
|
+
.option("--auth <mode>", "force admin auth: off | required (default: $WORLDS_AUTH, else off on loopback / required otherwise)")
|
|
77
|
+
.action(async (opts) => {
|
|
78
|
+
const envUrl = process.env.WORLDS_PUBLIC_URL || undefined;
|
|
79
|
+
const publicUrl = opts.publicUrl ?? envUrl;
|
|
80
|
+
const pinnedBy = opts.publicUrl ? "--public-url" : envUrl ? "WORLDS_PUBLIC_URL" : undefined;
|
|
81
|
+
const trustProxy = opts.trustProxy ?? process.env.WORLDS_TRUST_PROXY === "1";
|
|
82
|
+
const adminTokenFlag = opts.adminToken ?? program.opts().adminToken;
|
|
83
|
+
let auth = {};
|
|
84
|
+
try {
|
|
85
|
+
auth = authOptionsFromEnv({
|
|
86
|
+
...process.env,
|
|
87
|
+
...(adminTokenFlag !== undefined ? { WORLDS_ADMIN_TOKEN: adminTokenFlag } : {}),
|
|
88
|
+
...(opts.auth !== undefined ? { WORLDS_AUTH: opts.auth } : {}),
|
|
89
|
+
}, {
|
|
90
|
+
token: adminTokenFlag !== undefined ? "--admin-token" : undefined,
|
|
91
|
+
mode: opts.auth !== undefined ? "--auth" : undefined,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
96
|
+
}
|
|
97
|
+
const server = buildServer({
|
|
98
|
+
dataDir: opts.dataDir,
|
|
99
|
+
seedsDir: opts.seedsDir,
|
|
100
|
+
scenariosDir: opts.scenariosDir,
|
|
101
|
+
publicUrl,
|
|
102
|
+
trustProxy,
|
|
103
|
+
auth,
|
|
104
|
+
});
|
|
105
|
+
const address = await server.listen(Number(opts.port), opts.host);
|
|
106
|
+
const wildcard = opts.host === "0.0.0.0" || opts.host === "::";
|
|
107
|
+
const note = pinnedBy
|
|
108
|
+
? ` (pinned by ${pinnedBy})`
|
|
109
|
+
: wildcard
|
|
110
|
+
? " (wildcard bind: each caller gets its own Host back as base_url; set WORLDS_PUBLIC_URL to pin it)"
|
|
111
|
+
: "";
|
|
112
|
+
console.log(`worlds server listening at ${address}`);
|
|
113
|
+
console.log(` twin API ${address}/v1/* (Stripe-compatible — authenticate with a world API key)`);
|
|
114
|
+
console.log(` admin API ${address}/admin/*`);
|
|
115
|
+
console.log(` public URL ${server.publicUrl()}${note}`);
|
|
116
|
+
console.log(` ${describeAuth(server.auth())}`);
|
|
117
|
+
const refused = server.manager.refusedFiles().length;
|
|
118
|
+
if (refused > 0)
|
|
119
|
+
console.log(` ${refused} world file(s) this runtime refuses — run: worlds world ls`);
|
|
120
|
+
console.log(` seeds: ${server.manager.seedLibrary
|
|
121
|
+
.list()
|
|
122
|
+
.map((s) => s.name)
|
|
123
|
+
.join(", ") || "(none found)"}`);
|
|
124
|
+
console.log(` scenarios: ${server.scenarios
|
|
125
|
+
.list()
|
|
126
|
+
.map((s) => s.name)
|
|
127
|
+
.join(", ") || "(none found)"}`);
|
|
128
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
129
|
+
process.on(signal, () => void server.close().then(() => process.exit(0)));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
program
|
|
133
|
+
.command("demo")
|
|
134
|
+
.description("60-second demo, zero config: watch a naive agent double-refund under a rate-limit storm — and the world diff catch it")
|
|
135
|
+
.action(async () => {
|
|
136
|
+
const { runDemo } = await import("./demo.js");
|
|
137
|
+
process.exit(await runDemo());
|
|
138
|
+
});
|
|
139
|
+
program
|
|
140
|
+
.command("run")
|
|
141
|
+
.description("one terminal: a server in this process on a free port, then your command with WORLDS_URL, WORLDS_ADMIN_TOKEN and WORLDS_PUBLIC_URL in its environment (and one world's WORLDS_BASE_URL / WORLDS_API_KEY / WORLDS_WORLD_ID with --seed) — evidence exported when it fails, the server stopped, the command's exit code returned.\n" +
|
|
142
|
+
"Exit: the command's; 124 on --timeout; 2 when the server could not be started as asked or the command could not be spawned.")
|
|
143
|
+
.passThroughOptions()
|
|
144
|
+
.option("--seed <name>", "create one world from this seed first and hand the command its bindings")
|
|
145
|
+
.option("--evidence <dir>", "where a failing command's evidence goes: the world list, every world's dump and request log, the run's log (default: a temp directory, named on stderr)")
|
|
146
|
+
.option("--port <n>", "bind this port instead of a free one (refused when it is taken)")
|
|
147
|
+
.option("--timeout <sec>", "kill the command's tree after this many seconds (exit 124)")
|
|
148
|
+
.option("--keep", "keep the server's data directory after the run", false)
|
|
149
|
+
.option("--no-preload", "leave the SDK preload out of the agent's environment (NODE_OPTIONS --import, PYTHONPATH): the agent points its Stripe SDK at the world itself; WORLDS_PRELOAD=0 does the same")
|
|
150
|
+
.option("--seeds-dir <dir>", "your seeds directory, layered over the bundled library")
|
|
151
|
+
.option("--scenarios-dir <dir>", "your scenarios directory, layered over the bundled library")
|
|
152
|
+
.argument("<command...>", "the command to run (put it after --)")
|
|
153
|
+
.action(async (command, opts) => {
|
|
154
|
+
const port = opts.port === undefined ? undefined : Number(opts.port);
|
|
155
|
+
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))
|
|
156
|
+
fail(`--port must be a port number (got '${opts.port}')`, 2);
|
|
157
|
+
const timeout = opts.timeout === undefined ? undefined : Number(opts.timeout);
|
|
158
|
+
if (timeout !== undefined && (!Number.isInteger(timeout) || timeout < 1))
|
|
159
|
+
fail(`--timeout must be a positive integer of seconds (got '${opts.timeout}')`, 2);
|
|
160
|
+
const { RunSetupError, runCommand } = await import("./run.js");
|
|
161
|
+
try {
|
|
162
|
+
process.exit(await runCommand({
|
|
163
|
+
preload: opts.preload,
|
|
164
|
+
seed: opts.seed,
|
|
165
|
+
evidence: opts.evidence,
|
|
166
|
+
port,
|
|
167
|
+
timeoutSec: timeout,
|
|
168
|
+
keep: opts.keep,
|
|
169
|
+
seedsDir: opts.seedsDir,
|
|
170
|
+
scenariosDir: opts.scenariosDir,
|
|
171
|
+
command,
|
|
172
|
+
log: (line) => console.error(line),
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
fail(err instanceof Error ? err.message : String(err), err instanceof RunSetupError ? err.code : 1);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
async function ownServer(keep) {
|
|
180
|
+
const { RunSetupError, startServer } = await import("./run.js");
|
|
181
|
+
try {
|
|
182
|
+
return await startServer({ keep, log: (line) => console.error(line) });
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
fail(err instanceof Error ? err.message : String(err), err instanceof RunSetupError ? err.code : 1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function tokenSource() {
|
|
189
|
+
if (program.opts().adminToken !== undefined)
|
|
190
|
+
return "--admin-token";
|
|
191
|
+
if (process.env.WORLDS_ADMIN_TOKEN !== undefined)
|
|
192
|
+
return "WORLDS_ADMIN_TOKEN";
|
|
193
|
+
return "none";
|
|
194
|
+
}
|
|
195
|
+
program
|
|
196
|
+
.command("selftest")
|
|
197
|
+
.description("a deterministic check of the evaluator, not of your agent: five known agents on the built-in task (one correct, four wrong in the ways that matter), graded as a customer's agent would be — exit 0 when every verdict is as expected, 1 when one is not")
|
|
198
|
+
.option("--server <mode>", "auto: a server in this process for the run, instead of --url (one terminal)")
|
|
199
|
+
.option("--timeout <sec>", "kill a known agent after this many seconds", "120")
|
|
200
|
+
.option("--json", "print every variant's verdict as JSON on stdout", false)
|
|
201
|
+
.action(async (opts) => {
|
|
202
|
+
if (opts.server !== undefined && opts.server !== "auto")
|
|
203
|
+
fail(`--server must be auto (got '${opts.server}')`, 2);
|
|
204
|
+
const timeout = Number(opts.timeout);
|
|
205
|
+
if (!Number.isInteger(timeout) || timeout < 1)
|
|
206
|
+
fail(`--timeout must be a positive integer (got '${opts.timeout}')`, 2);
|
|
207
|
+
const { PasskConfigError, PasskInconclusiveError, PasskInterrupted, PasskSetupError } = await import("./passk.js");
|
|
208
|
+
const { runSelftest } = await import("./selftest.js");
|
|
209
|
+
const own = opts.server === "auto" ? await ownServer(false) : undefined;
|
|
210
|
+
let code = 1;
|
|
211
|
+
try {
|
|
212
|
+
code = await runSelftest({
|
|
213
|
+
adminUrl: own?.url ?? adminUrl(),
|
|
214
|
+
adminToken: own?.token ?? adminToken(),
|
|
215
|
+
timeoutSec: timeout,
|
|
216
|
+
json: opts.json,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
await own?.close();
|
|
221
|
+
fail(err instanceof Error ? err.message : String(err), err instanceof PasskInterrupted
|
|
222
|
+
? err.exitCode
|
|
223
|
+
: err instanceof PasskConfigError
|
|
224
|
+
? 2
|
|
225
|
+
: err instanceof PasskSetupError
|
|
226
|
+
? 3
|
|
227
|
+
: err instanceof PasskInconclusiveError
|
|
228
|
+
? 4
|
|
229
|
+
: 1);
|
|
230
|
+
}
|
|
231
|
+
await own?.close();
|
|
232
|
+
process.exit(code);
|
|
233
|
+
});
|
|
234
|
+
program.command("selftest-agent <variant>", { hidden: true }).action(async (variant) => {
|
|
235
|
+
const { runSelftestAgent } = await import("./selftest.js");
|
|
236
|
+
try {
|
|
237
|
+
process.exit(await runSelftestAgent(variant));
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
fail(err instanceof Error ? err.message : String(err), 1);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
program
|
|
244
|
+
.command("doctor")
|
|
245
|
+
.description("the dead ends explained: what the server looks like from here, one world and the topology its base URL implies, then a probe the way your agent's SDK would make it (the agent command after --, or the env file's base URL and key, or doctor's own) and whether the world's request log recorded it — a wrong host, a wrong scheme, a missing base URL, a key that is not the world's and plain silence each get their own line.\n" +
|
|
246
|
+
"Exit 0 the probe was recorded, 2 the environment or the command is wrong in a way doctor can name, 3 no server, 4 no traffic with no named cause.")
|
|
247
|
+
.passThroughOptions()
|
|
248
|
+
.option("--env-file <file>", "the file your agent's environment is loaded from (KEY=VALUE lines): its WORLDS_BASE_URL and WORLDS_API_KEY are checked")
|
|
249
|
+
.option("--server <mode>", "auto: a server in this process for the check, instead of --url (one terminal)")
|
|
250
|
+
.option("--no-preload", "leave the SDK preload out of the agent's environment (NODE_OPTIONS --import, PYTHONPATH): the agent points its Stripe SDK at the world itself; WORLDS_PRELOAD=0 does the same")
|
|
251
|
+
.option("--timeout <sec>", "kill the agent after this many seconds", "120")
|
|
252
|
+
.option("--json", "print the report as JSON on stdout", false)
|
|
253
|
+
.argument("[agent-command...]", "the agent command to run once against the world (put it after --)")
|
|
254
|
+
.action(async (agentCommand, opts) => {
|
|
255
|
+
if (opts.server !== undefined && opts.server !== "auto")
|
|
256
|
+
fail(`--server must be auto (got '${opts.server}')`, 2);
|
|
257
|
+
const timeout = Number(opts.timeout);
|
|
258
|
+
if (!Number.isInteger(timeout) || timeout < 1)
|
|
259
|
+
fail(`--timeout must be a positive integer (got '${opts.timeout}')`, 2);
|
|
260
|
+
if (opts.envFile !== undefined && agentCommand.length > 0)
|
|
261
|
+
fail("--env-file or an agent command after --, not both: doctor probes one way at a time", 2);
|
|
262
|
+
const { runDoctor } = await import("./doctor.js");
|
|
263
|
+
const own = opts.server === "auto" ? await ownServer(false) : undefined;
|
|
264
|
+
let code = 1;
|
|
265
|
+
try {
|
|
266
|
+
code = await runDoctor({
|
|
267
|
+
adminUrl: own?.url ?? adminUrl(),
|
|
268
|
+
adminToken: own?.token ?? adminToken(),
|
|
269
|
+
tokenSource: own ? "minted for this run" : tokenSource(),
|
|
270
|
+
...(own
|
|
271
|
+
? { own: { host: new URL(own.url).hostname, port: Number(new URL(own.url).port), dataDir: own.dataDir } }
|
|
272
|
+
: {}),
|
|
273
|
+
envFile: opts.envFile,
|
|
274
|
+
agentCommand,
|
|
275
|
+
timeoutSec: timeout,
|
|
276
|
+
preload: opts.preload,
|
|
277
|
+
json: opts.json,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
await own?.close();
|
|
282
|
+
fail(err instanceof Error ? err.message : String(err), 1);
|
|
283
|
+
}
|
|
284
|
+
await own?.close();
|
|
285
|
+
process.exit(code);
|
|
286
|
+
});
|
|
287
|
+
program
|
|
288
|
+
.command("try")
|
|
289
|
+
.description("the hello world: one world from the bundled account, one built-in task (a customer charged twice, asking for one refund), your agent — then what it did to the money, its reply, and the verdict.\n" +
|
|
290
|
+
"Exit 0 when the world ended right, 1 when it did not (or no Stripe traffic was recorded), 2 for configuration.")
|
|
291
|
+
.passThroughOptions()
|
|
292
|
+
.option("--mode <mode>", "isolated (the task on the agent's stdin and in WORLDS_TASK_JSON, stdout as the reply) or shift (the task on the world's help desk)", "isolated")
|
|
293
|
+
.option("--timeout <sec>", "kill the agent after this many seconds", "300")
|
|
294
|
+
.option("--keep-worlds", "keep the world for post-mortem instead of destroying it", false)
|
|
295
|
+
.option("--server <mode>", "auto: a server in this process for the run, instead of --url (one terminal)")
|
|
296
|
+
.option("--no-preload", "leave the SDK preload out of the agent's environment (NODE_OPTIONS --import, PYTHONPATH): the agent points its Stripe SDK at the world itself; WORLDS_PRELOAD=0 does the same")
|
|
297
|
+
.argument("<agent-command...>", "the agent command to run (put it after --)")
|
|
298
|
+
.action(async (agentCommand, opts) => {
|
|
299
|
+
if (opts.mode !== "isolated" && opts.mode !== "shift")
|
|
300
|
+
fail(`--mode must be isolated or shift (got '${opts.mode}')`, 2);
|
|
301
|
+
const timeout = Number(opts.timeout);
|
|
302
|
+
if (!Number.isInteger(timeout) || timeout < 1)
|
|
303
|
+
fail(`--timeout must be a positive integer (got '${opts.timeout}')`, 2);
|
|
304
|
+
if (opts.server !== undefined && opts.server !== "auto")
|
|
305
|
+
fail(`--server must be auto (got '${opts.server}')`, 2);
|
|
306
|
+
const { PasskConfigError, PasskInconclusiveError, PasskInterrupted, PasskSetupError } = await import("./passk.js");
|
|
307
|
+
const { runTry } = await import("./try.js");
|
|
308
|
+
const own = opts.server === "auto" ? await ownServer(opts.keepWorlds) : undefined;
|
|
309
|
+
let code = 1;
|
|
310
|
+
try {
|
|
311
|
+
code = await runTry({
|
|
312
|
+
adminUrl: own?.url ?? adminUrl(),
|
|
313
|
+
adminToken: own?.token ?? adminToken(),
|
|
314
|
+
mode: opts.mode,
|
|
315
|
+
timeoutSec: timeout,
|
|
316
|
+
keepWorlds: opts.keepWorlds,
|
|
317
|
+
preload: opts.preload,
|
|
318
|
+
serverAuto: opts.server === "auto",
|
|
319
|
+
agentCommand,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
await own?.close();
|
|
324
|
+
fail(err instanceof Error ? err.message : String(err), err instanceof PasskInterrupted
|
|
325
|
+
? err.exitCode
|
|
326
|
+
: err instanceof PasskConfigError
|
|
327
|
+
? 2
|
|
328
|
+
: err instanceof PasskSetupError
|
|
329
|
+
? 3
|
|
330
|
+
: err instanceof PasskInconclusiveError
|
|
331
|
+
? 4
|
|
332
|
+
: 1);
|
|
333
|
+
}
|
|
334
|
+
await own?.close();
|
|
335
|
+
process.exit(code);
|
|
336
|
+
});
|
|
337
|
+
program
|
|
338
|
+
.command("init")
|
|
339
|
+
.description("your account and your agent first — the Stripe key of the account to copy in (used once, written nowhere) and the command that runs your agent — then the policy (the defaults in one question, or five), then the crash test scaffolded into this repo: worlds/seeds/my-account.json and worlds/tasks.yaml on it (two tasks on your own customers; without a key, the bundled account and a starter pack), worlds/policy.md, the harness test, the CI workflow, the .gitignore line. Never overwrites.")
|
|
340
|
+
.option("--dir <dir>", "target directory", ".")
|
|
341
|
+
.option("--python", "generate a pytest harness instead of vitest", false)
|
|
342
|
+
.option("--stripe-key <key>", "the Stripe key of the account to copy in: a restricted read-only key (rk_…) or a test-mode key (default: $STRIPE_API_KEY, else asked); used once, in process, written nowhere")
|
|
343
|
+
.option("--no-import", "skip the key and the import: start on the bundled account")
|
|
344
|
+
.option("--agent <command>", "the command that runs your agent once on a ticket, into the harness and the workflow (default: asked, else a TODO)")
|
|
345
|
+
.option("--tickets <file>", "a help desk export to import now: its requesters are copied in from the account by email, each with its invoices, charges and subscriptions, and its rows become your first tasks (default: asked beside a key, else none)")
|
|
346
|
+
.option("--sample <n>", "objects per resource copied from the account beyond the customers the export names", "100")
|
|
347
|
+
.addOption(new Option("--stripe-base-url <url>", "the Stripe API origin to import from (tests)").hideHelp())
|
|
348
|
+
.option("--runtime <spec>", "what the generated harness and workflow run the runtime from: sparta_worlds@<version> on the registry (default: this CLI's own package and version), or a versioned tarball URL for a pinned copy")
|
|
349
|
+
.option("--tarball <url>", "a versioned runtime tarball URL, for a pinned copy instead of the registry package (the same as --runtime <url>)")
|
|
350
|
+
.option("--action <ref>", "write the two-step workflow on the setup action (e.g. Sparta-AI/twinlab/action@v0): the server for the job, the crash test against it, the evidence in the artifact — instead of the one-step passk --server auto")
|
|
351
|
+
.option("--refunds <days|none>", "may the agent refund, and within how many days (default: asked, else 60)")
|
|
352
|
+
.option("--credits <cents>x<count>|none", "goodwill credits: the amount in cents and how many a day, e.g. 5000x3 (default: asked, else 5000x3)")
|
|
353
|
+
.option("--cancel <mode>", "period-end | immediate | none (default: asked, else period-end)")
|
|
354
|
+
.option("--plan-changes <yes|no>", "may it change plans (default: asked, else yes)")
|
|
355
|
+
.option("--escalate <who>", "who takes what it may not do (default: asked, else 'the billing lead')")
|
|
356
|
+
.option("--pack <authorities>", "the starter packs to merge, overriding the answers' choice: refunds,credits,cancellations,plan-changes,invoices (the first task of each kind of failure, fifteen at most) — or all (every pack, uncapped: the whole starter library)")
|
|
357
|
+
.option("--no-prompt", "ask nothing: the defaults stand for every answer not given as a flag")
|
|
358
|
+
.action(async (opts) => {
|
|
359
|
+
const sample = Number(opts.sample);
|
|
360
|
+
if (!Number.isInteger(sample) || sample < 1)
|
|
361
|
+
fail(`--sample must be a positive integer (got '${opts.sample}')`, 2);
|
|
362
|
+
const { runInit } = await import("./init.js");
|
|
363
|
+
let result;
|
|
364
|
+
try {
|
|
365
|
+
result = await runInit({
|
|
366
|
+
dir: resolve(opts.dir),
|
|
367
|
+
python: opts.python,
|
|
368
|
+
stripeKey: opts.stripeKey,
|
|
369
|
+
noImport: !opts.import,
|
|
370
|
+
agent: opts.agent,
|
|
371
|
+
tickets: opts.tickets,
|
|
372
|
+
sample,
|
|
373
|
+
stripeBaseUrl: opts.stripeBaseUrl,
|
|
374
|
+
runtime: opts.runtime ?? opts.tarball,
|
|
375
|
+
action: opts.action,
|
|
376
|
+
refunds: opts.refunds,
|
|
377
|
+
credits: opts.credits,
|
|
378
|
+
cancel: opts.cancel,
|
|
379
|
+
planChanges: opts.planChanges,
|
|
380
|
+
escalate: opts.escalate,
|
|
381
|
+
pack: opts.pack,
|
|
382
|
+
noPrompt: !opts.prompt,
|
|
383
|
+
}, (line) => console.error(line));
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
fail(err instanceof Error ? err.message : String(err), 2);
|
|
387
|
+
}
|
|
388
|
+
console.log(`policy: refunds ${result.answers.refundDays === null ? "none" : `within ${result.answers.refundDays} days`} · credits ${result.answers.credits === null ? "none" : `${result.answers.credits.amountCents}x${result.answers.credits.perDay}`} · cancel ${result.answers.cancel} · plan changes ${result.answers.planChanges ? "yes" : "no"} · escalate to ${result.answers.escalateTo}`);
|
|
389
|
+
if (result.account) {
|
|
390
|
+
const a = result.account;
|
|
391
|
+
console.log(`account: ${a.seed} (${a.customers} customers, ${a.invoices} invoices, ${a.charges} charges, ${a.subscriptions} subscriptions; names, emails and free text pseudonymized — worlds/my-account.manifest.json)`);
|
|
392
|
+
}
|
|
393
|
+
if (result.account?.named && result.account.named.emails > 0) {
|
|
394
|
+
const n = result.account.named;
|
|
395
|
+
const missing = n.missing.length
|
|
396
|
+
? `; not in Stripe: ${n.missing.slice(0, 5).join(", ")}${n.missing.length > 5 ? ` and ${n.missing.length - 5} more` : ""}`
|
|
397
|
+
: "";
|
|
398
|
+
console.log(` named by the export: ${n.found} of ${n.emails} email(s) found in the account and copied in${missing}`);
|
|
399
|
+
}
|
|
400
|
+
if (result.imported)
|
|
401
|
+
console.log(`tickets: ${result.imported.tasks} task(s) from ${result.imported.file}${result.imported.unknown.length ? ` (${result.imported.unknown.length} requester(s) the account does not know were skipped)` : ""} → worlds/tasks.yaml`);
|
|
402
|
+
const packsLabel = result.packsAll
|
|
403
|
+
? "all"
|
|
404
|
+
: result.packs.length
|
|
405
|
+
? result.packs.join(", ")
|
|
406
|
+
: "(none: the tasks every pack carries)";
|
|
407
|
+
const library = result.packsAll
|
|
408
|
+
? ", the whole starter library"
|
|
409
|
+
: result.library !== undefined && result.packs.length > 0
|
|
410
|
+
? ` of ${result.library.tasks} in the starter library (--pack all takes them all)`
|
|
411
|
+
: "";
|
|
412
|
+
if (result.starter)
|
|
413
|
+
console.log(`packs: ${packsLabel} → ${result.starter.tasks} task(s)${library} in ${result.starter.file} (the starter library's account)`);
|
|
414
|
+
else if (result.account && result.packs.length === 0)
|
|
415
|
+
console.log(`packs: (none: two tasks on your own customers) → ${result.tasks} task(s)`);
|
|
416
|
+
else
|
|
417
|
+
console.log(`packs: ${packsLabel} → ${result.tasks} task(s)${library}`);
|
|
418
|
+
for (const f of result.created)
|
|
419
|
+
console.log(`created ${f}`);
|
|
420
|
+
for (const f of result.updated)
|
|
421
|
+
console.log(`updated ${f} (+ worlds/*.identities.json)`);
|
|
422
|
+
for (const f of result.skipped)
|
|
423
|
+
console.log(`skipped ${f} (already exists — not touching it)`);
|
|
424
|
+
console.log("\nWhat's left (the parts only you can do):");
|
|
425
|
+
result.nextSteps.forEach((s, i) => {
|
|
426
|
+
console.log(` ${i + 1}. ${s}`);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
program
|
|
430
|
+
.command("passk")
|
|
431
|
+
.description("run an agent command k times, each against a fresh world, and grade every run by the world diff.\n" +
|
|
432
|
+
"The agent command receives WORLDS_BASE_URL, WORLDS_API_KEY, WORLDS_WORLD_ID, and WORLDS_URL.\n" +
|
|
433
|
+
"Example:\n" +
|
|
434
|
+
" worlds passk --seed saas-billing-small --scenario rate-limit-storm --runs 8 \\\n" +
|
|
435
|
+
" --expect 'created.refunds==1' --expect 'created.refunds[0].amount==4000' \\\n" +
|
|
436
|
+
" -- pnpm --filter @worlds/demo-agent agent")
|
|
437
|
+
.passThroughOptions()
|
|
438
|
+
.option("--seed <name>", "library seed each run's world is created from (or --tasks, whose file names it)")
|
|
439
|
+
.option("--tasks <file>", "the tasks file (worlds/tasks.yaml, docs/tasks.md): the seed, your tickets and their keys in one document; exclusive with --seed and --rubric; its runs, scenario, mode, budget and policy are defaults the flags override")
|
|
440
|
+
.option("--mode <mode>", "with --tasks: isolated (one world per task, the task handed to the agent on stdin and in WORLDS_TASK_JSON, stdout as the reply) or shift (every task in one world, read from the desk); default: the file's")
|
|
441
|
+
.option("--task <id>", "with --tasks: only this task (its id, or its position), repeatable — the replay of one task", (v, prev) => [...prev, v], [])
|
|
442
|
+
.option("--scenario <name-or-file>", "failure scenario to apply to every world (library name or YAML/JSON file)")
|
|
443
|
+
.option("--runs <k>", "how many times to run the agent (default: the tasks file's runs, else 5)")
|
|
444
|
+
.option("--expect <expr>", "world-diff assertion, repeatable — e.g. 'created.refunds==1', 'money.usd.net_movement==-4000'", (v, prev) => [...prev, v], [])
|
|
445
|
+
.option("--rubric <file>", "per-ticket answer key (YAML/JSON) for a shift: grades every ticket and the whole shift, reports per-ticket accuracy")
|
|
446
|
+
.option("--mark <name>", "mark name set before each run", "passk-start")
|
|
447
|
+
.option("--timeout <sec>", "kill an agent run after this many seconds", "300")
|
|
448
|
+
.option("--json", "print a machine-readable report on stdout", false)
|
|
449
|
+
.option("--server <mode>", "auto: a server in this process for the run, instead of --url (one terminal)")
|
|
450
|
+
.option("--no-preload", "leave the SDK preload out of the agent's environment (NODE_OPTIONS --import, PYTHONPATH): the agent points its Stripe SDK at the world itself; WORLDS_PRELOAD=0 does the same")
|
|
451
|
+
.option("--json-file <file>", "also write the machine-readable results to this file (what CI uploads beside the report)")
|
|
452
|
+
.option("--report <file>", "write a self-contained HTML report (shareable, projector-ready)")
|
|
453
|
+
.option("--keep-worlds", "keep each run's world for post-mortem instead of destroying it", false)
|
|
454
|
+
.option("--evidence <dir>", "where a run that could not be graded leaves its world's dump and request log, and where --judge writes every ticket's judge/<ticket>.json (default: a temp directory, named on stderr)")
|
|
455
|
+
.option("--judge", "grade every ticket against a policy text with a model — advisory: verdicts land beside the run's own and never change pass or the exit code", false)
|
|
456
|
+
.option("--policy <file>", "the policy text --judge grades against (plain text or markdown; written once, not per ticket)")
|
|
457
|
+
.option("--judge-model <id>", "the judge's model", process.env.WORLDS_JUDGE_MODEL ?? "claude-sonnet-5")
|
|
458
|
+
.option("--judge-runs <n>", "samples per ticket; the majority is the verdict and every sample is kept", "1")
|
|
459
|
+
.option("--judge-budget <cents>", "the shift-wide credit cap the judge tracks when no rubric supplies one")
|
|
460
|
+
.argument("<agent-command...>", "the agent command to run (put it after --)")
|
|
461
|
+
.action(async (agentCommand, opts) => {
|
|
462
|
+
const runs = opts.runs === undefined ? undefined : Number(opts.runs);
|
|
463
|
+
if (runs !== undefined && (!Number.isInteger(runs) || runs < 1))
|
|
464
|
+
fail(`--runs must be a positive integer (got '${opts.runs}')`, 2);
|
|
465
|
+
if (opts.tasks !== undefined && opts.seed !== undefined)
|
|
466
|
+
fail("--tasks names its seed: drop --seed", 2);
|
|
467
|
+
if (opts.tasks !== undefined && opts.rubric !== undefined)
|
|
468
|
+
fail("--tasks carries its own key: drop --rubric", 2);
|
|
469
|
+
if (opts.tasks === undefined && opts.seed === undefined)
|
|
470
|
+
fail("--seed <name> or --tasks <file> is required", 2);
|
|
471
|
+
if (opts.mode !== undefined && opts.tasks === undefined)
|
|
472
|
+
fail("--mode belongs to --tasks", 2);
|
|
473
|
+
if (opts.mode !== undefined && opts.mode !== "isolated" && opts.mode !== "shift")
|
|
474
|
+
fail(`--mode must be isolated or shift (got '${opts.mode}')`, 2);
|
|
475
|
+
if (opts.task.length > 0 && opts.tasks === undefined)
|
|
476
|
+
fail("--task names a task of a tasks file: add --tasks <file>", 2);
|
|
477
|
+
if (opts.judge && !opts.policy && !opts.tasks)
|
|
478
|
+
fail("--judge needs --policy <file>: the policy text the judge grades against", 2);
|
|
479
|
+
if (opts.policy && !opts.judge)
|
|
480
|
+
fail("--policy is the judge's policy: add --judge, or drop --policy", 2);
|
|
481
|
+
const judgeRuns = Number(opts.judgeRuns);
|
|
482
|
+
if (!Number.isInteger(judgeRuns) || judgeRuns < 1) {
|
|
483
|
+
fail(`--judge-runs must be a positive integer (got '${opts.judgeRuns}')`, 2);
|
|
484
|
+
}
|
|
485
|
+
const judgeBudget = opts.judgeBudget === undefined ? undefined : Number(opts.judgeBudget);
|
|
486
|
+
if (judgeBudget !== undefined && (!Number.isInteger(judgeBudget) || judgeBudget < 0)) {
|
|
487
|
+
fail(`--judge-budget must be a non-negative integer of cents (got '${opts.judgeBudget}')`, 2);
|
|
488
|
+
}
|
|
489
|
+
if (opts.expect.length === 0 && !opts.rubric && !opts.tasks) {
|
|
490
|
+
fail("at least one --expect (or a --rubric, or --tasks) is required — passk grades the world, so say what the world should look like (e.g. --expect 'created.refunds==1')", 2);
|
|
491
|
+
}
|
|
492
|
+
if (opts.server !== undefined && opts.server !== "auto")
|
|
493
|
+
fail(`--server must be auto (got '${opts.server}')`, 2);
|
|
494
|
+
const { PasskConfigError, PasskInconclusiveError, PasskInterrupted, PasskSetupError, runPassk } = await import("./passk.js");
|
|
495
|
+
const own = opts.server === "auto" ? await ownServer(opts.keepWorlds) : undefined;
|
|
496
|
+
let code = 1;
|
|
497
|
+
try {
|
|
498
|
+
code = await runPassk({
|
|
499
|
+
adminUrl: own?.url ?? adminUrl(),
|
|
500
|
+
adminToken: own?.token ?? adminToken(),
|
|
501
|
+
...(opts.seed !== undefined ? { seed: opts.seed } : {}),
|
|
502
|
+
...(opts.tasks !== undefined
|
|
503
|
+
? {
|
|
504
|
+
tasks: {
|
|
505
|
+
file: opts.tasks,
|
|
506
|
+
...(opts.mode !== undefined ? { mode: opts.mode } : {}),
|
|
507
|
+
},
|
|
508
|
+
}
|
|
509
|
+
: {}),
|
|
510
|
+
...(opts.task.length > 0 ? { taskFilter: opts.task } : {}),
|
|
511
|
+
scenario: opts.scenario,
|
|
512
|
+
...(runs !== undefined ? { runs } : {}),
|
|
513
|
+
expects: opts.expect,
|
|
514
|
+
rubric: opts.rubric,
|
|
515
|
+
mark: opts.mark,
|
|
516
|
+
timeoutSec: Number(opts.timeout),
|
|
517
|
+
json: opts.json,
|
|
518
|
+
...(opts.jsonFile !== undefined ? { jsonFile: opts.jsonFile } : {}),
|
|
519
|
+
keepWorlds: opts.keepWorlds,
|
|
520
|
+
preload: opts.preload,
|
|
521
|
+
report: opts.report,
|
|
522
|
+
evidence: opts.evidence,
|
|
523
|
+
agentCommand,
|
|
524
|
+
invocation: shellLine(["worlds", ...process.argv.slice(2)]),
|
|
525
|
+
...(opts.judge
|
|
526
|
+
? { judge: { policyFile: opts.policy, model: opts.judgeModel, runs: judgeRuns, budget: judgeBudget } }
|
|
527
|
+
: {}),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
await own?.close();
|
|
532
|
+
fail(err instanceof Error ? err.message : String(err), err instanceof PasskInterrupted
|
|
533
|
+
? err.exitCode
|
|
534
|
+
: err instanceof PasskConfigError
|
|
535
|
+
? 2
|
|
536
|
+
: err instanceof PasskSetupError
|
|
537
|
+
? 3
|
|
538
|
+
: err instanceof PasskInconclusiveError
|
|
539
|
+
? 4
|
|
540
|
+
: 1);
|
|
541
|
+
}
|
|
542
|
+
await own?.close();
|
|
543
|
+
process.exit(code);
|
|
544
|
+
});
|
|
545
|
+
program
|
|
546
|
+
.command("judge [world_id]")
|
|
547
|
+
.description("judge a kept world against a policy text, post hoc — the diff since a mark, every ticket's verdict from the world's records, advisory.\n" +
|
|
548
|
+
"Iterate on the policy without re-running the agent: worlds passk --keep-worlds, then worlds judge <world> --policy p.md.\n" +
|
|
549
|
+
"With --transcript <jsonl> instead of a world: the same judge over the agent's own transcript (a WORLDS_TRAJECTORY_FILE), no world needed")
|
|
550
|
+
.option("--from <mark>", "the mark the shift started at", "passk-start")
|
|
551
|
+
.option("--transcript <jsonl>", "judge from the agent's transcript (one JSON line per ticket) instead of a world's records")
|
|
552
|
+
.option("--clock <iso>", "with --transcript: the world's clock at the shift's start, so ages are precomputed as for a world")
|
|
553
|
+
.requiredOption("--policy <file>", "the policy text (plain text or markdown; written once, not per ticket)")
|
|
554
|
+
.option("--judge-model <id>", "the judge's model", process.env.WORLDS_JUDGE_MODEL ?? "claude-sonnet-5")
|
|
555
|
+
.option("--judge-runs <n>", "samples per ticket; the majority is the verdict", "1")
|
|
556
|
+
.option("--judge-budget <cents>", "the shift-wide credit cap when no rubric supplies one")
|
|
557
|
+
.option("--rubric <file>", "the answer key, to report the judge's agreement with it")
|
|
558
|
+
.option("--json", "print the verdicts as JSON on stdout", false)
|
|
559
|
+
.option("--json-file <file>", "also write the JSON to this file")
|
|
560
|
+
.option("--evidence <dir>", "write every ticket's input, raw model output and verdict under <dir>/<world>/judge/")
|
|
561
|
+
.action(async (worldId, opts) => {
|
|
562
|
+
const runs = Number(opts.judgeRuns);
|
|
563
|
+
if (!Number.isInteger(runs) || runs < 1)
|
|
564
|
+
fail(`--judge-runs must be a positive integer (got '${opts.judgeRuns}')`, 2);
|
|
565
|
+
const budget = opts.judgeBudget === undefined ? undefined : Number(opts.judgeBudget);
|
|
566
|
+
if (budget !== undefined && (!Number.isInteger(budget) || budget < 0)) {
|
|
567
|
+
fail(`--judge-budget must be a non-negative integer of cents (got '${opts.judgeBudget}')`, 2);
|
|
568
|
+
}
|
|
569
|
+
if (!opts.transcript && !worldId)
|
|
570
|
+
fail("judge needs a world id, or --transcript <jsonl>", 2);
|
|
571
|
+
if (opts.transcript && worldId)
|
|
572
|
+
fail("judge takes a world id or --transcript <jsonl>, not both", 2);
|
|
573
|
+
if (opts.transcript && opts.rubric)
|
|
574
|
+
fail("--rubric grades a world's diff; pair transcript verdicts with a passk --json run instead", 2);
|
|
575
|
+
if (opts.clock && !opts.transcript)
|
|
576
|
+
fail("--clock goes with --transcript; a world carries its own clock", 2);
|
|
577
|
+
const { runJudgeCommand, runTranscriptJudgeCommand } = await import("./judge-command.js");
|
|
578
|
+
const { PasskConfigError } = await import("./passk.js");
|
|
579
|
+
try {
|
|
580
|
+
if (opts.transcript) {
|
|
581
|
+
process.exit(await runTranscriptJudgeCommand({
|
|
582
|
+
transcript: opts.transcript,
|
|
583
|
+
clock: opts.clock,
|
|
584
|
+
policyFile: opts.policy,
|
|
585
|
+
model: opts.judgeModel,
|
|
586
|
+
runs,
|
|
587
|
+
budget,
|
|
588
|
+
json: opts.json,
|
|
589
|
+
jsonFile: opts.jsonFile,
|
|
590
|
+
evidence: opts.evidence,
|
|
591
|
+
}));
|
|
592
|
+
}
|
|
593
|
+
process.exit(await runJudgeCommand({
|
|
594
|
+
adminUrl: adminUrl(),
|
|
595
|
+
adminToken: adminToken(),
|
|
596
|
+
worldId: worldId,
|
|
597
|
+
mark: opts.from,
|
|
598
|
+
policyFile: opts.policy,
|
|
599
|
+
model: opts.judgeModel,
|
|
600
|
+
runs,
|
|
601
|
+
budget,
|
|
602
|
+
rubric: opts.rubric,
|
|
603
|
+
json: opts.json,
|
|
604
|
+
jsonFile: opts.jsonFile,
|
|
605
|
+
evidence: opts.evidence,
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
fail(err instanceof Error ? err.message : String(err), err instanceof PasskConfigError ? 2 : 1);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
const seed = program.command("seed").description("inspect and validate seeds (local, no server needed)");
|
|
613
|
+
seed
|
|
614
|
+
.command("ls")
|
|
615
|
+
.description("list library seeds: the bundled library plus your own seeds directory ($WORLDS_SEEDS_DIR or ./seeds), which wins on name collisions")
|
|
616
|
+
.option("--seeds-dir <dir>", "your seeds directory (default: $WORLDS_SEEDS_DIR, else ./seeds)")
|
|
617
|
+
.action((opts) => {
|
|
618
|
+
const lib = createLayeredSeedLibrary(resolveLibraryDirs("seeds", opts.seedsDir));
|
|
619
|
+
const seeds = lib.list();
|
|
620
|
+
if (seeds.length === 0)
|
|
621
|
+
fail(`no valid seeds found (looked in: ${lib.dirs.join(", ")})`);
|
|
622
|
+
for (const s of seeds) {
|
|
623
|
+
const d = s.seed.data;
|
|
624
|
+
console.log(`${s.name} (v${s.seed.version}, ${s.seed.clock_mode} clock)`);
|
|
625
|
+
console.log(` hash ${s.hash}`);
|
|
626
|
+
console.log(` customers=${d.customers.length} products=${d.products.length} prices=${d.prices.length} invoices=${d.invoices.length} subscriptions=${d.subscriptions.length}`);
|
|
627
|
+
console.log(` file ${s.file}`);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
seed
|
|
631
|
+
.command("validate <file>")
|
|
632
|
+
.description("validate a seed file: structure, referential integrity, and its canonical hash")
|
|
633
|
+
.action((file) => {
|
|
634
|
+
const path = resolve(file);
|
|
635
|
+
if (!existsSync(path))
|
|
636
|
+
fail(`no such file: ${path}`);
|
|
637
|
+
let doc;
|
|
638
|
+
try {
|
|
639
|
+
doc = JSON.parse(readFileSync(path, "utf8"));
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
fail(`not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
const parsed = parseSeed(doc);
|
|
646
|
+
console.log(`OK: ${parsed.name} is a valid seed`);
|
|
647
|
+
console.log(` seed_hash ${seedHash(parsed)}`);
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
if (err instanceof SeedValidationError) {
|
|
651
|
+
console.error("INVALID seed:");
|
|
652
|
+
for (const issue of err.issues)
|
|
653
|
+
console.error(` - ${issue.path}: ${issue.message}`);
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
seed
|
|
660
|
+
.command("import")
|
|
661
|
+
.description("import a Stripe account's shape (products, prices, invoice states, subscriptions — identities and free text pseudonymized) into a seed file")
|
|
662
|
+
.requiredOption("--from-stripe", "import from a Stripe-compatible API (the only source in the MVP)")
|
|
663
|
+
.option("--key <key>", "read-only API key (default: $STRIPE_API_KEY)")
|
|
664
|
+
.option("--base-url <url>", "API origin — api.stripe.com, or a world's base_url", "https://api.stripe.com")
|
|
665
|
+
.option("--name <name>", "seed name (also the default output filename)", "imported-stripe")
|
|
666
|
+
.option("--out <file>", "output path (default: $WORLDS_SEEDS_DIR or ./seeds, <name>.json)")
|
|
667
|
+
.option("--limit <n>", "max objects fetched per resource", "100")
|
|
668
|
+
.option("--tickets <file>", "copy in the customers this help desk export names (by email, each with its invoices, charges and subscriptions) beyond the --limit sample")
|
|
669
|
+
.option("--keep-metadata", "keep customer metadata verbatim (only for test-mode accounts — metadata may name a person)", false)
|
|
670
|
+
.option("--keep-names", "keep product names and price nicknames verbatim (default: hash-derived placeholders — free text can name a client)", false)
|
|
671
|
+
.option("--keep-descriptions", "keep invoice line descriptions verbatim (default: the line's price nickname (kept or placeholder), else 'Line item N')", false)
|
|
672
|
+
.option("--manifest <file>", "also write the import manifest (what was replaced, kept, folded, unsupported) as JSON")
|
|
673
|
+
.option("--identity-map <file>", "also write the identity map — each customer's real id, email and name beside the seed's pseudonym — mode 0600, for `tasks build` on this machine only; never commit it (`worlds init` adds worlds/*.identities.json to .gitignore)")
|
|
674
|
+
.option("--epoch <unix>", "override the epoch (default: newest object's `created`, rounded up to UTC midnight)")
|
|
675
|
+
.action(async (opts) => {
|
|
676
|
+
const { identityMapDoc, importStripeSeed } = await import("./import.js");
|
|
677
|
+
const { writeFileSync, mkdirSync, chmodSync } = await import("node:fs");
|
|
678
|
+
const { dirname } = await import("node:path");
|
|
679
|
+
const apiKey = opts.key ?? process.env.STRIPE_API_KEY;
|
|
680
|
+
if (!apiKey)
|
|
681
|
+
fail("no API key: pass --key or set $STRIPE_API_KEY (a read-only restricted key is enough)");
|
|
682
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(opts.name))
|
|
683
|
+
fail(`invalid --name '${opts.name}' (kebab-case required)`);
|
|
684
|
+
let customerEmails;
|
|
685
|
+
if (opts.tickets !== undefined) {
|
|
686
|
+
const { exportRequesterEmails } = await import("./tasks.js");
|
|
687
|
+
try {
|
|
688
|
+
customerEmails = exportRequesterEmails(resolve(opts.tickets));
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
fail(`--tickets ${opts.tickets}: ${err instanceof Error ? err.message : String(err)}`, 2);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
let result;
|
|
695
|
+
try {
|
|
696
|
+
result = await importStripeSeed({
|
|
697
|
+
baseUrl: opts.baseUrl.replace(/\/$/, ""),
|
|
698
|
+
apiKey,
|
|
699
|
+
name: opts.name,
|
|
700
|
+
limit: Number(opts.limit),
|
|
701
|
+
customerEmails,
|
|
702
|
+
keepMetadata: opts.keepMetadata,
|
|
703
|
+
keepNames: opts.keepNames,
|
|
704
|
+
keepDescriptions: opts.keepDescriptions,
|
|
705
|
+
epoch: opts.epoch !== undefined ? Number(opts.epoch) : undefined,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
710
|
+
}
|
|
711
|
+
const seed = parseSeed(result.seedDoc);
|
|
712
|
+
const outFile = resolve(opts.out ?? join(resolveUserDir("seeds"), `${opts.name}.json`));
|
|
713
|
+
mkdirSync(dirname(outFile), { recursive: true });
|
|
714
|
+
writeFileSync(outFile, `${JSON.stringify(result.seedDoc, null, 2)}\n`);
|
|
715
|
+
const s = result.stats;
|
|
716
|
+
const m = result.manifest;
|
|
717
|
+
console.log(`wrote ${outFile}`);
|
|
718
|
+
if (opts.manifest) {
|
|
719
|
+
const manifestFile = resolve(opts.manifest);
|
|
720
|
+
mkdirSync(dirname(manifestFile), { recursive: true });
|
|
721
|
+
writeFileSync(manifestFile, `${JSON.stringify(m, null, 2)}\n`);
|
|
722
|
+
console.log(`manifest written ${manifestFile}`);
|
|
723
|
+
}
|
|
724
|
+
if (opts.identityMap) {
|
|
725
|
+
const mapFile = resolve(opts.identityMap);
|
|
726
|
+
mkdirSync(dirname(mapFile), { recursive: true });
|
|
727
|
+
const map = identityMapDoc(seed.name, seedHash(seed), result.identities);
|
|
728
|
+
writeFileSync(mapFile, `${JSON.stringify(map, null, 2)}\n`, { mode: 0o600 });
|
|
729
|
+
chmodSync(mapFile, 0o600);
|
|
730
|
+
console.log(`identity map written ${mapFile} (mode 0600 — real identities; keep it on this machine, never commit it)`);
|
|
731
|
+
}
|
|
732
|
+
if (s.named.emails > 0)
|
|
733
|
+
console.log(` named by --tickets: ${s.named.found} of ${s.named.emails} email(s) found in the account and copied in with their invoices, charges and subscriptions${s.named.missing.length ? `; not in the account: ${s.named.missing.slice(0, 5).join(", ")}${s.named.missing.length > 5 ? ` and ${s.named.missing.length - 5} more` : ""}` : ""}`);
|
|
734
|
+
console.log(` seed_hash ${seedHash(seed)}`);
|
|
735
|
+
console.log(` customers=${s.customers} products=${s.products} prices=${s.prices} invoices=${s.invoices} (charges attached: ${s.charges_attached}) subscriptions=${s.subscriptions}`);
|
|
736
|
+
const r = m.scrub.replaced;
|
|
737
|
+
const kept = m.scrub.retained.product_names || m.scrub.retained.line_descriptions
|
|
738
|
+
? " (some kept by flag — see the manifest samples)"
|
|
739
|
+
: "";
|
|
740
|
+
const fromKept = r.line_descriptions_from_kept_nickname > 0
|
|
741
|
+
? `, ${r.line_descriptions_from_kept_nickname} line(s) carry their price's kept nickname`
|
|
742
|
+
: "";
|
|
743
|
+
console.log(` pseudonymized: ${r.customer_names} name(s), ${r.customer_emails} email(s); replaced ${r.product_names} product name(s), ${r.price_nicknames} price nickname(s), ${r.line_descriptions} line description(s)${fromKept}${kept}; ids remapped — pseudonymization, not anonymity`);
|
|
744
|
+
const v = m.scrub.verbatim;
|
|
745
|
+
if (v.product_ids.count + v.price_ids.count > 0) {
|
|
746
|
+
const examples = [...v.product_ids.samples, ...v.price_ids.samples].slice(0, 3).join(", ");
|
|
747
|
+
console.log(` verbatim: ${v.product_ids.count} merchant-chosen product id(s), ${v.price_ids.count} price id(s) kept as-is — agent code references them, so an id that names a client is not scrubbed (e.g. ${examples}; the manifest lists them)`);
|
|
748
|
+
}
|
|
749
|
+
for (const note of m.lossy.notes)
|
|
750
|
+
console.log(` lossy: ${note}`);
|
|
751
|
+
if (s.skipped.length > 0) {
|
|
752
|
+
console.log(` skipped ${s.skipped.length} object(s):`);
|
|
753
|
+
for (const line of s.skipped.slice(0, 10))
|
|
754
|
+
console.log(` - ${line}`);
|
|
755
|
+
if (s.skipped.length > 10)
|
|
756
|
+
console.log(` ... and ${s.skipped.length - 10} more`);
|
|
757
|
+
}
|
|
758
|
+
console.log(`\nCreate a world from it: worlds world create --seed ${seed.name}`);
|
|
759
|
+
});
|
|
760
|
+
const tasks = program
|
|
761
|
+
.command("tasks")
|
|
762
|
+
.description("the tasks file (worlds/tasks.yaml): your tickets with one-line expected: keys, compiled into a seed and a rubric — validate, build, import. Local, no server.\n" +
|
|
763
|
+
"expected: forms — refund <amount> [on any | on duplicate | on <charge id>] · credit <amount> · credit up to <amount> · void [<invoice id>] · cancel [<subscription id> | duplicate] · cancel at period end [<subscription id>] · plan change to <price id> [<subscription id>] · none · escalate");
|
|
764
|
+
async function buildTasksFile(file, seedsDir, keepSeedTickets) {
|
|
765
|
+
const { buildTasks, loadTasks, readIdentityMap, resolveSeedRef, TaskErrors } = await import("./tasks.js");
|
|
766
|
+
const path = resolve(file);
|
|
767
|
+
if (!existsSync(path))
|
|
768
|
+
fail(`no such file: ${path}`, 2);
|
|
769
|
+
let loaded;
|
|
770
|
+
try {
|
|
771
|
+
loaded = loadTasks(path);
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
fail(err instanceof Error ? err.message : String(err), 2);
|
|
775
|
+
}
|
|
776
|
+
const { doc, dir, text } = loaded;
|
|
777
|
+
try {
|
|
778
|
+
const base = resolveSeedRef(doc.seed, dir, seedsDir);
|
|
779
|
+
const map = doc.identity_map === undefined ? undefined : readIdentityMap(resolve(dir, doc.identity_map));
|
|
780
|
+
const built = buildTasks(doc, base, map, { keepSeedTickets, name: basename(path), text });
|
|
781
|
+
return { doc, dir, text, path, built };
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
if (err instanceof TaskErrors) {
|
|
785
|
+
console.error(`${path}: ${err.errors.length} problem(s):`);
|
|
786
|
+
for (const e of err.errors)
|
|
787
|
+
console.error(` ✗ ${e.message}`);
|
|
788
|
+
process.exit(2);
|
|
789
|
+
}
|
|
790
|
+
fail(err instanceof Error ? err.message : String(err), 2);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
tasks
|
|
794
|
+
.command("validate <file>")
|
|
795
|
+
.description("parse the file, resolve every expected: line against the seed, check the identity map; every problem by task id, exit 2 on any")
|
|
796
|
+
.option("--seeds-dir <dir>", "your seeds directory for a library seed name (default: $WORLDS_SEEDS_DIR, else ./seeds)")
|
|
797
|
+
.option("--keep-seed-tickets", "keep the base seed's own tickets ahead of the tasks", false)
|
|
798
|
+
.action(async (file, opts) => {
|
|
799
|
+
const { describeBuild } = await import("./tasks.js");
|
|
800
|
+
const { doc, built, path } = await buildTasksFile(file, opts.seedsDir, opts.keepSeedTickets);
|
|
801
|
+
console.log(`OK: ${path}`);
|
|
802
|
+
console.log(` ${describeBuild(doc, built)}`);
|
|
803
|
+
for (const t of built.record.tasks)
|
|
804
|
+
console.log(` ${t.position}. ${t.label}: ${t.customer ?? `(escalate: ${built.rubric.tickets[t.position - 1].candidates.join(", ")})`} — ${t.resolution}`);
|
|
805
|
+
});
|
|
806
|
+
tasks
|
|
807
|
+
.command("build <file>")
|
|
808
|
+
.description("compile the tasks file: the seed (the account with the tasks on its desk), the rubric, and <name>.build.json beside the file")
|
|
809
|
+
.option("--out-seed <file>", "write the seed document (JSON)")
|
|
810
|
+
.option("--out-rubric <file>", "write the rubric (JSON)")
|
|
811
|
+
.option("--out-record <file>", "write the build record here instead of beside the tasks file")
|
|
812
|
+
.option("--rewrite", "write the tasks file back with every requester replaced by the seed's email (for a file going to CI)", false)
|
|
813
|
+
.option("--keep-seed-tickets", "keep the base seed's own tickets ahead of the tasks", false)
|
|
814
|
+
.option("--seeds-dir <dir>", "your seeds directory for a library seed name (default: $WORLDS_SEEDS_DIR, else ./seeds)")
|
|
815
|
+
.action(async (file, opts) => {
|
|
816
|
+
const { buildRecordPath, describeBuild, rewriteRequesters } = await import("./tasks.js");
|
|
817
|
+
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
818
|
+
const { dirname } = await import("node:path");
|
|
819
|
+
const { doc, built, path, text } = await buildTasksFile(file, opts.seedsDir, opts.keepSeedTickets);
|
|
820
|
+
const write = (target, content) => {
|
|
821
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
822
|
+
writeFileSync(target, content);
|
|
823
|
+
console.log(`wrote ${target}`);
|
|
824
|
+
};
|
|
825
|
+
if (opts.outSeed)
|
|
826
|
+
write(resolve(opts.outSeed), `${JSON.stringify(built.seedDoc, null, 2)}\n`);
|
|
827
|
+
if (opts.outRubric)
|
|
828
|
+
write(resolve(opts.outRubric), `${JSON.stringify(built.rubric, null, 2)}\n`);
|
|
829
|
+
write(opts.outRecord ? resolve(opts.outRecord) : buildRecordPath(path), `${JSON.stringify(built.record, null, 2)}\n`);
|
|
830
|
+
if (opts.rewrite) {
|
|
831
|
+
const rewritten = rewriteRequesters(text, built);
|
|
832
|
+
if (rewritten !== text) {
|
|
833
|
+
writeFileSync(path, rewritten);
|
|
834
|
+
console.log(`rewrote ${path}: requesters are the seed's emails now`);
|
|
835
|
+
}
|
|
836
|
+
else
|
|
837
|
+
console.log(`${path}: every requester is already the seed's email`);
|
|
838
|
+
}
|
|
839
|
+
console.log(describeBuild(doc, built));
|
|
840
|
+
console.log(` seed ${built.record.seed.name} (${built.record.seed.seed_hash.slice(0, 12)}…) · rubric ${built.record.rubric_hash.slice(0, 12)}…`);
|
|
841
|
+
});
|
|
842
|
+
tasks
|
|
843
|
+
.command("import <file>")
|
|
844
|
+
.description("one task per row of a help-desk export or a dataset — CSV, a JSON array, JSON lines — the format from the file, the columns from their names (the mapping is printed; --map overrides an entry), appended to the tasks file without duplicating ids. A requester the account does not know is skipped and counted; on an imported account, a real email becomes its pseudonym through the identity map beside the seed")
|
|
845
|
+
.option("--from <format>", "csv | json | jsonl (default: the file's extension, else its content)")
|
|
846
|
+
.option("--map <spec>", "field=column,… (requester=customer_email) to override the detection, with the fields id, requester, requester_name, subject, body, priority, tags, expected, created, trap_class; unmapped columns land in metadata")
|
|
847
|
+
.option("--requester <email>", "the requester for every row that has none")
|
|
848
|
+
.option("--tag <tag>", "keep rows whose tags column holds this tag")
|
|
849
|
+
.option("--since <date>", "keep rows whose created column is on or after this date")
|
|
850
|
+
.option("--limit <n>", "keep the first n rows after the filters")
|
|
851
|
+
.option("--into <file>", "the tasks file to append to (default: worlds/tasks.yaml when it exists; created when absent, with --seed)")
|
|
852
|
+
.option("--seed <name-or-file>", "the seed a new tasks file names (required when --into does not exist yet)")
|
|
853
|
+
.option("--mode <mode>", "the mode a new tasks file names: isolated | shift", "isolated")
|
|
854
|
+
.option("--identity-map <file>", "the identity map to resolve real emails through (default: <seed name>.identities.json beside the tasks file, when it exists)")
|
|
855
|
+
.option("--keep-unknown", "keep rows whose requester neither the account nor the map knows (validate will name them)", false)
|
|
856
|
+
.action(async (file, opts) => {
|
|
857
|
+
const { runTasksImport, TasksImportError } = await import("./tasks.js");
|
|
858
|
+
try {
|
|
859
|
+
const report = runTasksImport({
|
|
860
|
+
file: resolve(file),
|
|
861
|
+
from: opts.from,
|
|
862
|
+
map: opts.map,
|
|
863
|
+
requester: opts.requester,
|
|
864
|
+
tag: opts.tag,
|
|
865
|
+
since: opts.since,
|
|
866
|
+
limit: opts.limit === undefined ? undefined : Number(opts.limit),
|
|
867
|
+
into: opts.into === undefined ? undefined : resolve(opts.into),
|
|
868
|
+
intoShown: opts.into,
|
|
869
|
+
cwd: process.cwd(),
|
|
870
|
+
seed: opts.seed,
|
|
871
|
+
mode: opts.mode,
|
|
872
|
+
identityMap: opts.identityMap,
|
|
873
|
+
keepUnknown: opts.keepUnknown,
|
|
874
|
+
});
|
|
875
|
+
for (const line of report.lines)
|
|
876
|
+
console.log(line);
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
if (err instanceof TasksImportError)
|
|
880
|
+
fail(err.message, 2);
|
|
881
|
+
throw err;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
const world = program.command("world").description("create, inspect, reset, and destroy worlds");
|
|
885
|
+
world
|
|
886
|
+
.command("create")
|
|
887
|
+
.description("create a world from a seed; prints world_id, base URL, and API key")
|
|
888
|
+
.requiredOption("--seed <name>", "library seed name (see `worlds seed ls`)")
|
|
889
|
+
.option("--json", "print the raw JSON response")
|
|
890
|
+
.action(async (opts) => {
|
|
891
|
+
const res = await admin("POST", "/admin/worlds", { seed: opts.seed });
|
|
892
|
+
if (opts.json)
|
|
893
|
+
return out(res);
|
|
894
|
+
console.log(`world_id ${res.world_id}`);
|
|
895
|
+
console.log(`base_url ${res.base_url}`);
|
|
896
|
+
console.log(`api_key ${res.api_key}`);
|
|
897
|
+
console.log(`admin_token ${res.admin_token}`);
|
|
898
|
+
console.log(`seed_hash ${res.seed_hash}`);
|
|
899
|
+
console.log(`clock ${res.clock} (${new Date(res.clock * 1000).toISOString()})`);
|
|
900
|
+
console.log(`\nGive the agent the base URL + API key; keep the admin token for your harness (marks, diffs, reset).`);
|
|
901
|
+
});
|
|
902
|
+
world
|
|
903
|
+
.command("ls")
|
|
904
|
+
.description("list worlds on the server")
|
|
905
|
+
.action(async () => {
|
|
906
|
+
const res = await admin("GET", "/admin/worlds");
|
|
907
|
+
const unreadable = res.unreadable ?? [];
|
|
908
|
+
if (res.worlds.length === 0 && unreadable.length === 0)
|
|
909
|
+
return console.log("no worlds. create one: worlds world create --seed saas-billing-small");
|
|
910
|
+
for (const w of res.worlds) {
|
|
911
|
+
console.log(`${w.world_id} seed=${w.seed_name} clock=${new Date(w.clock * 1000).toISOString()} (${w.clock_mode})`);
|
|
912
|
+
}
|
|
913
|
+
for (const u of unreadable) {
|
|
914
|
+
const head = `world ${u.world_id} `;
|
|
915
|
+
const reason = u.reason.startsWith(head) ? u.reason.slice(head.length) : u.reason;
|
|
916
|
+
console.log(`${u.world_id} unreadable — ${reason}`);
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
world
|
|
920
|
+
.command("export <world_id>")
|
|
921
|
+
.description("export a world's live state back into a seed file (fork a world into a new starting universe)")
|
|
922
|
+
.option("--name <name>", "seed name (kebab-case; default: <original seed>-export)")
|
|
923
|
+
.option("--out <file>", "output path (default: $WORLDS_SEEDS_DIR or ./seeds, <name>.json)")
|
|
924
|
+
.option("--json", "print the raw endpoint response instead of writing a file")
|
|
925
|
+
.action(async (id, opts) => {
|
|
926
|
+
const qs = opts.name ? `?name=${encodeURIComponent(opts.name)}` : "";
|
|
927
|
+
const res = await admin("GET", `/admin/worlds/${id}/export-seed${qs}`);
|
|
928
|
+
if (opts.json)
|
|
929
|
+
return out(res);
|
|
930
|
+
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
931
|
+
const { dirname } = await import("node:path");
|
|
932
|
+
const outFile = resolve(opts.out ?? join(resolveUserDir("seeds"), `${res.name}.json`));
|
|
933
|
+
mkdirSync(dirname(outFile), { recursive: true });
|
|
934
|
+
writeFileSync(outFile, `${JSON.stringify(res.seed_doc, null, 2)}\n`);
|
|
935
|
+
const c = res.counts;
|
|
936
|
+
console.log(`wrote ${outFile}`);
|
|
937
|
+
console.log(` seed_hash ${res.seed_hash}`);
|
|
938
|
+
console.log(` customers=${c.customers} products=${c.products} prices=${c.prices} invoices=${c.invoices} (charges attached: ${c.charges_attached}) subscriptions=${c.subscriptions}`);
|
|
939
|
+
for (const note of res.lossy.notes)
|
|
940
|
+
console.log(` lossy: ${note}`);
|
|
941
|
+
console.log(`\nCreate a world from it: worlds world create --seed ${res.name}`);
|
|
942
|
+
});
|
|
943
|
+
world
|
|
944
|
+
.command("reset <world_id>")
|
|
945
|
+
.description("reset a world to its seed (keeps API keys)")
|
|
946
|
+
.action(async (id) => {
|
|
947
|
+
await admin("POST", `/admin/worlds/${id}/reset`, {});
|
|
948
|
+
console.log(`reset ${id} to its seed`);
|
|
949
|
+
});
|
|
950
|
+
world
|
|
951
|
+
.command("destroy <world_id>")
|
|
952
|
+
.description("destroy a world and delete its database")
|
|
953
|
+
.action(async (id) => {
|
|
954
|
+
await admin("DELETE", `/admin/worlds/${id}`);
|
|
955
|
+
console.log(`destroyed ${id}`);
|
|
956
|
+
});
|
|
957
|
+
const scenario = program.command("scenario").description("apply failure-injection scenarios to a world");
|
|
958
|
+
scenario
|
|
959
|
+
.command("apply <world_id> <file-or-name>")
|
|
960
|
+
.description("apply a scenario from a YAML/JSON file, or a library scenario by name")
|
|
961
|
+
.action(async (id, source) => {
|
|
962
|
+
const body = existsSync(source) ? { scenario_doc: readDoc(source) } : { name: source };
|
|
963
|
+
const res = await admin("POST", `/admin/worlds/${id}/scenario`, body);
|
|
964
|
+
console.log(`applied scenario '${res.applied}' (${res.rules} rule${res.rules === 1 ? "" : "s"}) to ${id}`);
|
|
965
|
+
});
|
|
966
|
+
scenario
|
|
967
|
+
.command("clear <world_id>")
|
|
968
|
+
.description("clear the active scenario (and its counters)")
|
|
969
|
+
.action(async (id) => {
|
|
970
|
+
await admin("DELETE", `/admin/worlds/${id}/scenario`);
|
|
971
|
+
console.log(`cleared scenario on ${id}`);
|
|
972
|
+
});
|
|
973
|
+
scenario
|
|
974
|
+
.command("ls")
|
|
975
|
+
.description("list library scenarios on the server")
|
|
976
|
+
.action(async () => {
|
|
977
|
+
const res = await admin("GET", "/admin/scenarios");
|
|
978
|
+
if (res.scenarios.length === 0)
|
|
979
|
+
return console.log("no library scenarios found");
|
|
980
|
+
for (const s of res.scenarios)
|
|
981
|
+
console.log(`${s.name} ${s.description ?? ""}`);
|
|
982
|
+
});
|
|
983
|
+
const clock = program.command("clock").description("inspect and advance a world's logical clock");
|
|
984
|
+
clock
|
|
985
|
+
.command("advance <world_id> <duration>")
|
|
986
|
+
.description('advance logical time, e.g. "30d", "12h", "45m", "90s" — due renewals bill immediately')
|
|
987
|
+
.action(async (id, duration) => {
|
|
988
|
+
const res = await admin("POST", `/admin/worlds/${id}/clock/advance`, { duration });
|
|
989
|
+
console.log(`advanced ${id} by ${res.advanced_by}s → ${res.iso}`);
|
|
990
|
+
if (res.renewals_billed > 0)
|
|
991
|
+
console.log(` ${res.renewals_billed} subscription renewal${res.renewals_billed === 1 ? "" : "s"} billed`);
|
|
992
|
+
if (res.renewals_pending > 0)
|
|
993
|
+
console.log(` ${res.renewals_pending} subscription${res.renewals_pending === 1 ? "" : "s"} still due — advance again to continue`);
|
|
994
|
+
});
|
|
995
|
+
clock
|
|
996
|
+
.command("show <world_id>")
|
|
997
|
+
.description("show the world's current logical time")
|
|
998
|
+
.action(async (id) => {
|
|
999
|
+
const res = await admin("GET", `/admin/worlds/${id}/clock`);
|
|
1000
|
+
console.log(`${res.iso} (unix ${res.clock}, ${res.clock_mode} mode)`);
|
|
1001
|
+
});
|
|
1002
|
+
if (process.argv.slice(2).includes("--commands-json")) {
|
|
1003
|
+
out(commandAllowlist());
|
|
1004
|
+
process.exit(0);
|
|
1005
|
+
}
|
|
1006
|
+
const misplaced = globalOptionAfterSubcommand(process.argv.slice(2), Object.fromEntries(program.commands.map((c) => [
|
|
1007
|
+
c.name(),
|
|
1008
|
+
[c, ...c.commands].flatMap((cmd) => cmd.options.map((o) => o.long)).filter((l) => l !== undefined),
|
|
1009
|
+
])));
|
|
1010
|
+
if (misplaced !== undefined)
|
|
1011
|
+
fail(misplaced, 2);
|
|
1012
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
1013
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
1014
|
+
});
|