Skip to content

Commit a595c26

Browse files
authored
Merge pull request #67 from ChainSafe/feat/metrics
feat: metrics and reward handling
2 parents 5aedfa1 + c2b3a2b commit a595c26

16 files changed

Lines changed: 1545 additions & 240 deletions

autobots/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,37 @@ sudo journalctl -fu autobots | grep -E "STATUS|CRITICAL"
146146
{container="autobots"} |~ "CRITICAL|WARN" # anything needing attention
147147
```
148148

149+
## Growth Mode — simulate organic user growth
150+
151+
Instead of a single pre-provisioned `--key`, growth mode mints new Canton parties on-the-fly via the billing portal and runs each one as an independent bot on its own task loop. Both the number of bots and the per-bot task rate ramp up over time following a sqrt curve, producing a decelerating-growth trend suitable for e2e soak testing.
152+
153+
```bash
154+
# Short demo run — 5 users over 6 minutes, 0.2 → 2 tasks/min per bot
155+
node runner.js --growth \
156+
--server https://mcp-dev1.01.chainsafe.dev \
157+
--billing-portal https://billing-dev1.01.chainsafe.dev \
158+
--max-users 5 --ramp-hours 0.1 \
159+
--min-rate-per-min 0.2 --max-rate-per-min 2
160+
```
161+
162+
Generated keyfiles are saved to `./keys/<name-prefix>-<N>.json` and reused on restart, so killing and re-starting the process does not re-mint parties or double the fleet.
163+
164+
| Flag | Default | Purpose |
165+
|------|---------|---------|
166+
| `--growth` | off | Enable growth mode |
167+
| `--billing-portal` | (required) | Portal URL for party registration |
168+
| `--max-users` | 20 | Ramp target (concurrent bots at plateau) |
169+
| `--ramp-hours` | 24 | Hours from 1 bot to `--max-users` bots |
170+
| `--min-rate-per-min` | 0.2 | Per-bot task rate at ramp start |
171+
| `--max-rate-per-min` | 2 | Per-bot task rate at ramp end |
172+
| `--keys-dir` | `./keys` | Where generated keyfiles are persisted |
173+
| `--name-prefix` | `autobot` | Prefix for minted party names |
174+
| `--growth-tick-seconds` | 30 | Orchestrator tick cadence |
175+
176+
Growth-mode `[STATUS]` lines include a `bots=N` segment alongside the existing task stats. Each bot spawn emits `[EVENT] ... party_provisioned` (new mint) or reuses an existing keyfile silently.
177+
178+
> Note: newly minted parties on devnet are not auto-funded (faucet/tap is localnet-only). If your MCP server charges strict CC balance, pre-fund the keys in `./keys/` manually or run against localnet.
179+
149180
## Custom Tasks
150181

151182
Create a JSON file with your own tasks:

autobots/keys/finn_wang.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"partyId": "finn_wang::12209f228b507dbeb27eed4648bfc8eb42aacfad11cc4b73d89803bcf00df11b899c",
3+
"privateKey": "GK8HRsoFbvHlLnuovEQE0PraJYD73VhJ0H+PMofzYuA=",
4+
"publicKey": "R1+QdrBUvTU5Hh3ZVUrwCCnh4BQIWalr9xl9N4XCXBk=",
5+
"fingerprint": "12209f228b507dbeb27eed4648bfc8eb42aacfad11cc4b73d89803bcf00df11b899c"
6+
}

autobots/keys/lars49.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"partyId": "lars49::1220ce0d49ac2416cea749758382c11ee00802635f46f7d70482ad516fc2e716574f",
3+
"privateKey": "ErWfJ67t/iJ8TkWh32dqUWC8Oy3TmEtT8xhSPkeJEWA=",
4+
"publicKey": "oZukI5j2Xj2ltozOmkboiQzVRIv2XbiCx5CYL0rxxg0=",
5+
"fingerprint": "1220ce0d49ac2416cea749758382c11ee00802635f46f7d70482ad516fc2e716574f"
6+
}

autobots/keys/leila84.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"partyId": "leila84::12200a03f84e6c59b5fb9d9838ba46f95b21feae84c8ab5f076a1111d7a08a69bf7c",
3+
"privateKey": "t6uIud+K7Makdf95gR6mrN+e9BEiE3aMNwNO+jKLZhE=",
4+
"publicKey": "mKiyvcIU14QJB0PmNEnGWRJ58W0jQ+3vdnT0YmfD0SQ=",
5+
"fingerprint": "12200a03f84e6c59b5fb9d9838ba46f95b21feae84c8ab5f076a1111d7a08a69bf7c"
6+
}

autobots/keys/neonwolf379.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"partyId": "neonwolf379::12207be2811a8ddb74785206db1ea3f5b4da0eed66df14d13112219a8ce5937035f7",
3+
"privateKey": "xYkS0+StkMHpeL7331OBdAiRn8O++Ms7AKdBNf10KfM=",
4+
"publicKey": "W+730zW5fc69c2aXJYDnEo9xbJhT9ktFAsrOl+XcAys=",
5+
"fingerprint": "12207be2811a8ddb74785206db1ea3f5b4da0eed66df14d13112219a8ce5937035f7"
6+
}

autobots/keys/zara.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"partyId": "zara::1220576dabbeefbc686bafdb196a9739ecb918461c81e2d10d66bcd8153ec435fda7",
3+
"privateKey": "d1rpPEbPGaGqAtQGgWQSO4PCqCva2rkZZ2ieaW3Qps4=",
4+
"publicKey": "1oAtla1RFP0xs9h1MHItYiNFGGChPMtRr/kb8TDqxIA=",
5+
"fingerprint": "1220576dabbeefbc686bafdb196a9739ecb918461c81e2d10d66bcd8153ec435fda7"
6+
}

autobots/names.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Realistic username generation for autobot party hints.
3+
*
4+
* Party IDs look like `<hint>::<fingerprint>` on Canton; the hint is the
5+
* human-readable part a dashboard shows. Rather than `autobot-42`, we want
6+
* hints that look like real usernames — first names, first.last patterns,
7+
* handle + digits — so stakeholder views feel organic.
8+
*
9+
* All hints are lowercased `[a-z0-9_]+` (Canton-safe). No hyphens, so they
10+
* visually distinguish from any pre-existing `autobot-N` keys.
11+
*/
12+
13+
const FIRST_NAMES = [
14+
"alex", "sam", "jamie", "taylor", "jordan", "morgan", "riley", "casey",
15+
"quinn", "avery", "chris", "maya", "priya", "arjun", "raj", "ananya",
16+
"yuki", "ken", "haruto", "mei", "wei", "jun", "lin", "chen",
17+
"sofia", "diego", "luca", "emma", "liam", "noah", "olivia", "ava",
18+
"sarah", "mike", "dave", "jen", "rachel", "ben", "nate", "kate",
19+
"amir", "leila", "omar", "zara", "kwame", "adaeze", "tomas", "anna",
20+
"erik", "lars", "freya", "nora", "finn", "ivy", "eli", "ada",
21+
"hiro", "miko", "sana", "aiko", "ravi", "neha", "vikram", "asha",
22+
];
23+
24+
const LAST_NAMES = [
25+
"smith", "jones", "brown", "taylor", "lee", "wang", "kim", "patel",
26+
"singh", "garcia", "martinez", "lopez", "kumar", "sharma", "tanaka",
27+
"suzuki", "nguyen", "tran", "cohen", "muller", "schmidt", "novak",
28+
"rossi", "bianchi", "silva", "oliveira", "kowalski", "andersson",
29+
"kapoor", "chen", "liu", "zhang", "yamada", "ito", "park", "choi",
30+
];
31+
32+
const HANDLE_WORDS = [
33+
"fox", "wolf", "raven", "kite", "otter", "atlas", "nova", "echo",
34+
"river", "orbit", "pixel", "drift", "spark", "bloom", "loop", "forge",
35+
"neon", "sable", "lumen", "quartz", "flux", "cipher", "nebula",
36+
];
37+
38+
const HANDLE_ADJECTIVES = [
39+
"cool", "silent", "neon", "fast", "lucky", "quiet", "brave", "sunny",
40+
"misty", "shady", "wild", "urban", "retro", "rapid", "clever",
41+
];
42+
43+
function pick(arr, rng) {
44+
return arr[Math.floor(rng() * arr.length)];
45+
}
46+
47+
function maybe(rng, p) {
48+
return rng() < p;
49+
}
50+
51+
function twoDigit(rng) {
52+
// 10..99 — avoid the very common "1"/"2" suffix that looks incremental
53+
return String(10 + Math.floor(rng() * 90));
54+
}
55+
56+
/**
57+
* Generate one realistic username-style party hint.
58+
* Patterns are roughly distributed across common real-world shapes.
59+
*/
60+
export function generateHint(rng = Math.random) {
61+
const pattern = rng();
62+
const first = pick(FIRST_NAMES, rng);
63+
const last = pick(LAST_NAMES, rng);
64+
const lastInitial = last[0];
65+
66+
if (pattern < 0.25) {
67+
// "alex"
68+
return first;
69+
}
70+
if (pattern < 0.5) {
71+
// "alex42"
72+
return `${first}${twoDigit(rng)}`;
73+
}
74+
if (pattern < 0.65) {
75+
// "alex_kim"
76+
return `${first}_${last}`;
77+
}
78+
if (pattern < 0.8) {
79+
// "alexk" or "alexk21"
80+
const base = `${first}${lastInitial}`;
81+
return maybe(rng, 0.5) ? `${base}${twoDigit(rng)}` : base;
82+
}
83+
// "coolfox7" / "neonkite42"
84+
const adj = pick(HANDLE_ADJECTIVES, rng);
85+
const word = pick(HANDLE_WORDS, rng);
86+
const digit = maybe(rng, 0.6) ? String(Math.floor(rng() * 1000)) : "";
87+
return `${adj}${word}${digit}`;
88+
}
89+
90+
/**
91+
* Generate a hint that doesn't collide with any existing one. Collision on
92+
* hint is not a Canton uniqueness violation (fingerprint disambiguates), but
93+
* local keyfiles live at `<hint>.json` so filename collision matters.
94+
*
95+
* @param {(hint: string) => boolean} exists — returns true if hint already used
96+
* @param {number} maxTries
97+
*/
98+
export function generateUniqueHint(exists, rng = Math.random, maxTries = 50) {
99+
for (let i = 0; i < maxTries; i++) {
100+
const h = generateHint(rng);
101+
if (!exists(h)) return h;
102+
}
103+
// Fallback: append random hex suffix
104+
return `${generateHint(rng)}${Math.floor(rng() * 1e6).toString(36)}`;
105+
}

autobots/orchestrator.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* GrowthOrchestrator — manages a fleet of persona-driven VirtualBots.
3+
*
4+
* The orchestrator decides *when* to add new bots; each bot's cadence is
5+
* driven by its persona (see personas.js), not by the orchestrator. Signup
6+
* follows a sqrt-ramp from 0 → maxUsers over rampMs, producing an organic
7+
* cohort curve rather than a flat spawn.
8+
*
9+
* On spawn, each new bot is assigned a persona drawn from the configured
10+
* weighted distribution (Casual / Regular / Churned by default).
11+
*
12+
* On startup the orchestrator adopts any pre-existing keyfiles before minting
13+
* new ones, so restarts are idempotent.
14+
*/
15+
16+
import { PartyPool } from "./party-pool.js";
17+
import { drawPersona, PERSONAS } from "./personas.js";
18+
import { VirtualBot } from "./virtual-bot.js";
19+
20+
const iso = () => new Date().toISOString();
21+
22+
function logInfo(msg) {
23+
console.log(`[INFO] ${iso()} ${msg}`);
24+
}
25+
function logWarn(msg) {
26+
console.log(`[WARN] ${iso()} ${msg}`);
27+
}
28+
function logCritical(msg) {
29+
console.log(`[CRITICAL] ${iso()} ${msg}`);
30+
}
31+
32+
function sleep(ms) {
33+
return new Promise((r) => setTimeout(r, ms));
34+
}
35+
36+
export class GrowthOrchestrator {
37+
#serverUrl;
38+
#billingApiKey;
39+
#maxUsers;
40+
#rampMs;
41+
#tickMs;
42+
#tasks;
43+
#stats;
44+
#personas;
45+
#demoTimeScale;
46+
#pool;
47+
48+
#startedAt = 0;
49+
#bots = [];
50+
#stopped = false;
51+
52+
constructor({
53+
serverUrl,
54+
billingPortalUrl,
55+
billingApiKey = null,
56+
keysDir,
57+
maxUsers,
58+
rampHours,
59+
tickSeconds = 30,
60+
tasks,
61+
stats,
62+
personas = PERSONAS,
63+
demoTimeScale = 1,
64+
poolSize = 100,
65+
}) {
66+
this.#serverUrl = serverUrl;
67+
this.#billingApiKey = billingApiKey;
68+
this.#maxUsers = maxUsers;
69+
this.#rampMs = rampHours * 3600 * 1000;
70+
this.#tickMs = tickSeconds * 1000;
71+
this.#tasks = tasks;
72+
this.#stats = stats;
73+
this.#personas = personas;
74+
this.#demoTimeScale = demoTimeScale;
75+
this.#pool = new PartyPool({ billingPortalUrl, keysDir, maxSize: poolSize });
76+
}
77+
78+
get botCount() {
79+
return this.#bots.length;
80+
}
81+
82+
get activeBotCount() {
83+
return this.#bots.filter((b) => !b.dormant).length;
84+
}
85+
86+
get targetBotCount() {
87+
return this.#usersAt(this.#elapsedFraction());
88+
}
89+
90+
#elapsedFraction() {
91+
if (this.#rampMs <= 0) return 1;
92+
const elapsed = Date.now() - this.#startedAt;
93+
return Math.min(1, Math.max(0, elapsed / this.#rampMs));
94+
}
95+
96+
#usersAt(fraction) {
97+
return Math.max(1, Math.round(this.#maxUsers * Math.sqrt(fraction)));
98+
}
99+
100+
async start() {
101+
this.#startedAt = Date.now();
102+
const personaNames = Object.keys(this.#personas).join("/");
103+
const { adopted, capacity } = this.#pool.init();
104+
logInfo(
105+
`Growth orchestrator starting — maxUsers=${this.#maxUsers} rampHours=${(this.#rampMs / 3600_000).toFixed(2)} personas=${personaNames} demoTimeScale=${this.#demoTimeScale} billingApiKey=${this.#billingApiKey ? "configured" : "missing"} poolSize=${capacity} adoptedKeys=${adopted}`
106+
);
107+
if (this.#maxUsers > capacity) {
108+
logWarn(
109+
`maxUsers=${this.#maxUsers} exceeds pool capacity=${capacity}; active bots will cap at pool size`
110+
);
111+
}
112+
113+
// Warm-start: if we adopted keyfiles from a previous run, spawn bots for
114+
// them immediately (up to maxUsers) instead of re-ramping from zero.
115+
// Bots get freshly drawn personas — we don't persist persona-to-party
116+
// mapping across runs.
117+
const warmStart = Math.min(adopted, this.#maxUsers);
118+
for (let i = 0; i < warmStart && !this.#stopped; i++) {
119+
try {
120+
const reservation = await this.#pool.checkout();
121+
if (!reservation) break;
122+
await this.#spawnBot(reservation.keyPath, reservation.reused);
123+
} catch (err) {
124+
logWarn(`Warm-start spawn failed: ${err.message} — continuing`);
125+
}
126+
}
127+
128+
await this.#tickLoop();
129+
}
130+
131+
stop() {
132+
this.#stopped = true;
133+
for (const bot of this.#bots) bot.stop();
134+
}
135+
136+
async #spawnBot(keyPath, reused = false) {
137+
const persona = drawPersona(this.#personas);
138+
const bot = new VirtualBot({
139+
keyPath,
140+
serverUrl: this.#serverUrl,
141+
tasks: this.#tasks,
142+
stats: this.#stats,
143+
persona,
144+
demoTimeScale: this.#demoTimeScale,
145+
billingApiKey: this.#billingApiKey,
146+
onRelease: () => this.#handleBotReleased(bot, keyPath),
147+
});
148+
try {
149+
await bot.authenticate();
150+
} catch (err) {
151+
logCritical(`Bot auth failed for ${keyPath}: ${err.message}`);
152+
this.#pool.release(keyPath);
153+
throw err;
154+
}
155+
this.#bots.push(bot);
156+
this.#stats.addBotSpawned?.(bot.partyId, persona.name);
157+
bot.startLoop();
158+
logInfo(
159+
`Bot online (${this.#bots.length}/${this.targetBotCount}) persona=${persona.name} party=${reused ? "recycled" : "new"} partyId=${bot.partyId.slice(0, 50)}...`
160+
);
161+
}
162+
163+
#handleBotReleased(bot, keyPath) {
164+
const idx = this.#bots.indexOf(bot);
165+
if (idx >= 0) this.#bots.splice(idx, 1);
166+
this.#pool.release(keyPath);
167+
}
168+
169+
async #tickLoop() {
170+
while (!this.#stopped) {
171+
try {
172+
const fraction = this.#elapsedFraction();
173+
const target = this.#usersAt(fraction);
174+
175+
while (this.#bots.length < target && !this.#stopped) {
176+
let reservation;
177+
try {
178+
reservation = await this.#pool.checkout();
179+
} catch (err) {
180+
logWarn(`Provisioning failed: ${err.message} — will retry next tick`);
181+
break;
182+
}
183+
if (!reservation) {
184+
logWarn(
185+
`Party pool exhausted (${this.#pool.inUseCount}/${this.#pool.maxSize}); will retry next tick`
186+
);
187+
break;
188+
}
189+
try {
190+
await this.#spawnBot(reservation.keyPath, reservation.reused);
191+
} catch {
192+
// #spawnBot already released the party and logged.
193+
break;
194+
}
195+
}
196+
197+
console.log(
198+
`[STATUS] ${iso().slice(0, 19)}Z growth bots=${this.#bots.length} active=${this.activeBotCount} target=${target} ramp=${(fraction * 100).toFixed(1)}% pool=${this.#pool.size}/${this.#pool.maxSize} idle=${this.#pool.idleCount}`
199+
);
200+
} catch (err) {
201+
logCritical(`Orchestrator tick error: ${err.message}`);
202+
if (err.stack) console.error(err.stack);
203+
}
204+
await sleep(this.#tickMs);
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)