@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/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
- if (!fs.existsSync(adapterFile) || has("force")) {
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
- const commands = { init, doctor, run, clean, demo, report, version, smoke: smokeCmd };
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
- // reportPath is not a population setting; keep it out of that spread.
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 || {}),