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/demo.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { buildServer } from "#server";
|
|
5
|
+
async function twin(ctx, method, path, form) {
|
|
6
|
+
const res = await fetch(`${ctx.base}${path}`, {
|
|
7
|
+
method,
|
|
8
|
+
headers: {
|
|
9
|
+
authorization: `Bearer ${ctx.key}`,
|
|
10
|
+
...(form ? { "content-type": "application/x-www-form-urlencoded" } : {}),
|
|
11
|
+
},
|
|
12
|
+
...(form ? { body: new URLSearchParams(form).toString() } : {}),
|
|
13
|
+
});
|
|
14
|
+
return { status: res.status, body: (await res.json()) };
|
|
15
|
+
}
|
|
16
|
+
async function retryUntilOk(fn, log, label) {
|
|
17
|
+
for (let attempt = 1; attempt <= 6; attempt++) {
|
|
18
|
+
const res = await fn();
|
|
19
|
+
if (res.status === 200)
|
|
20
|
+
return res;
|
|
21
|
+
log(` ${label}: got ${res.status}, retrying...`);
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`${label}: gave up`);
|
|
24
|
+
}
|
|
25
|
+
async function naiveAgent(ctx, email, log) {
|
|
26
|
+
const customers = await twin(ctx, "GET", `/v1/customers?email=${encodeURIComponent(email)}`);
|
|
27
|
+
const customer = customers.body.data[0];
|
|
28
|
+
log(` agent: found customer ${customer.id} (${customer.name})`);
|
|
29
|
+
const remediate = async () => {
|
|
30
|
+
const charges = await retryUntilOk(() => twin(ctx, "GET", `/v1/charges?customer=${customer.id}&limit=100`), log, "list charges");
|
|
31
|
+
const all = charges.body.data;
|
|
32
|
+
const ok = all.filter((c) => c.status === "succeeded");
|
|
33
|
+
const dup = ok.find((c) => c.invoice && ok.filter((o) => o.invoice === c.invoice && o.amount === c.amount).length > 1);
|
|
34
|
+
if (!dup)
|
|
35
|
+
return log(" agent: no duplicate found, nothing to do");
|
|
36
|
+
const pair = ok.filter((c) => c.invoice === dup.invoice && c.amount === dup.amount);
|
|
37
|
+
const target = pair.filter((c) => c.amount_refunded === 0)[0];
|
|
38
|
+
if (!target)
|
|
39
|
+
return log(" agent: duplicates already refunded, done");
|
|
40
|
+
log(` agent: two identical charges of $${dup.amount / 100} on ${dup.invoice} — refunding ${target.id}`);
|
|
41
|
+
const refund = await retryUntilOk(() => twin(ctx, "POST", "/v1/refunds", { charge: target.id, amount: String(target.amount) }), log, "create refund");
|
|
42
|
+
log(` agent: created refund ${refund.body.id}`);
|
|
43
|
+
};
|
|
44
|
+
await remediate();
|
|
45
|
+
const verify = await twin(ctx, "GET", "/v1/refunds?limit=100");
|
|
46
|
+
if (verify.status !== 200) {
|
|
47
|
+
log(` agent: verification failed (${verify.status}) — assuming the refund never happened, redoing the fix`);
|
|
48
|
+
await remediate();
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
log(" agent: verified, all good ✅ (or so it says)");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function runDemo() {
|
|
55
|
+
const dataDir = mkdtempSync(join(tmpdir(), "worlds-demo-"));
|
|
56
|
+
const server = buildServer({ dataDir });
|
|
57
|
+
await server.listen(0);
|
|
58
|
+
const admin = `http://127.0.0.1:${server.port()}`;
|
|
59
|
+
const log = (s) => console.log(s);
|
|
60
|
+
const runAct = async (title, scenario) => {
|
|
61
|
+
const create = await fetch(`${admin}/admin/worlds`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "content-type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ seed: "saas-billing-small" }),
|
|
65
|
+
});
|
|
66
|
+
const world = (await create.json());
|
|
67
|
+
if (scenario) {
|
|
68
|
+
await fetch(`${admin}/admin/worlds/${world.world_id}/scenario`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
scenario_doc: {
|
|
73
|
+
name: scenario,
|
|
74
|
+
rules: [
|
|
75
|
+
{
|
|
76
|
+
match: { path: "/v1/**" },
|
|
77
|
+
after_requests: 3,
|
|
78
|
+
duration_requests: 3,
|
|
79
|
+
respond: { status: 429, stripe_error: "rate_limit" },
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
await fetch(`${admin}/admin/worlds/${world.world_id}/marks`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ name: "start" }),
|
|
90
|
+
});
|
|
91
|
+
log(`\n${title}`);
|
|
92
|
+
log(scenario ? " (scenario: after 3 requests, the next 3 get 429 rate-limited — a bad minute at Stripe)\n" : "");
|
|
93
|
+
await naiveAgent({ base: world.base_url, key: world.api_key }, "dana.doublepay@example.com", log);
|
|
94
|
+
const diffRes = await fetch(`${admin}/admin/worlds/${world.world_id}/diff?from=start`);
|
|
95
|
+
const diff = (await diffRes.json());
|
|
96
|
+
const refunds = diff.created.refunds ?? [];
|
|
97
|
+
log(`\n 📋 world diff since mark: ${refunds.length} refund(s) created${refunds.map((r) => ` — $${r.amount / 100} on ${r.charge}`).join(",")}`);
|
|
98
|
+
return refunds.length;
|
|
99
|
+
};
|
|
100
|
+
console.log('worlds demo — the ticket: "I was charged twice for my invoice, please fix it."');
|
|
101
|
+
console.log("The universe: 50 customers, 200 invoices; Dana Doublepay really was charged twice ($40 each).");
|
|
102
|
+
const clear = await runAct("☀️ ACT 1 — clear skies", null);
|
|
103
|
+
console.log(clear === 1 ? " ✅ PASS — exactly one $40 refund. Nice agent." : " ❌ unexpected!");
|
|
104
|
+
const storm = await runAct("⛈ ACT 2 — same agent, same ticket, rate-limit storm", "storm");
|
|
105
|
+
if (storm > 1) {
|
|
106
|
+
console.log(` ❌ CAUGHT — the agent refunded ${storm} times ($${storm * 40} back on a $40 mistake).`);
|
|
107
|
+
console.log(" Its transcript claimed success. The world diff knew better.");
|
|
108
|
+
}
|
|
109
|
+
console.log("\nThat's worlds: grade the world, not the transcript — deterministically, before production.");
|
|
110
|
+
console.log("Next: `worlds init` scaffolds this harness into your own repo for your own agent.");
|
|
111
|
+
await server.close();
|
|
112
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
113
|
+
return storm > 1 && clear === 1 ? 0 : 1;
|
|
114
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { EXIT_CODES, summarizeTraffic } from "./contract.js";
|
|
5
|
+
import { createAdminCall } from "./passk.js";
|
|
6
|
+
import { describeSdkPath, preloadEnabled, preloadEnv, preloadSighting } from "./preload.js";
|
|
7
|
+
import { stdioDead } from "./stdio.js";
|
|
8
|
+
import { TRY_TASK } from "./try.js";
|
|
9
|
+
export function parseEnvFile(text) {
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
12
|
+
const line = raw.trim();
|
|
13
|
+
if (line === "" || line.startsWith("#"))
|
|
14
|
+
continue;
|
|
15
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
16
|
+
if (!m)
|
|
17
|
+
continue;
|
|
18
|
+
let value = m[2].trim();
|
|
19
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
20
|
+
value = value.slice(1, -1);
|
|
21
|
+
out[m[1]] = value;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
const isLoopback = (host) => host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host);
|
|
26
|
+
export function inferTopology(adminUrl, baseUrl) {
|
|
27
|
+
let admin;
|
|
28
|
+
let world;
|
|
29
|
+
try {
|
|
30
|
+
admin = new URL(adminUrl);
|
|
31
|
+
world = new URL(baseUrl);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return `the world's base_url (${baseUrl}) is not a URL`;
|
|
35
|
+
}
|
|
36
|
+
if (world.hostname === "host.docker.internal")
|
|
37
|
+
return "host → container: the twin on the host, the agent in a container reaching it as host.docker.internal";
|
|
38
|
+
if (isLoopback(world.hostname) && isLoopback(admin.hostname))
|
|
39
|
+
return "host → host: the agent and the twin on one machine, over loopback";
|
|
40
|
+
if (world.hostname === admin.hostname)
|
|
41
|
+
return `sibling container or LAN host: the agent reaches the twin by the name the server was started with (${world.hostname})`;
|
|
42
|
+
return `reverse proxy or an explicit --public-url: the worlds are handed out at ${world.origin}, not at the admin URL's ${admin.origin}`;
|
|
43
|
+
}
|
|
44
|
+
function runAgentOnce(command, env, timeoutSec) {
|
|
45
|
+
return new Promise((resolvePromise) => {
|
|
46
|
+
const started = performance.now();
|
|
47
|
+
const child = spawn(command[0], command.slice(1), {
|
|
48
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
49
|
+
env: { ...process.env, ...env },
|
|
50
|
+
detached: process.platform !== "win32",
|
|
51
|
+
});
|
|
52
|
+
let stderr = "";
|
|
53
|
+
child.stderr?.on("data", (chunk) => {
|
|
54
|
+
process.stderr.write(chunk);
|
|
55
|
+
if (stderr.length < 65_536)
|
|
56
|
+
stderr += chunk.toString("utf8");
|
|
57
|
+
});
|
|
58
|
+
let timedOut = false;
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
timedOut = true;
|
|
61
|
+
try {
|
|
62
|
+
if (child.pid !== undefined && process.platform !== "win32")
|
|
63
|
+
process.kill(-child.pid, "SIGKILL");
|
|
64
|
+
else
|
|
65
|
+
child.kill("SIGKILL");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
}
|
|
69
|
+
}, timeoutSec * 1000);
|
|
70
|
+
child.on("error", (err) => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolvePromise({
|
|
73
|
+
spawned: false,
|
|
74
|
+
exit: null,
|
|
75
|
+
signal: null,
|
|
76
|
+
timedOut: false,
|
|
77
|
+
error: err.message,
|
|
78
|
+
stderr,
|
|
79
|
+
ms: performance.now() - started,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
let done = false;
|
|
83
|
+
const finish = (exit, signal) => {
|
|
84
|
+
if (done)
|
|
85
|
+
return;
|
|
86
|
+
done = true;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolvePromise({ spawned: true, exit, signal, timedOut, stderr, ms: performance.now() - started });
|
|
89
|
+
};
|
|
90
|
+
child.on("close", finish);
|
|
91
|
+
child.on("exit", (exit, signal) => {
|
|
92
|
+
setTimeout(() => finish(exit, signal), 500).unref();
|
|
93
|
+
});
|
|
94
|
+
child.stdin?.on("error", () => { });
|
|
95
|
+
child.stdin?.end(`${env.WORLDS_TASK_JSON}\n`);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export async function runDoctor(opts) {
|
|
99
|
+
const preloadOn = preloadEnabled(opts.preload);
|
|
100
|
+
let sighting;
|
|
101
|
+
const say = (line) => {
|
|
102
|
+
if (!opts.json && !stdioDead(process.stdout))
|
|
103
|
+
console.log(line);
|
|
104
|
+
};
|
|
105
|
+
const admin = opts.admin ?? createAdminCall(opts.adminUrl, opts.adminToken);
|
|
106
|
+
const report = {
|
|
107
|
+
status: "inconclusive",
|
|
108
|
+
reason: "",
|
|
109
|
+
next_action: "",
|
|
110
|
+
exit_code: EXIT_CODES.inconclusive,
|
|
111
|
+
server: { url: opts.adminUrl, auth: "?", token_source: opts.tokenSource, protocol: null, worlds: null },
|
|
112
|
+
world: null,
|
|
113
|
+
probe: {
|
|
114
|
+
kind: opts.envFile !== undefined ? "env-file" : opts.agentCommand?.length ? "agent" : "built-in",
|
|
115
|
+
detail: [],
|
|
116
|
+
},
|
|
117
|
+
traffic: null,
|
|
118
|
+
webhook: "not checked",
|
|
119
|
+
sdk: null,
|
|
120
|
+
};
|
|
121
|
+
const finish = (status, reason, next) => {
|
|
122
|
+
report.status = status;
|
|
123
|
+
report.reason = reason;
|
|
124
|
+
report.next_action = next;
|
|
125
|
+
report.exit_code = EXIT_CODES[status];
|
|
126
|
+
if (opts.json)
|
|
127
|
+
console.log(JSON.stringify(report, null, 2));
|
|
128
|
+
else {
|
|
129
|
+
say(`status: ${status} — ${reason}`);
|
|
130
|
+
say(`next: ${next}`);
|
|
131
|
+
}
|
|
132
|
+
return report.exit_code;
|
|
133
|
+
};
|
|
134
|
+
let health;
|
|
135
|
+
try {
|
|
136
|
+
health = await admin("GET", "/admin/health");
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
say(`doctor: server ${opts.adminUrl} — no answer (${err instanceof Error ? err.message : String(err)})`);
|
|
140
|
+
return finish("setup_failed", `no server answered ${opts.adminUrl}/admin/health`, "start one (worlds serve), point --url / WORLDS_URL at it, or run with --server auto");
|
|
141
|
+
}
|
|
142
|
+
report.server = {
|
|
143
|
+
url: opts.adminUrl,
|
|
144
|
+
auth: health.auth,
|
|
145
|
+
token_source: opts.tokenSource,
|
|
146
|
+
protocol: health.protocol,
|
|
147
|
+
worlds: health.worlds,
|
|
148
|
+
};
|
|
149
|
+
say(`doctor: server ${opts.adminUrl} — ok · auth ${health.auth} (token: ${opts.tokenSource}) · protocol ${health.protocol} · ${health.worlds} world${health.worlds === 1 ? "" : "s"}${opts.own ? `\n this run's own server: bound ${opts.own.host}:${opts.own.port}, data ${opts.own.dataDir}` : ""}`);
|
|
150
|
+
let world;
|
|
151
|
+
try {
|
|
152
|
+
world = await admin("POST", "/admin/worlds", { seed: "saas-billing-small" });
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
return finish("setup_failed", `the server could not create a world from saas-billing-small: ${err instanceof Error ? err.message : String(err)}`, "the bundled seed is missing or the server refused: `worlds seed ls` against this server, and its version against the CLI's");
|
|
156
|
+
}
|
|
157
|
+
const topology = inferTopology(opts.adminUrl, world.base_url);
|
|
158
|
+
report.world = { world_id: world.world_id, base_url: world.base_url, topology };
|
|
159
|
+
say(`doctor: world ${world.world_id} from saas-billing-small — base_url ${world.base_url}\n topology: ${topology}`);
|
|
160
|
+
try {
|
|
161
|
+
const detail = report.probe.detail;
|
|
162
|
+
let configuration;
|
|
163
|
+
if (opts.envFile !== undefined) {
|
|
164
|
+
const file = resolve(opts.envFile);
|
|
165
|
+
if (!existsSync(file))
|
|
166
|
+
return finish("configuration_incomplete", `--env-file ${file} does not exist`, "name the file your agent's environment is loaded from");
|
|
167
|
+
const env = parseEnvFile(readFileSync(file, "utf8"));
|
|
168
|
+
const names = Object.keys(env);
|
|
169
|
+
detail.push(`env file ${file}: ${names.length} variable${names.length === 1 ? "" : "s"}${names.length ? ` (${names.slice(0, 8).join(", ")}${names.length > 8 ? ", …" : ""})` : ""}`);
|
|
170
|
+
const base = env.WORLDS_BASE_URL;
|
|
171
|
+
const worldUrl = new URL(world.base_url);
|
|
172
|
+
if (base === undefined || base === "") {
|
|
173
|
+
configuration = {
|
|
174
|
+
reason: `no base URL in ${file}: WORLDS_BASE_URL is not set`,
|
|
175
|
+
next: "a Stripe SDK reaches the twin only through a base URL it can be pointed at (stripe-node: host/port/protocol; stripe-python: stripe.api_base); passk exports WORLDS_BASE_URL to the agent — read it there. A hosted Stripe tool with no base URL to swap is out of scope",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = new URL(base);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
parsed = undefined;
|
|
185
|
+
}
|
|
186
|
+
if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:"))
|
|
187
|
+
configuration = {
|
|
188
|
+
reason: `WORLDS_BASE_URL=${base} is not an http(s) URL`,
|
|
189
|
+
next: `the world's is ${world.base_url}: scheme, host and port, no path beyond the world's`,
|
|
190
|
+
};
|
|
191
|
+
else if (parsed.host !== worldUrl.host)
|
|
192
|
+
configuration = {
|
|
193
|
+
reason: `WORLDS_BASE_URL points at ${parsed.host}; this server's worlds live at ${worldUrl.host} — the SDK would call the wrong place`,
|
|
194
|
+
next: `use the base URL passk exports (${world.base_url} for this world), or start the server where the env file expects it`,
|
|
195
|
+
};
|
|
196
|
+
else {
|
|
197
|
+
detail.push(`WORLDS_BASE_URL=${base}: scheme and host match the world's`);
|
|
198
|
+
const key = env.WORLDS_API_KEY ?? world.api_key;
|
|
199
|
+
detail.push(env.WORLDS_API_KEY
|
|
200
|
+
? "WORLDS_API_KEY: from the env file"
|
|
201
|
+
: "WORLDS_API_KEY: not in the env file — the world's key used for the probe (passk exports one per world)");
|
|
202
|
+
const res = await fetch(`${base.replace(/\/$/, "")}/v1/customers?limit=1`, {
|
|
203
|
+
headers: { authorization: `Bearer ${key}` },
|
|
204
|
+
}).catch((err) => err);
|
|
205
|
+
if (res instanceof Error)
|
|
206
|
+
detail.push(`probe GET ${base}/v1/customers?limit=1: ${res.message}`);
|
|
207
|
+
else {
|
|
208
|
+
detail.push(`probe GET ${base}/v1/customers?limit=1 → ${res.status}`);
|
|
209
|
+
if (res.status === 401)
|
|
210
|
+
configuration = {
|
|
211
|
+
reason: `the key in ${file} is not this world's (401 from the twin)`,
|
|
212
|
+
next: "keys are minted per world: read WORLDS_API_KEY from the environment passk hands the agent instead of fixing one in a file",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else if (opts.agentCommand && opts.agentCommand.length > 0) {
|
|
219
|
+
const task = {
|
|
220
|
+
id: 1,
|
|
221
|
+
label: TRY_TASK.id,
|
|
222
|
+
subject: TRY_TASK.subject,
|
|
223
|
+
body: TRY_TASK.body,
|
|
224
|
+
requester: { name: "Dana Doublepay", email: TRY_TASK.requester },
|
|
225
|
+
priority: "normal",
|
|
226
|
+
tags: [],
|
|
227
|
+
};
|
|
228
|
+
const env = {
|
|
229
|
+
...(preloadOn ? preloadEnv(process.env) : {}),
|
|
230
|
+
WORLDS_BASE_URL: world.base_url,
|
|
231
|
+
WORLDS_API_KEY: world.api_key,
|
|
232
|
+
STRIPE_SECRET_KEY: world.api_key,
|
|
233
|
+
STRIPE_API_KEY: world.api_key,
|
|
234
|
+
WORLDS_WORLD_ID: world.world_id,
|
|
235
|
+
WORLDS_TASK_JSON: JSON.stringify(task),
|
|
236
|
+
};
|
|
237
|
+
say(`doctor: running your agent once with the world's bindings and the built-in task (${opts.timeoutSec}s limit)…`);
|
|
238
|
+
const run = await runAgentOnce(opts.agentCommand, env, opts.timeoutSec);
|
|
239
|
+
sighting = preloadSighting(run.stderr);
|
|
240
|
+
if (!run.spawned)
|
|
241
|
+
configuration = {
|
|
242
|
+
reason: `the agent command could not be started: ${run.error ?? "unknown error"}`,
|
|
243
|
+
next: `check the command after -- (${opts.agentCommand.join(" ")}) runs from this directory`,
|
|
244
|
+
};
|
|
245
|
+
else
|
|
246
|
+
detail.push(`agent: ${opts.agentCommand.join(" ")} — ${run.timedOut ? `killed after ${opts.timeoutSec}s` : run.exit !== null ? `exit ${run.exit}` : `ended by ${run.signal}`} in ${(run.ms / 1000).toFixed(1)}s`);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const res = await fetch(`${world.base_url}/v1/customers?limit=1`, {
|
|
250
|
+
headers: { authorization: `Bearer ${world.api_key}` },
|
|
251
|
+
}).catch((err) => err);
|
|
252
|
+
detail.push(res instanceof Error
|
|
253
|
+
? `built-in probe GET ${world.base_url}/v1/customers?limit=1: ${res.message}`
|
|
254
|
+
: `built-in probe GET ${world.base_url}/v1/customers?limit=1 → ${res.status}`);
|
|
255
|
+
}
|
|
256
|
+
for (const line of detail)
|
|
257
|
+
say(`doctor: ${line}`);
|
|
258
|
+
const rows = (await admin("GET", `/admin/worlds/${world.world_id}/requests`)).data;
|
|
259
|
+
const traffic = summarizeTraffic(rows);
|
|
260
|
+
report.traffic = traffic;
|
|
261
|
+
const first = rows[0];
|
|
262
|
+
say(`doctor: the world's request log — ${traffic.requests} request${traffic.requests === 1 ? "" : "s"}${first ? ` (first: ${first.method} ${first.path})` : ""}${traffic.unmatched.length ? ` · ${traffic.unmatched.length} route(s) the twin does not implement: ${traffic.unmatched.map((u) => u.route).join(", ")}` : ""}${traffic.refused.length ? ` · ${traffic.refused.length} refused parameter(s): ${traffic.refused.map((u) => u.route).join(", ")}` : ""}`);
|
|
263
|
+
if (report.probe.kind === "agent") {
|
|
264
|
+
report.sdk = describeSdkPath(sighting, traffic.requests > 0, preloadOn);
|
|
265
|
+
say(`doctor: sdk — ${report.sdk}`);
|
|
266
|
+
}
|
|
267
|
+
const info = await admin("GET", `/admin/worlds/${world.world_id}`);
|
|
268
|
+
const endpoint = info.webhook_endpoint?.url;
|
|
269
|
+
if (!endpoint)
|
|
270
|
+
report.webhook = "none configured on this world";
|
|
271
|
+
else {
|
|
272
|
+
const res = await fetch(endpoint, { method: "HEAD", signal: AbortSignal.timeout(3000) }).catch((err) => err);
|
|
273
|
+
report.webhook =
|
|
274
|
+
res instanceof Error ? `${endpoint} — unreachable (${res.message})` : `${endpoint} — answered ${res.status}`;
|
|
275
|
+
}
|
|
276
|
+
say(`doctor: webhook endpoint — ${report.webhook}`);
|
|
277
|
+
if (configuration)
|
|
278
|
+
return finish("configuration_incomplete", configuration.reason, configuration.next);
|
|
279
|
+
if (traffic.requests === 0)
|
|
280
|
+
return finish("inconclusive", report.probe.kind === "built-in"
|
|
281
|
+
? `the server did not log the built-in probe: the base_url the world hands out (${world.base_url}) does not route back to this server`
|
|
282
|
+
: "no Stripe traffic recorded: the agent never reached the twin", report.probe.kind === "built-in"
|
|
283
|
+
? "a reverse proxy or a container boundary is in the way: set --public-url to the address the agent can reach, and check it forwards to this server"
|
|
284
|
+
: "point the Stripe SDK at WORLDS_BASE_URL with WORLDS_API_KEY from the environment passk hands the agent (stripe-node: host/port/protocol; stripe-python: stripe.api_base; the key also arrives as STRIPE_SECRET_KEY / STRIPE_API_KEY, so an SDK built from either needs only the host; the preload fills that host for a stock stripe-node or stripe-python it can see, so an agent it did not reach is bundled, on another SDK, or in a process the environment does not pass through), and check the command runs from this directory");
|
|
285
|
+
if (traffic.unmatched.length || traffic.refused.length)
|
|
286
|
+
return finish("inconclusive", `the twin heard the agent, but it asked for what the twin does not model (${[...traffic.unmatched, ...traffic.refused].map((u) => u.route).join(", ")})`, "docs/scope.md lists what is implemented and what is refused; a run on those calls cannot be graded");
|
|
287
|
+
return finish("passed", `the twin heard the ${report.probe.kind === "agent" ? "agent" : "probe"}: ${traffic.requests} request${traffic.requests === 1 ? "" : "s"} recorded`, report.probe.kind === "agent"
|
|
288
|
+
? "worlds try -- <your agent command> shows its first diff"
|
|
289
|
+
: "run doctor with your agent command after -- to check the agent itself");
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
try {
|
|
293
|
+
await admin("DELETE", `/admin/worlds/${world.world_id}`);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
say(`doctor: world ${world.world_id} could not be destroyed: ${err instanceof Error ? err.message : String(err)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { RESOURCE_NAMES } from "#server";
|
|
2
|
+
const OPS = ["==", "!=", ">=", "<=", ">", "<"];
|
|
3
|
+
const GROUPS = ["created", "updated", "deleted"];
|
|
4
|
+
const MONEY_FIELDS = [
|
|
5
|
+
"charges_created",
|
|
6
|
+
"refunds_created",
|
|
7
|
+
"invoices_paid",
|
|
8
|
+
"credits_granted",
|
|
9
|
+
"net_movement",
|
|
10
|
+
];
|
|
11
|
+
const MONEY_LEAVES = ["count", "amount"];
|
|
12
|
+
export class PasskConfigError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export class PasskInconclusiveError extends Error {
|
|
15
|
+
}
|
|
16
|
+
export class PasskSetupError extends Error {
|
|
17
|
+
}
|
|
18
|
+
export function requireBeforeImages(diff) {
|
|
19
|
+
if (diff.before === undefined) {
|
|
20
|
+
throw new PasskInconclusiveError("the server's diff has no before-images (diff.before), so the shift cannot be graded — upgrade the server to the CLI's version");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function editDistance(a, b) {
|
|
24
|
+
const d = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
25
|
+
for (let i = 0; i <= a.length; i++)
|
|
26
|
+
d[i][0] = i;
|
|
27
|
+
for (let j = 0; j <= b.length; j++)
|
|
28
|
+
d[0][j] = j;
|
|
29
|
+
for (let i = 1; i <= a.length; i++) {
|
|
30
|
+
for (let j = 1; j <= b.length; j++) {
|
|
31
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
32
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
33
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
34
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return d[a.length][b.length];
|
|
39
|
+
}
|
|
40
|
+
export function suggest(name, known) {
|
|
41
|
+
const needle = name.toLowerCase();
|
|
42
|
+
const limit = Math.min(3, Math.floor(name.length / 3));
|
|
43
|
+
let best;
|
|
44
|
+
for (const candidate of known) {
|
|
45
|
+
const distance = editDistance(needle, candidate.toLowerCase());
|
|
46
|
+
if (distance <= limit && (best === undefined || distance < best.distance))
|
|
47
|
+
best = { name: candidate, distance };
|
|
48
|
+
}
|
|
49
|
+
return best?.name;
|
|
50
|
+
}
|
|
51
|
+
function unknownName(expr, what, name, known) {
|
|
52
|
+
const hint = suggest(name, known);
|
|
53
|
+
return new PasskConfigError(`invalid --expect '${expr}': unknown ${what} '${name}'${hint ? ` — did you mean '${hint}'?` : ""} (known: ${known.join(", ")})`);
|
|
54
|
+
}
|
|
55
|
+
function countShape(path) {
|
|
56
|
+
if (path[0] !== "money")
|
|
57
|
+
return path.length === 2 ? "a count" : undefined;
|
|
58
|
+
return path[path.length - 1] === "count" ? "a count" : "an amount in minor units";
|
|
59
|
+
}
|
|
60
|
+
export function parseExpect(expr) {
|
|
61
|
+
let opIndex = -1;
|
|
62
|
+
let op;
|
|
63
|
+
for (const candidate of OPS) {
|
|
64
|
+
const idx = expr.indexOf(candidate);
|
|
65
|
+
if (idx !== -1 && (opIndex === -1 || idx < opIndex || (idx === opIndex && candidate.length > (op?.length ?? 0)))) {
|
|
66
|
+
opIndex = idx;
|
|
67
|
+
op = candidate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (op === undefined || opIndex <= 0) {
|
|
71
|
+
throw new PasskConfigError(`invalid --expect '${expr}': expected <path> <op> <value> with op one of ${OPS.join(" ")}`);
|
|
72
|
+
}
|
|
73
|
+
const rawPath = expr.slice(0, opIndex).trim();
|
|
74
|
+
const rawValue = expr.slice(opIndex + op.length).trim();
|
|
75
|
+
if (rawValue === "")
|
|
76
|
+
throw new PasskConfigError(`invalid --expect '${expr}': missing value after '${op}'`);
|
|
77
|
+
const path = [];
|
|
78
|
+
for (const segment of rawPath.split(".")) {
|
|
79
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)((?:\[\d+\])*)$/.exec(segment);
|
|
80
|
+
if (!m)
|
|
81
|
+
throw new PasskConfigError(`invalid --expect '${expr}': bad path segment '${segment}'`);
|
|
82
|
+
const indexes = [...m[2].matchAll(/\[(\d+)\]/g)];
|
|
83
|
+
if (indexes.length > 1) {
|
|
84
|
+
throw new PasskConfigError(`invalid --expect '${expr}': '${segment}' takes at most one index — a group is a list of objects, and no object is a list`);
|
|
85
|
+
}
|
|
86
|
+
path.push(m[1]);
|
|
87
|
+
for (const idx of indexes)
|
|
88
|
+
path.push(Number(idx[1]));
|
|
89
|
+
}
|
|
90
|
+
if (path.length < 2 || ![...GROUPS, "money"].includes(String(path[0]))) {
|
|
91
|
+
throw new PasskConfigError(`invalid --expect '${expr}': path must start with created., updated., deleted., or money.`);
|
|
92
|
+
}
|
|
93
|
+
if (path[0] === "money") {
|
|
94
|
+
if (path.length < 3) {
|
|
95
|
+
throw new PasskConfigError(`invalid --expect '${expr}': money paths are money.<currency>.<field> — e.g. money.usd.net_movement or money.usd.refunds_created.count`);
|
|
96
|
+
}
|
|
97
|
+
const currency = path[1];
|
|
98
|
+
if (typeof currency !== "string" || !/^[a-z]{3}$/.test(currency)) {
|
|
99
|
+
throw new PasskConfigError(`invalid --expect '${expr}': money paths are money.<currency>.<field>, with a 3-letter lowercase currency (got '${String(currency)}')`);
|
|
100
|
+
}
|
|
101
|
+
const field = path[2];
|
|
102
|
+
if (typeof field !== "string" || !MONEY_FIELDS.includes(field)) {
|
|
103
|
+
throw unknownName(expr, "money field", String(field ?? ""), MONEY_FIELDS);
|
|
104
|
+
}
|
|
105
|
+
if (field === "net_movement") {
|
|
106
|
+
if (path.length !== 3) {
|
|
107
|
+
throw new PasskConfigError(`invalid --expect '${expr}': money.${currency}.net_movement is a number and takes no sub-field`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
if (path.length === 3) {
|
|
112
|
+
throw new PasskConfigError(`invalid --expect '${expr}': money.${currency}.${field} is {count, amount}: say .count or .amount`);
|
|
113
|
+
}
|
|
114
|
+
const leaf = path[3];
|
|
115
|
+
if (typeof leaf !== "string" || !MONEY_LEAVES.includes(leaf)) {
|
|
116
|
+
throw unknownName(expr, `sub-field of money.${currency}.${field}`, String(leaf), MONEY_LEAVES);
|
|
117
|
+
}
|
|
118
|
+
if (path.length > 4) {
|
|
119
|
+
throw new PasskConfigError(`invalid --expect '${expr}': money.${currency}.${field}.${leaf} is a number and takes no sub-field`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const resource = path[1];
|
|
125
|
+
if (typeof resource !== "string" || !RESOURCE_NAMES.includes(resource)) {
|
|
126
|
+
throw unknownName(expr, "resource", String(resource ?? ""), RESOURCE_NAMES);
|
|
127
|
+
}
|
|
128
|
+
if (path.length > 2 && typeof path[2] !== "number") {
|
|
129
|
+
throw new PasskConfigError(`invalid --expect '${expr}': a field needs an index — ${String(path[0])}.${resource}[0].${String(path[2])}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
let value;
|
|
133
|
+
if (/^-?\d+$/.test(rawValue))
|
|
134
|
+
value = Number(rawValue);
|
|
135
|
+
else if (rawValue === "true")
|
|
136
|
+
value = true;
|
|
137
|
+
else if (rawValue === "false")
|
|
138
|
+
value = false;
|
|
139
|
+
else if (rawValue === "null")
|
|
140
|
+
value = null;
|
|
141
|
+
else if (/^'.*'$/.test(rawValue) || /^".*"$/.test(rawValue))
|
|
142
|
+
value = rawValue.slice(1, -1);
|
|
143
|
+
else
|
|
144
|
+
value = rawValue;
|
|
145
|
+
const shape = countShape(path);
|
|
146
|
+
if (shape !== undefined && typeof value !== "number") {
|
|
147
|
+
throw new PasskConfigError(`invalid --expect '${expr}': ${rawPath} is ${shape} — compare it with an integer (got '${rawValue}')`);
|
|
148
|
+
}
|
|
149
|
+
return { expr, path, op, value };
|
|
150
|
+
}
|
|
151
|
+
export function evaluateExpect(parsed, diff) {
|
|
152
|
+
const root = diff?.[String(parsed.path[0])];
|
|
153
|
+
const absent = root?.[String(parsed.path[1])] === undefined;
|
|
154
|
+
let node = diff;
|
|
155
|
+
for (const segment of parsed.path) {
|
|
156
|
+
if (node === null || node === undefined || typeof node !== "object") {
|
|
157
|
+
node = null;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
node = node[segment] ?? null;
|
|
161
|
+
}
|
|
162
|
+
let actual = Array.isArray(node) ? node.length : node;
|
|
163
|
+
if (absent && actual === null && (parsed.path.length === 2 || parsed.path[0] === "money"))
|
|
164
|
+
actual = 0;
|
|
165
|
+
const missing = actual === null;
|
|
166
|
+
const comparable = parsed.value === null || typeof actual === typeof parsed.value;
|
|
167
|
+
let pass;
|
|
168
|
+
switch (parsed.op) {
|
|
169
|
+
case "==":
|
|
170
|
+
pass = actual === parsed.value;
|
|
171
|
+
break;
|
|
172
|
+
case "!=":
|
|
173
|
+
pass = !missing && comparable && actual !== parsed.value;
|
|
174
|
+
break;
|
|
175
|
+
default: {
|
|
176
|
+
if (typeof actual !== "number" || typeof parsed.value !== "number") {
|
|
177
|
+
pass = false;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
pass =
|
|
181
|
+
parsed.op === ">="
|
|
182
|
+
? actual >= parsed.value
|
|
183
|
+
: parsed.op === "<="
|
|
184
|
+
? actual <= parsed.value
|
|
185
|
+
: parsed.op === ">"
|
|
186
|
+
? actual > parsed.value
|
|
187
|
+
: actual < parsed.value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
expr: parsed.expr,
|
|
192
|
+
pass,
|
|
193
|
+
actual,
|
|
194
|
+
...(absent ? { absent: true } : {}),
|
|
195
|
+
...(missing ? { missing: true } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
package/dist/cli/home.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export const HOME_FALLBACK = "https://usesparta.co";
|
|
5
|
+
export function productHome() {
|
|
6
|
+
const start = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
for (let dir = start;; dir = dirname(dir)) {
|
|
8
|
+
const manifest = join(dir, "package.json");
|
|
9
|
+
if (existsSync(manifest)) {
|
|
10
|
+
const { homepage } = JSON.parse(readFileSync(manifest, "utf8"));
|
|
11
|
+
return typeof homepage === "string" && /^https?:\/\//.test(homepage)
|
|
12
|
+
? homepage.replace(/\/+$/, "")
|
|
13
|
+
: HOME_FALLBACK;
|
|
14
|
+
}
|
|
15
|
+
if (dirname(dir) === dir)
|
|
16
|
+
return HOME_FALLBACK;
|
|
17
|
+
}
|
|
18
|
+
}
|