@gigzen/populace 0.1.0 → 1.0.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 +227 -10
- package/action.yml +145 -0
- package/adapters/buzzbuzz.mjs +1 -1
- package/adapters/template-rest.mjs +16 -0
- package/examples/buzzbuzz/populace-report.html +69 -60
- package/examples/buzzbuzz/populace-report.json +97 -79
- package/examples/buzzbuzz/populace.config.mjs +3 -3
- package/examples/buzzbuzz/run-test.ps1 +1 -1
- package/examples/rest-api/README.md +2 -2
- package/examples/rest-api/adapter.mjs +5 -2
- package/examples/rest-api/populace-report.html +245 -0
- package/examples/rest-api/populace-report.json +280 -0
- package/examples/rest-api/server.mjs +4 -4
- package/package.json +3 -2
- package/src/ai.mjs +158 -0
- package/src/cli.mjs +139 -2
- package/src/engine/personas.mjs +654 -11
- package/src/engine/world.mjs +3 -3
- package/src/explain.mjs +247 -0
- package/src/github-summary.mjs +152 -0
- package/src/openapi.mjs +322 -0
- package/src/report.mjs +37 -0
- package/src/selftest.mjs +251 -0
- package/src/update.mjs +141 -0
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
|
@@ -19,6 +19,10 @@ import { renderSmoke, smoke, smokePersona } from "./smoke.mjs";
|
|
|
19
19
|
import { PACKAGE_NAME, VERSION } from "./version.mjs";
|
|
20
20
|
import { World } from "./engine/world.mjs";
|
|
21
21
|
import { buildPersonas, CITIES } from "./engine/personas.mjs";
|
|
22
|
+
import * as openapi from "./openapi.mjs";
|
|
23
|
+
import { explainReport, verdictLine } from "./explain.mjs";
|
|
24
|
+
import { explainWithAI, isConfigured } from "./ai.mjs";
|
|
25
|
+
import { updateCommand, updateNotice } from "./update.mjs";
|
|
22
26
|
import { Agent } from "./engine/agent.mjs";
|
|
23
27
|
|
|
24
28
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -49,6 +53,7 @@ function overridesFromFlags() {
|
|
|
49
53
|
if (flag("minutes")) o.minutes = Number(flag("minutes"));
|
|
50
54
|
if (flag("tick")) o.tickSeconds = Number(flag("tick"));
|
|
51
55
|
if (flag("cities")) o.cities = String(flag("cities")).split(",").map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
if (flag("engagement")) o.engagement = Number(flag("engagement"));
|
|
52
57
|
if (flag("report")) o.reportPath = flag("report");
|
|
53
58
|
return o;
|
|
54
59
|
}
|
|
@@ -90,7 +95,20 @@ async function init() {
|
|
|
90
95
|
|
|
91
96
|
fs.mkdirSync(adapterDir, { recursive: true });
|
|
92
97
|
fs.copyFileSync(path.join(here, "..", "populace.config.example.mjs"), configFile);
|
|
93
|
-
|
|
98
|
+
|
|
99
|
+
// --from-openapi fills the template's paths in from an API description. The
|
|
100
|
+
// adapter is still a draft afterwards; the point is to remove the half hour
|
|
101
|
+
// of looking up thirteen endpoints by hand, not to finish the job.
|
|
102
|
+
const specPath = flag("from-openapi", null);
|
|
103
|
+
let matched = null;
|
|
104
|
+
if (specPath && !blank) {
|
|
105
|
+
const doc = openapi.load(specPath);
|
|
106
|
+
const { operationCount, results } = openapi.match(doc);
|
|
107
|
+
const template = fs.readFileSync(path.join(here, "..", "adapters", templateName), "utf8");
|
|
108
|
+
const { source, applied } = openapi.fill(template, results);
|
|
109
|
+
if (!fs.existsSync(adapterFile) || has("force")) fs.writeFileSync(adapterFile, source);
|
|
110
|
+
matched = { operationCount, results, applied };
|
|
111
|
+
} else if (!fs.existsSync(adapterFile) || has("force")) {
|
|
94
112
|
fs.copyFileSync(path.join(here, "..", "adapters", templateName), adapterFile);
|
|
95
113
|
}
|
|
96
114
|
|
|
@@ -100,6 +118,32 @@ async function init() {
|
|
|
100
118
|
adapters/my-app.mjs ← ${blank ? "an empty contract to fill in" : "a working REST adapter to edit"}
|
|
101
119
|
`);
|
|
102
120
|
|
|
121
|
+
if (matched) {
|
|
122
|
+
const { operationCount, results } = matched;
|
|
123
|
+
const order = { high: 0, medium: 1, low: 2, none: 3 };
|
|
124
|
+
const rows = Object.entries(results).sort((a, b) => order[a[1].confidence] - order[b[1].confidence]);
|
|
125
|
+
const found = rows.filter(([, r]) => r.op).length;
|
|
126
|
+
|
|
127
|
+
console.log(` Read ${operationCount} operations from ${path.basename(specPath)} and matched ${found} of 13.
|
|
128
|
+
`);
|
|
129
|
+
for (const [method, r] of rows) {
|
|
130
|
+
const mark = { high: "✔", medium: "~", low: "?", none: "✖" }[r.confidence];
|
|
131
|
+
const where = r.op ? `${r.op.verb.toUpperCase()} ${r.op.path}` : "left as the template default";
|
|
132
|
+
console.log(` ${mark} ${method.padEnd(21)} ${where}`);
|
|
133
|
+
if (r.confidence === "low" || r.confidence === "none") {
|
|
134
|
+
console.log(` ${r.why[0]} — check this one`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
console.log(`
|
|
138
|
+
These are guesses from endpoint names. Request bodies and field names are
|
|
139
|
+
still the template's defaults, so the adapter is a draft, not finished.
|
|
140
|
+
|
|
141
|
+
Next: populace smoke ← exercises each method once and names the first
|
|
142
|
+
one that is wrong
|
|
143
|
+
`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
103
147
|
if (blank) {
|
|
104
148
|
console.log(` Every method throws until you write it. Start with createUser and
|
|
105
149
|
deleteUser — those two are required.
|
|
@@ -241,6 +285,12 @@ async function run() {
|
|
|
241
285
|
console.log(` Report: ${displayPath(files.json)}`);
|
|
242
286
|
console.log(` Shareable page: ${displayPath(files.html)}\n`);
|
|
243
287
|
|
|
288
|
+
// After the report a person is already reading, never before it, and at most
|
|
289
|
+
// once a day. Silent if the registry cannot be reached — a version check is
|
|
290
|
+
// never worth an error message at the end of a successful run.
|
|
291
|
+
const notice = await updateNotice();
|
|
292
|
+
if (notice) console.log(`${notice}\n`);
|
|
293
|
+
|
|
244
294
|
if (report.verdict.status !== "clean") process.exitCode = 1;
|
|
245
295
|
}
|
|
246
296
|
|
|
@@ -544,7 +594,91 @@ async function version() {
|
|
|
544
594
|
console.log(`${PACKAGE_NAME} ${VERSION} · node ${process.version} · ${process.platform}`);
|
|
545
595
|
}
|
|
546
596
|
|
|
547
|
-
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------- explain
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Explain a report's failures: rules first, then the model for the remainder.
|
|
602
|
+
*
|
|
603
|
+
* A separate command rather than part of `run` on purpose. A run should not
|
|
604
|
+
* wait on a network call to something else, and it should never fail because
|
|
605
|
+
* an API key was wrong. The report is complete without this.
|
|
606
|
+
*/
|
|
607
|
+
async function explainCmd() {
|
|
608
|
+
const reportPath = flag("file", flag("report", "populace-report.json"));
|
|
609
|
+
let report;
|
|
610
|
+
try {
|
|
611
|
+
report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
|
612
|
+
} catch (error) {
|
|
613
|
+
console.log(`
|
|
614
|
+
No readable report at ${displayPath(reportPath)} — ${error.message}
|
|
615
|
+
`);
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const explained = explainReport(report);
|
|
620
|
+
if (!explained.length) {
|
|
621
|
+
console.log(`
|
|
622
|
+
Nothing failed in ${displayPath(reportPath)}. Nothing to explain.
|
|
623
|
+
`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const BLAME = { app: "YOUR APP", environment: "THE PLATFORM", harness: "THE TEST CLIENT", unknown: "UNKNOWN" };
|
|
628
|
+
const show = (e) => {
|
|
629
|
+
const tag = e.source === "model" ? " · explained by the model" : "";
|
|
630
|
+
console.log(`
|
|
631
|
+
[${BLAME[e.blame]}] ${e.method} × ${e.count ?? 1}${tag}`);
|
|
632
|
+
console.log(` ${e.headline}`);
|
|
633
|
+
console.log(` ${e.why}`);
|
|
634
|
+
if (e.fix) console.log(` Fix: ${e.fix}`);
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
console.log(`
|
|
638
|
+
${verdictLine(explained)}`);
|
|
639
|
+
const named = explained.filter((e) => e.rule !== "none");
|
|
640
|
+
const unnamed = explained.filter((e) => e.rule === "none");
|
|
641
|
+
for (const e of named) show(e);
|
|
642
|
+
|
|
643
|
+
if (!unnamed.length) {
|
|
644
|
+
console.log(`
|
|
645
|
+
Every failure matched a rule. The model was not needed.
|
|
646
|
+
`);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
console.log(`
|
|
651
|
+
${unnamed.length} failure${unnamed.length === 1 ? "" : "s"} matched no rule.`);
|
|
652
|
+
|
|
653
|
+
const { config } = await open().catch(() => ({ config: null }));
|
|
654
|
+
if (!isConfigured(config)) {
|
|
655
|
+
console.log(`
|
|
656
|
+
Set ANTHROPIC_API_KEY to have the model explain these. It is sent the method
|
|
657
|
+
name, the error text, how many times it happened and that method's latency —
|
|
658
|
+
and nothing else. Run with --verbose to see exactly what leaves the machine.
|
|
659
|
+
`);
|
|
660
|
+
for (const e of unnamed) show(e);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
console.log(` Asking the model…`);
|
|
665
|
+
const ai = await explainWithAI(
|
|
666
|
+
unnamed.map((e) => {
|
|
667
|
+
const m = (report.api?.methods || []).find((x) => x.method === e.method);
|
|
668
|
+
return { ...e, p50: m?.latencyMs?.p50, p95: m?.latencyMs?.p95 };
|
|
669
|
+
}),
|
|
670
|
+
{ config, verbose: has("verbose") },
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const byMethod = new Map(ai.map((e) => [e.method, e]));
|
|
674
|
+
for (const e of unnamed) {
|
|
675
|
+
const better = byMethod.get(e.method);
|
|
676
|
+
show(better ? { ...e, ...better } : e);
|
|
677
|
+
}
|
|
678
|
+
console.log("");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const commands = { init, doctor, run, clean, demo, report, version, smoke: smokeCmd, explain: explainCmd, update: updateCommand };
|
|
548
682
|
|
|
549
683
|
// `--version` and `-v` are what people actually type.
|
|
550
684
|
if (has("version") || argv[0] === "-v") {
|
|
@@ -563,6 +697,8 @@ if (!commands[command]) {
|
|
|
563
697
|
populace run bring the population to life
|
|
564
698
|
populace clean delete accounts a run created
|
|
565
699
|
populace report re-open the report from an earlier run
|
|
700
|
+
populace explain say what each failure means and how to fix it
|
|
701
|
+
populace update check whether a newer Populace is out
|
|
566
702
|
populace version print version and environment
|
|
567
703
|
|
|
568
704
|
Options
|
|
@@ -570,6 +706,7 @@ if (!commands[command]) {
|
|
|
570
706
|
--agents <n> --minutes <n> override the config
|
|
571
707
|
--tick <seconds> simulated seconds per step
|
|
572
708
|
--cities <a,b> ${Object.keys(CITIES).join(", ")}
|
|
709
|
+
--engagement <x> how busy people are; 1 = normal, 5 = relentless
|
|
573
710
|
--report <path> where to write the report
|
|
574
711
|
--keep leave accounts in place after a run
|
|
575
712
|
--file <path> which report to re-open (report)
|