@gigzen/populace 0.1.0 → 1.1.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/README.md +231 -10
- package/action.yml +145 -0
- package/adapters/buzzbuzz.mjs +1 -1
- package/adapters/template-rest.mjs +16 -0
- package/examples/buzzbuzz/populace.config.mjs +3 -3
- package/examples/buzzbuzz-local/populace.config.mjs +63 -0
- package/examples/rest-api/README.md +2 -2
- package/examples/rest-api/adapter.mjs +5 -2
- package/examples/rest-api/server.mjs +4 -4
- package/package.json +6 -2
- package/src/ai.mjs +158 -0
- package/src/cli.mjs +192 -2
- package/src/config.mjs +18 -2
- package/src/engine/personas.mjs +654 -11
- package/src/engine/world.mjs +40 -12
- package/src/explain.mjs +247 -0
- package/src/github-summary.mjs +152 -0
- package/src/openapi.mjs +322 -0
- package/src/progress.mjs +141 -0
- package/src/report.mjs +37 -0
- package/src/selftest.mjs +323 -0
- package/src/update.mjs +141 -0
- package/examples/buzzbuzz/populace-report.html +0 -245
- package/examples/buzzbuzz/populace-report.json +0 -280
- package/examples/buzzbuzz/run-test.ps1 +0 -61
- package/examples/demo/populace-report.html +0 -230
- package/examples/demo/populace-report.json +0 -219
package/src/ai.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The model layer: explanations for failures no rule could name.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately the SECOND thing tried, never the first. src/explain.mjs handles
|
|
4
|
+
// the recognisable shapes — permissions, foreign keys, quotas, sockets — with
|
|
5
|
+
// rules that are instant, free, offline and auditable. This is for the
|
|
6
|
+
// remainder, and it only ever sees the remainder.
|
|
7
|
+
//
|
|
8
|
+
// Raw fetch rather than @anthropic-ai/sdk, and that is a deliberate trade. The
|
|
9
|
+
// SDK is the better tool in almost any other project; here it would be
|
|
10
|
+
// Populace's first runtime dependency, and "zero dependencies" is not a
|
|
11
|
+
// slogan — it is why `npx @gigzen/populace` works with nothing to install.
|
|
12
|
+
// One POST is a fair price for keeping that true.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IS SENT, exactly:
|
|
15
|
+
// · the method name e.g. "sendMessage"
|
|
16
|
+
// · the error message e.g. "TypeError: fetch failed"
|
|
17
|
+
// · how many times e.g. 17
|
|
18
|
+
// · p50/p95 for that method
|
|
19
|
+
// Nothing else. No target URL, no keys, no tokens, no request bodies, no
|
|
20
|
+
// simulated users' content. Everything sent is printed first when --verbose is
|
|
21
|
+
// on, because a tool that quietly ships your data somewhere is not one you
|
|
22
|
+
// should trust with your staging credentials.
|
|
23
|
+
|
|
24
|
+
const ENDPOINT = "https://api.anthropic.com/v1/messages";
|
|
25
|
+
const MODEL = "claude-opus-5";
|
|
26
|
+
const VERSION = "2023-06-01";
|
|
27
|
+
|
|
28
|
+
/** The key, from the environment or the config. Never written to disk by us. */
|
|
29
|
+
export function apiKey(config) {
|
|
30
|
+
return process.env.ANTHROPIC_API_KEY || config?.ai?.apiKey || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const isConfigured = (config) => Boolean(apiKey(config));
|
|
34
|
+
|
|
35
|
+
const SYSTEM = `You explain failures from a load-testing tool called Populace, which drives an
|
|
36
|
+
application through its own API as a population of simulated users.
|
|
37
|
+
|
|
38
|
+
You are the fallback. A rule engine already handled every failure it recognised —
|
|
39
|
+
row-level security, foreign keys, expired sessions, provider rate limits, socket
|
|
40
|
+
errors. What reaches you is what it could not name.
|
|
41
|
+
|
|
42
|
+
For each failure, answer three things:
|
|
43
|
+
headline one sentence: what happened. No preamble.
|
|
44
|
+
why two or three sentences: the mechanism. What would produce this?
|
|
45
|
+
fix one or two sentences: what to change. Concrete.
|
|
46
|
+
blame exactly one of: app | environment | harness | unknown
|
|
47
|
+
app the application under test is at fault
|
|
48
|
+
environment the platform or provider refused it (quotas, limits)
|
|
49
|
+
harness it never reached the server (sockets, DNS, the client)
|
|
50
|
+
unknown you genuinely cannot tell
|
|
51
|
+
|
|
52
|
+
Rules you must follow:
|
|
53
|
+
- If you cannot tell, say so and use blame "unknown". A confident wrong cause
|
|
54
|
+
costs more to debug than an honest blank.
|
|
55
|
+
- Never invent a status code, table name, policy name or line number that was
|
|
56
|
+
not in the input.
|
|
57
|
+
- Prefer the boring explanation. Under concurrency, most failures are
|
|
58
|
+
contention, permissions, or a resource limit — not exotic.
|
|
59
|
+
|
|
60
|
+
Reply with JSON only, no prose around it:
|
|
61
|
+
{"explanations":[{"method":"...","headline":"...","why":"...","fix":"...","blame":"..."}]}`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ask the model about failures the rules could not name.
|
|
65
|
+
*
|
|
66
|
+
* Returns [] on any problem — a missing key, a refusal, a network error, a
|
|
67
|
+
* malformed reply. This is an enhancement to a report that is already complete
|
|
68
|
+
* and useful; it must never be the reason a run fails or a report is not
|
|
69
|
+
* written.
|
|
70
|
+
*/
|
|
71
|
+
export async function explainWithAI(unexplained, { config, verbose = false, timeoutMs = 30000 } = {}) {
|
|
72
|
+
const key = apiKey(config);
|
|
73
|
+
if (!key || !unexplained?.length) return [];
|
|
74
|
+
|
|
75
|
+
const payload = unexplained.map((e) => ({
|
|
76
|
+
method: e.method,
|
|
77
|
+
error: String(e.message ?? "").slice(0, 500),
|
|
78
|
+
occurrences: e.count ?? 1,
|
|
79
|
+
p50ms: e.p50 ?? null,
|
|
80
|
+
p95ms: e.p95 ?? null,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
if (verbose) {
|
|
84
|
+
console.log("\n Sending to the model — this is everything, nothing else leaves this machine:");
|
|
85
|
+
console.log(JSON.stringify(payload, null, 2).split("\n").map((l) => ` ${l}`).join("\n"));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(ENDPOINT, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
headers: {
|
|
96
|
+
"content-type": "application/json",
|
|
97
|
+
"x-api-key": key,
|
|
98
|
+
"anthropic-version": VERSION,
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
model: MODEL,
|
|
102
|
+
max_tokens: 2000,
|
|
103
|
+
// Effort low: this is a small, bounded task on a handful of short
|
|
104
|
+
// strings, and a CLI that pauses for twenty seconds to explain an
|
|
105
|
+
// error is a CLI people turn off.
|
|
106
|
+
output_config: { effort: "low" },
|
|
107
|
+
system: SYSTEM,
|
|
108
|
+
messages: [{
|
|
109
|
+
role: "user",
|
|
110
|
+
content:
|
|
111
|
+
`Failures a rule engine could not classify:\n\n${JSON.stringify(payload, null, 2)}\n\n` +
|
|
112
|
+
`Explain each one.`,
|
|
113
|
+
}],
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const body = await res.text().catch(() => "");
|
|
119
|
+
return fail(res.status === 401
|
|
120
|
+
? "the API key was rejected"
|
|
121
|
+
: res.status === 429
|
|
122
|
+
? "rate limited by the API"
|
|
123
|
+
: `the API returned ${res.status}${body ? `: ${body.slice(0, 160)}` : ""}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
|
|
128
|
+
// A safety refusal is a normal outcome, not a crash.
|
|
129
|
+
if (data.stop_reason === "refusal") return fail("the model declined to answer");
|
|
130
|
+
|
|
131
|
+
const text = (data.content || []).filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
132
|
+
const json = text.match(/\{[\s\S]*\}/);
|
|
133
|
+
if (!json) return fail("the reply was not JSON");
|
|
134
|
+
|
|
135
|
+
const parsed = JSON.parse(json[0]);
|
|
136
|
+
const BLAME = new Set(["app", "environment", "harness", "unknown"]);
|
|
137
|
+
|
|
138
|
+
return (parsed.explanations || []).map((e) => ({
|
|
139
|
+
method: String(e.method ?? "unknown"),
|
|
140
|
+
headline: String(e.headline ?? "").trim(),
|
|
141
|
+
why: String(e.why ?? "").trim(),
|
|
142
|
+
fix: e.fix ? String(e.fix).trim() : undefined,
|
|
143
|
+
// An unrecognised blame value becomes "unknown" rather than being trusted.
|
|
144
|
+
blame: BLAME.has(e.blame) ? e.blame : "unknown",
|
|
145
|
+
rule: "model",
|
|
146
|
+
source: "model",
|
|
147
|
+
})).filter((e) => e.headline);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return fail(error.name === "AbortError" ? `no reply within ${timeoutMs / 1000}s` : error.message);
|
|
150
|
+
} finally {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fail(why) {
|
|
155
|
+
console.log(` (the model layer was skipped — ${why})`);
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
import { ConfigError, loadAdapter, loadConfig } from "./config.mjs";
|
|
13
13
|
import { createMetrics, instrument } from "./instrument.mjs";
|
|
14
14
|
import { buildReport, renderReport, writeReport } from "./report.mjs";
|
|
15
|
+
import { createProgress } from "./progress.mjs";
|
|
15
16
|
import { canSignInOnly } from "./contract.mjs";
|
|
16
17
|
import { isTransportError } from "./net.mjs";
|
|
17
18
|
import { diagnose } from "./diagnose.mjs";
|
|
@@ -19,6 +20,10 @@ import { renderSmoke, smoke, smokePersona } from "./smoke.mjs";
|
|
|
19
20
|
import { PACKAGE_NAME, VERSION } from "./version.mjs";
|
|
20
21
|
import { World } from "./engine/world.mjs";
|
|
21
22
|
import { buildPersonas, CITIES } from "./engine/personas.mjs";
|
|
23
|
+
import * as openapi from "./openapi.mjs";
|
|
24
|
+
import { explainReport, verdictLine } from "./explain.mjs";
|
|
25
|
+
import { explainWithAI, isConfigured } from "./ai.mjs";
|
|
26
|
+
import { updateCommand, updateNotice } from "./update.mjs";
|
|
22
27
|
import { Agent } from "./engine/agent.mjs";
|
|
23
28
|
|
|
24
29
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -49,7 +54,9 @@ function overridesFromFlags() {
|
|
|
49
54
|
if (flag("minutes")) o.minutes = Number(flag("minutes"));
|
|
50
55
|
if (flag("tick")) o.tickSeconds = Number(flag("tick"));
|
|
51
56
|
if (flag("cities")) o.cities = String(flag("cities")).split(",").map((s) => s.trim()).filter(Boolean);
|
|
57
|
+
if (flag("engagement")) o.engagement = Number(flag("engagement"));
|
|
52
58
|
if (flag("report")) o.reportPath = flag("report");
|
|
59
|
+
if (flag("stagger")) o.signupStaggerMs = Number(flag("stagger"));
|
|
53
60
|
return o;
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -90,7 +97,20 @@ async function init() {
|
|
|
90
97
|
|
|
91
98
|
fs.mkdirSync(adapterDir, { recursive: true });
|
|
92
99
|
fs.copyFileSync(path.join(here, "..", "populace.config.example.mjs"), configFile);
|
|
93
|
-
|
|
100
|
+
|
|
101
|
+
// --from-openapi fills the template's paths in from an API description. The
|
|
102
|
+
// adapter is still a draft afterwards; the point is to remove the half hour
|
|
103
|
+
// of looking up thirteen endpoints by hand, not to finish the job.
|
|
104
|
+
const specPath = flag("from-openapi", null);
|
|
105
|
+
let matched = null;
|
|
106
|
+
if (specPath && !blank) {
|
|
107
|
+
const doc = openapi.load(specPath);
|
|
108
|
+
const { operationCount, results } = openapi.match(doc);
|
|
109
|
+
const template = fs.readFileSync(path.join(here, "..", "adapters", templateName), "utf8");
|
|
110
|
+
const { source, applied } = openapi.fill(template, results);
|
|
111
|
+
if (!fs.existsSync(adapterFile) || has("force")) fs.writeFileSync(adapterFile, source);
|
|
112
|
+
matched = { operationCount, results, applied };
|
|
113
|
+
} else if (!fs.existsSync(adapterFile) || has("force")) {
|
|
94
114
|
fs.copyFileSync(path.join(here, "..", "adapters", templateName), adapterFile);
|
|
95
115
|
}
|
|
96
116
|
|
|
@@ -100,6 +120,32 @@ async function init() {
|
|
|
100
120
|
adapters/my-app.mjs ← ${blank ? "an empty contract to fill in" : "a working REST adapter to edit"}
|
|
101
121
|
`);
|
|
102
122
|
|
|
123
|
+
if (matched) {
|
|
124
|
+
const { operationCount, results } = matched;
|
|
125
|
+
const order = { high: 0, medium: 1, low: 2, none: 3 };
|
|
126
|
+
const rows = Object.entries(results).sort((a, b) => order[a[1].confidence] - order[b[1].confidence]);
|
|
127
|
+
const found = rows.filter(([, r]) => r.op).length;
|
|
128
|
+
|
|
129
|
+
console.log(` Read ${operationCount} operations from ${path.basename(specPath)} and matched ${found} of 13.
|
|
130
|
+
`);
|
|
131
|
+
for (const [method, r] of rows) {
|
|
132
|
+
const mark = { high: "✔", medium: "~", low: "?", none: "✖" }[r.confidence];
|
|
133
|
+
const where = r.op ? `${r.op.verb.toUpperCase()} ${r.op.path}` : "left as the template default";
|
|
134
|
+
console.log(` ${mark} ${method.padEnd(21)} ${where}`);
|
|
135
|
+
if (r.confidence === "low" || r.confidence === "none") {
|
|
136
|
+
console.log(` ${r.why[0]} — check this one`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
console.log(`
|
|
140
|
+
These are guesses from endpoint names. Request bodies and field names are
|
|
141
|
+
still the template's defaults, so the adapter is a draft, not finished.
|
|
142
|
+
|
|
143
|
+
Next: populace smoke ← exercises each method once and names the first
|
|
144
|
+
one that is wrong
|
|
145
|
+
`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
103
149
|
if (blank) {
|
|
104
150
|
console.log(` Every method throws until you write it. Start with createUser and
|
|
105
151
|
deleteUser — those two are required.
|
|
@@ -143,6 +189,28 @@ async function doctor() {
|
|
|
143
189
|
|
|
144
190
|
const d = diagnose({ config, adapter: raw, reachable });
|
|
145
191
|
|
|
192
|
+
// --json for anything that has to describe a config without printing it -
|
|
193
|
+
// the desktop app shows this on the Run screen, so you can see what you are
|
|
194
|
+
// about to point a population at before you start one.
|
|
195
|
+
if (has("json")) {
|
|
196
|
+
console.log(JSON.stringify({
|
|
197
|
+
app: config.app || null,
|
|
198
|
+
environment: config.environment,
|
|
199
|
+
adapter: config.adapter,
|
|
200
|
+
target: config.target?.url || null,
|
|
201
|
+
coverage: d.coverage,
|
|
202
|
+
guarded: d.guarded,
|
|
203
|
+
cleanup: d.cleanup,
|
|
204
|
+
reachable,
|
|
205
|
+
reachError: reachable === false ? reachError : null,
|
|
206
|
+
ready: d.ready,
|
|
207
|
+
blockers: d.blockers,
|
|
208
|
+
population: config.population,
|
|
209
|
+
}));
|
|
210
|
+
if (!d.ready) process.exitCode = 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
146
214
|
console.log(`
|
|
147
215
|
Config ${path.basename(config._file)}`);
|
|
148
216
|
console.log(` App ${config.app || raw.name || "(unnamed)"}`);
|
|
@@ -187,13 +255,26 @@ async function run() {
|
|
|
187
255
|
const startedAt = Date.now();
|
|
188
256
|
|
|
189
257
|
const { agents, cities, minutes, tickSeconds } = config.population;
|
|
258
|
+
|
|
259
|
+
// Anything watching this run rather than reading it - the desktop app today,
|
|
260
|
+
// a dashboard tomorrow - gets the same numbers the terminal table shows,
|
|
261
|
+
// every tick, as JSON. Off unless asked, so ordinary output is untouched.
|
|
262
|
+
const progress = createProgress({ enabled: flag("progress") === "json" });
|
|
263
|
+
progress.start(config);
|
|
264
|
+
|
|
190
265
|
console.log(`\n Bringing ${agents} people to life across ${cities.join(", ")}…\n`);
|
|
191
266
|
|
|
192
267
|
const world = World.fromConfig(config, adapter, {
|
|
193
268
|
joined: (a) => console.log(` ✓ ${a.persona.name} (${a.persona.city.name}, ${a.persona.platform})`),
|
|
194
269
|
joinFailed: (p, e) => console.log(` ✖ ${p.name}: ${e.message}`),
|
|
270
|
+
// Said out loud, because waiting silently is indistinguishable from hanging.
|
|
271
|
+
// Retrying a throttled sign-up can add seconds per person, and a run that
|
|
272
|
+
// pauses without explanation is a run somebody kills.
|
|
273
|
+
joinThrottled: (p, attempt) =>
|
|
274
|
+
console.log(` … ${p.name}: rate limited, waiting (attempt ${attempt})`),
|
|
195
275
|
tick: (n, total, w) => {
|
|
196
276
|
render(config, n, total, w);
|
|
277
|
+
progress.tick(n, total, w, metrics);
|
|
197
278
|
// Stop as soon as the target is judged gone. Grinding out the remaining
|
|
198
279
|
// ticks against a dead host wastes the operator's time and adds nothing
|
|
199
280
|
// to the report.
|
|
@@ -236,11 +317,18 @@ async function run() {
|
|
|
236
317
|
|
|
237
318
|
metrics.endedAt = Date.now();
|
|
238
319
|
const report = buildReport({ config, adapter: raw, world, metrics, teardown, startedAt });
|
|
320
|
+
progress.done(report);
|
|
239
321
|
const files = writeReport(report, config);
|
|
240
322
|
console.log(renderReport(report));
|
|
241
323
|
console.log(` Report: ${displayPath(files.json)}`);
|
|
242
324
|
console.log(` Shareable page: ${displayPath(files.html)}\n`);
|
|
243
325
|
|
|
326
|
+
// After the report a person is already reading, never before it, and at most
|
|
327
|
+
// once a day. Silent if the registry cannot be reached — a version check is
|
|
328
|
+
// never worth an error message at the end of a successful run.
|
|
329
|
+
const notice = await updateNotice();
|
|
330
|
+
if (notice) console.log(`${notice}\n`);
|
|
331
|
+
|
|
244
332
|
if (report.verdict.status !== "clean") process.exitCode = 1;
|
|
245
333
|
}
|
|
246
334
|
|
|
@@ -544,7 +632,104 @@ async function version() {
|
|
|
544
632
|
console.log(`${PACKAGE_NAME} ${VERSION} · node ${process.version} · ${process.platform}`);
|
|
545
633
|
}
|
|
546
634
|
|
|
547
|
-
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------- explain
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Explain a report's failures: rules first, then the model for the remainder.
|
|
640
|
+
*
|
|
641
|
+
* A separate command rather than part of `run` on purpose. A run should not
|
|
642
|
+
* wait on a network call to something else, and it should never fail because
|
|
643
|
+
* an API key was wrong. The report is complete without this.
|
|
644
|
+
*/
|
|
645
|
+
async function explainCmd() {
|
|
646
|
+
const reportPath = flag("file", flag("report", "populace-report.json"));
|
|
647
|
+
let report;
|
|
648
|
+
try {
|
|
649
|
+
report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.log(`
|
|
652
|
+
No readable report at ${displayPath(reportPath)} — ${error.message}
|
|
653
|
+
`);
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const explained = explainReport(report);
|
|
658
|
+
|
|
659
|
+
// --json for anything rendering this itself rather than reading a terminal.
|
|
660
|
+
// Same explanations, same order; only the presentation differs, so a window
|
|
661
|
+
// cannot show a cause the command line would not.
|
|
662
|
+
if (has("json")) {
|
|
663
|
+
console.log(JSON.stringify({
|
|
664
|
+
report: reportPath,
|
|
665
|
+
verdict: explained.length ? verdictLine(explained) : null,
|
|
666
|
+
explained,
|
|
667
|
+
}));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (!explained.length) {
|
|
672
|
+
console.log(`
|
|
673
|
+
Nothing failed in ${displayPath(reportPath)}. Nothing to explain.
|
|
674
|
+
`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const BLAME = { app: "YOUR APP", environment: "THE PLATFORM", harness: "THE TEST CLIENT", unknown: "UNKNOWN" };
|
|
679
|
+
const show = (e) => {
|
|
680
|
+
const tag = e.source === "model" ? " · explained by the model" : "";
|
|
681
|
+
console.log(`
|
|
682
|
+
[${BLAME[e.blame]}] ${e.method} × ${e.count ?? 1}${tag}`);
|
|
683
|
+
console.log(` ${e.headline}`);
|
|
684
|
+
console.log(` ${e.why}`);
|
|
685
|
+
if (e.fix) console.log(` Fix: ${e.fix}`);
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
console.log(`
|
|
689
|
+
${verdictLine(explained)}`);
|
|
690
|
+
const named = explained.filter((e) => e.rule !== "none");
|
|
691
|
+
const unnamed = explained.filter((e) => e.rule === "none");
|
|
692
|
+
for (const e of named) show(e);
|
|
693
|
+
|
|
694
|
+
if (!unnamed.length) {
|
|
695
|
+
console.log(`
|
|
696
|
+
Every failure matched a rule. The model was not needed.
|
|
697
|
+
`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
console.log(`
|
|
702
|
+
${unnamed.length} failure${unnamed.length === 1 ? "" : "s"} matched no rule.`);
|
|
703
|
+
|
|
704
|
+
const { config } = await open().catch(() => ({ config: null }));
|
|
705
|
+
if (!isConfigured(config)) {
|
|
706
|
+
console.log(`
|
|
707
|
+
Set ANTHROPIC_API_KEY to have the model explain these. It is sent the method
|
|
708
|
+
name, the error text, how many times it happened and that method's latency —
|
|
709
|
+
and nothing else. Run with --verbose to see exactly what leaves the machine.
|
|
710
|
+
`);
|
|
711
|
+
for (const e of unnamed) show(e);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
console.log(` Asking the model…`);
|
|
716
|
+
const ai = await explainWithAI(
|
|
717
|
+
unnamed.map((e) => {
|
|
718
|
+
const m = (report.api?.methods || []).find((x) => x.method === e.method);
|
|
719
|
+
return { ...e, p50: m?.latencyMs?.p50, p95: m?.latencyMs?.p95 };
|
|
720
|
+
}),
|
|
721
|
+
{ config, verbose: has("verbose") },
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
const byMethod = new Map(ai.map((e) => [e.method, e]));
|
|
725
|
+
for (const e of unnamed) {
|
|
726
|
+
const better = byMethod.get(e.method);
|
|
727
|
+
show(better ? { ...e, ...better } : e);
|
|
728
|
+
}
|
|
729
|
+
console.log("");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const commands = { init, doctor, run, clean, demo, report, version, smoke: smokeCmd, explain: explainCmd, update: updateCommand };
|
|
548
733
|
|
|
549
734
|
// `--version` and `-v` are what people actually type.
|
|
550
735
|
if (has("version") || argv[0] === "-v") {
|
|
@@ -563,6 +748,8 @@ if (!commands[command]) {
|
|
|
563
748
|
populace run bring the population to life
|
|
564
749
|
populace clean delete accounts a run created
|
|
565
750
|
populace report re-open the report from an earlier run
|
|
751
|
+
populace explain say what each failure means and how to fix it
|
|
752
|
+
populace update check whether a newer Populace is out
|
|
566
753
|
populace version print version and environment
|
|
567
754
|
|
|
568
755
|
Options
|
|
@@ -570,6 +757,9 @@ if (!commands[command]) {
|
|
|
570
757
|
--agents <n> --minutes <n> override the config
|
|
571
758
|
--tick <seconds> simulated seconds per step
|
|
572
759
|
--cities <a,b> ${Object.keys(CITIES).join(", ")}
|
|
760
|
+
--engagement <x> how busy people are; 1 = normal, 5 = relentless
|
|
761
|
+
--stagger <ms> gap between sign-ups; raise it for a
|
|
762
|
+
throttled auth endpoint (default 400)
|
|
573
763
|
--report <path> where to write the report
|
|
574
764
|
--keep leave accounts in place after a run
|
|
575
765
|
--file <path> which report to re-open (report)
|
package/src/config.mjs
CHANGED
|
@@ -36,6 +36,19 @@ const DEFAULTS = {
|
|
|
36
36
|
// Consecutive unreachable calls before Populace declares the target down and
|
|
37
37
|
// stops, instead of retrying every call for the rest of the run. 0 disables.
|
|
38
38
|
giveUpAfter: 12,
|
|
39
|
+
// Gap between sign-ups. Auth endpoints are throttled far harder than the rest
|
|
40
|
+
// of an API — Supabase's default is 30 sign-ups per five minutes per address,
|
|
41
|
+
// and 400ms is 2.5 per second. That default is right for real users, who each
|
|
42
|
+
// arrive from their own address, and impossible for a simulation, where every
|
|
43
|
+
// request shares one. Raise this to fit a target you do not control; a hosted
|
|
44
|
+
// Supabase project needs about 10_000.
|
|
45
|
+
signupStaggerMs: 400,
|
|
46
|
+
// When a sign-up is refused *for being too fast*, wait and try that person
|
|
47
|
+
// again rather than recording them as a failure. A rate limit says nothing
|
|
48
|
+
// about the application under test, so counting it as a finding is a lie.
|
|
49
|
+
// Backoff is this value times the attempt number. 0 disables retrying.
|
|
50
|
+
signupRateLimitBackoffMs: 5_000,
|
|
51
|
+
signupRateLimitRetries: 2,
|
|
39
52
|
population: { agents: 6, cities: ["manila", "mumbai"], tickSeconds: 5, minutes: 10 },
|
|
40
53
|
// Comfortably inside a 1-hour token, which is the common default.
|
|
41
54
|
session: { refreshEveryMinutes: 30 },
|
|
@@ -43,8 +56,8 @@ const DEFAULTS = {
|
|
|
43
56
|
};
|
|
44
57
|
|
|
45
58
|
export async function loadConfig({ configPath, cwd = process.cwd(), overrides = {} } = {}) {
|
|
46
|
-
//
|
|
47
|
-
const { reportPath: _reportPath, ...populationOverrides } = overrides;
|
|
59
|
+
// Neither of these is a population setting; keep them out of that spread.
|
|
60
|
+
const { reportPath: _reportPath, signupStaggerMs: _stagger, ...populationOverrides } = overrides;
|
|
48
61
|
const file = path.resolve(cwd, configPath || "populace.config.mjs");
|
|
49
62
|
|
|
50
63
|
if (!fs.existsSync(file)) {
|
|
@@ -63,6 +76,9 @@ export async function loadConfig({ configPath, cwd = process.cwd(), overrides =
|
|
|
63
76
|
const config = {
|
|
64
77
|
...DEFAULTS,
|
|
65
78
|
...loaded,
|
|
79
|
+
// --stagger wins over the config file, so a run can be paced to fit a target
|
|
80
|
+
// whose rate limit is not yours to change.
|
|
81
|
+
...(overrides.signupStaggerMs ? { signupStaggerMs: overrides.signupStaggerMs } : {}),
|
|
66
82
|
population: {
|
|
67
83
|
...DEFAULTS.population,
|
|
68
84
|
...(loaded.population || {}),
|