brainchain-worker 0.1.0 → 0.2.1

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/dist/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { BaseError, formatEther, formatUnits, HttpRequestError, parseUnits } from "viem";
3
+ import { join as joinPath } from "node:path";
4
+ import { formatEther, formatUnits, parseUnits } from "viem";
4
5
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
5
- import { ApplicationState, createChain } from "./chain.js";
6
+ import { ApplicationState, createChain, faucetReopens, reason, unhealthy } from "./chain.js";
6
7
  import { resolveNetwork } from "./network.js";
7
- import { createWork, shellCommand } from "./work.js";
8
+ import { fileStore, lock, stateDir } from "./state.js";
9
+ import { createWork, shellCommand, stopper } from "./work.js";
8
10
  const { version } = createRequire(import.meta.url)("../package.json");
9
11
  const USAGE = `brainchain-worker ${version}
10
12
 
@@ -14,26 +16,35 @@ Usage:
14
16
  brainchain-worker new-key Print a fresh private key and its address.
15
17
  brainchain-worker join --name <name> --about <text> [--price <tUSDG>]
16
18
  Stake the minimum and apply to take tasks.
17
- brainchain-worker run --exec "<command>" [--format prompt|json] [--once]
19
+ brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
18
20
  Do every task Jev hires you for: the command reads the
19
- task on stdin and prints the deliverable on stdout.
20
- brainchain-worker status Stake, application, record and earnings.
21
+ task on stdin and prints the deliverable on stdout, within
22
+ --timeout minutes (20 by default, at most 1440).
23
+ brainchain-worker status Stake, tBRAIN balance, application, record and earnings.
21
24
  brainchain-worker price <tUSDG> Change your asking price per task.
22
25
  brainchain-worker leave Start unstaking (3-day cooldown).
23
26
  brainchain-worker withdraw Withdraw stake once the cooldown has passed.
27
+ brainchain-worker --version Print the version.
24
28
 
25
29
  Environment:
26
30
  BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
27
31
  BRAINCHAIN_NETWORK testnet (the default, and the only network open to outside workers today).
28
32
  BRAINCHAIN_RPC_URL Overrides the network's RPC.
33
+ BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
34
+ localhost or 127.0.0.1.
35
+
36
+ The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
37
+ Unfinished work is kept in ~/.config/brainchain-worker/<address>.json across restarts, and one
38
+ run at a time works for each address.
29
39
 
30
40
  Docs: https://docs.usebrainchain.com/run-a-worker`;
41
+ /** A mistake in the command line. Every other error is printed after the program's name. */
31
42
  class UsageError extends Error {
32
43
  }
33
44
  /** The flags each command takes; `true` marks a switch, which takes no value. */
34
45
  const FLAGS = {
35
46
  join: { name: false, about: false, price: false },
36
- run: { exec: false, format: false, once: true },
47
+ run: { exec: false, format: false, timeout: false, once: true },
37
48
  status: {},
38
49
  price: {},
39
50
  leave: {},
@@ -43,7 +54,7 @@ const POSITIONAL = { price: 1 };
43
54
  function parse(command, args) {
44
55
  const known = FLAGS[command];
45
56
  if (known === undefined)
46
- throw new UsageError(`Unknown command "${command}". Run brainchain-worker help.`);
57
+ throw new UsageError(`Unknown command "${command}" (see brainchain-worker help)`);
47
58
  const positional = [];
48
59
  const values = new Map();
49
60
  const switches = new Set();
@@ -56,7 +67,7 @@ function parse(command, args) {
56
67
  const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
57
68
  const isSwitch = known[name];
58
69
  if (isSwitch === undefined)
59
- throw new UsageError(`${command} has no --${name} flag. Run brainchain-worker help.`);
70
+ throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker help)`);
60
71
  if (isSwitch) {
61
72
  switches.add(name);
62
73
  continue;
@@ -67,23 +78,25 @@ function parse(command, args) {
67
78
  values.set(name, value);
68
79
  }
69
80
  if (positional.length > (POSITIONAL[command] ?? 0))
70
- throw new UsageError(`Unexpected "${positional.at(-1)}". Run brainchain-worker help.`);
81
+ throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker help)`);
71
82
  return { positional, values, switches };
72
83
  }
73
- function account() {
84
+ function signer() {
74
85
  const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
75
86
  if (!raw)
76
- throw new UsageError("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
87
+ throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
77
88
  const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
78
89
  if (!/^[0-9a-fA-F]{64}$/.test(hex))
79
- throw new UsageError("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
90
+ throw new Error("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
91
+ const key = `0x${hex.toLowerCase()}`;
80
92
  try {
81
- return privateKeyToAccount(`0x${hex}`);
93
+ return { key, account: privateKeyToAccount(key) };
82
94
  }
83
95
  catch {
84
- throw new UsageError("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
96
+ throw new Error("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
85
97
  }
86
98
  }
99
+ const DEFAULT_PRICE = "0.20";
87
100
  /** A price in the bounty token, which has 6 decimals. */
88
101
  function price(value) {
89
102
  if (!/^\d{1,6}(\.\d{1,6})?$/.test(value)) {
@@ -94,19 +107,39 @@ function price(value) {
94
107
  const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
95
108
  const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
96
109
  const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
97
- async function needGas(chain, network) {
98
- if ((await chain.ethBalance()) > 0n)
99
- return;
100
- throw new UsageError(`${chain.account} has no ETH on ${network.name} for gas. Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com ` +
110
+ const RUN = '`npx brainchain-worker run --exec "<command>"`';
111
+ function noGas(chain, network) {
112
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com ` +
101
113
  `and bridge some at ${network.explorer}; 0.001 ETH is plenty.`);
102
114
  }
103
- /** Why the worker can or cannot be hired right now, in one line. */
104
- async function hireable(chain, network) {
115
+ async function needGas(chain, network) {
116
+ if ((await chain.ethBalance()) === 0n)
117
+ throw noGas(chain, network);
118
+ }
119
+ /** The worker's standing with the board and the staking contract, and what join would do about it. */
120
+ async function snapshot(chain) {
105
121
  const [allowed, w, min, { applications, change }] = await Promise.all([chain.allowed(), chain.stake(), chain.minStake(), chain.applications()]);
122
+ const pending = change !== undefined && !change.executed && !change.vetoed;
123
+ const open = applications.find((a) => a.state === ApplicationState.Open);
124
+ return {
125
+ allowed,
126
+ w,
127
+ min,
128
+ applications,
129
+ change,
130
+ pending,
131
+ open,
132
+ applying: !allowed && !pending && open === undefined,
133
+ // Below the minimum after leave or a slash, an allowed worker cannot be hired until it stakes again.
134
+ toStake: w.staked >= min ? 0n : min - w.staked,
135
+ };
136
+ }
137
+ /** Why the worker can or cannot be hired right now, in one line. */
138
+ function hireable({ allowed, w, min, applications, change, pending }, network) {
106
139
  if (allowed)
107
140
  return w.staked >= min ? "yes" : `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again.`;
108
141
  // An allowed application waiting out its timelock outranks anything filed after it.
109
- if (change && !change.executed && !change.vetoed)
142
+ if (change && pending)
110
143
  return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
111
144
  const last = applications.at(-1);
112
145
  if (last === undefined)
@@ -115,39 +148,45 @@ async function hireable(chain, network) {
115
148
  return `no: application ${last.id} is waiting for Jev`;
116
149
  if (last.state === ApplicationState.Burned)
117
150
  return `no: Jev did not allow application ${last.id}, and its bond was burned. Run join to apply again.`;
118
- if (change?.vetoed)
119
- return `no: the guardian vetoed application ${last.id}`;
151
+ if (change?.vetoed) {
152
+ // A vetoed call can never be queued again, so the veto still stands for applications filed after it.
153
+ const vetoed = applications.find((a) => a.decisionId === change.decisionId);
154
+ return `no: the guardian vetoed ${vetoed ? `application ${vetoed.id}` : "allowing this worker"}`;
155
+ }
120
156
  return `no: application ${last.id} closed without a decision; its bond was returned. Run join to apply again.`;
121
157
  }
122
158
  async function join(chain, network, values) {
123
- const name = values.get("name")?.trim();
159
+ const name = values.get("name")?.trim() ?? "";
124
160
  const about = values.get("about")?.trim() ?? "";
125
- if (!name || name.length > 60)
161
+ if (!name)
126
162
  throw new UsageError("--name is required, up to 60 characters");
127
- if (!about || about.length > 500) {
128
- throw new UsageError("--about is required, up to 500 characters: say what work your agent does. Jev reads it to decide, and refuses an empty application.");
163
+ if (name.length > 60)
164
+ throw new UsageError("--name is at most 60 characters");
165
+ if (!about) {
166
+ throw new UsageError("--about is required: say what work your agent does in up to 500 characters, since Jev reads it to decide and refuses an empty application");
129
167
  }
130
- const asking = price(values.get("price") ?? "0.20");
168
+ if (about.length > 500)
169
+ throw new UsageError("--about is at most 500 characters");
170
+ const requested = values.has("price") ? price(values.get("price")) : undefined;
131
171
  await needGas(chain, network);
132
- if (await chain.allowed()) {
133
- console.log("This worker can already be hired. Start it with `npx brainchain-worker run`.");
134
- return;
135
- }
136
- const { applications, change } = await chain.applications();
137
- if (change && !change.executed && !change.vetoed) {
138
- console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep \`npx brainchain-worker run\` going until then.`);
139
- return;
140
- }
141
- const open = applications.find((a) => a.state === ApplicationState.Open);
142
- const [w, min, bond, balance] = await Promise.all([chain.stake(), chain.minStake(), chain.bond(), chain.stakeBalance()]);
143
- const toStake = w.staked >= min ? 0n : min - w.staked;
144
- const needed = toStake + (open ? 0n : bond);
145
- if (balance < needed) {
172
+ const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
173
+ // A worker that has staked keeps its price unless --price changes it.
174
+ const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
175
+ const [bond, balance] = await Promise.all([chain.bond(), chain.stakeBalance()]);
176
+ const need = toStake + (applying ? bond : 0n);
177
+ if (balance < need) {
146
178
  console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
147
- await chain.claimStakeToken();
179
+ await chain.claimStakeToken().catch((error) => {
180
+ const opensAt = faucetReopens(error);
181
+ if (opensAt === undefined)
182
+ throw error;
183
+ const unstaking = w.unstaking > 0n ? `, or run withdraw after ${when(w.unlocksAt)} and join with the ${stake(network, w.unstaking)} it returns` : "";
184
+ throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs, and the faucet allows one claim a day: ` +
185
+ `this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
186
+ });
148
187
  }
149
188
  if (toStake > 0n) {
150
- console.log(`Staking ${stake(network, toStake)} at ${usdg(network, asking)} per task...`);
189
+ console.log(`Staking ${stake(network, toStake)}${w.staked > 0n ? " more, up to the minimum," : ""} at ${usdg(network, asking)} per task...`);
151
190
  await chain.approve(network.contracts.stakeToken, network.contracts.staking, toStake);
152
191
  await chain.stakeTokens(toStake, asking);
153
192
  }
@@ -155,6 +194,14 @@ async function join(chain, network, values) {
155
194
  console.log(`Setting your price to ${usdg(network, asking)} per task...`);
156
195
  await chain.setPrice(asking);
157
196
  }
197
+ if (allowed) {
198
+ console.log(`${toStake > 0n ? "Staked back to the minimum, so this worker can be hired again." : "This worker can already be hired."} Start it with ${RUN}.`);
199
+ return;
200
+ }
201
+ if (change && pending) {
202
+ console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep ${RUN} going until then.`);
203
+ return;
204
+ }
158
205
  if (open) {
159
206
  console.log(`Application ${open.id} is already waiting for Jev.`);
160
207
  return;
@@ -164,38 +211,88 @@ async function join(chain, network, values) {
164
211
  const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
165
212
  await chain.apply(`data:application/json;base64,${profile}`);
166
213
  console.log("\nApplied. Jev usually decides within a few minutes. If it allows the worker, it can be hired once a 2-hour timelock passes.\n" +
167
- "Start `npx brainchain-worker run` now so it takes its first task as soon as it is hired, and follow the application\n" +
168
- "with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
214
+ `Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
215
+ "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
216
+ }
217
+ /** The wallet's tBRAIN and, below the minimum stake, where join would find what it needs. */
218
+ function wallet(network, s, balance, bond, faucet, now) {
219
+ const has = stake(network, balance);
220
+ if (s.toStake === 0n)
221
+ return has;
222
+ const need = s.toStake + (s.applying ? bond : 0n);
223
+ if (balance >= need)
224
+ return `${has}, enough for the ${stake(network, need)} join needs`;
225
+ if (now >= faucet.opensAt)
226
+ return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
227
+ const unstaking = s.w.unstaking > 0n ? `, and the ${stake(network, s.w.unstaking)} unstaking can be withdrawn ${now >= s.w.unlocksAt ? "now" : `after ${when(s.w.unlocksAt)}`}` : "";
228
+ return `${has}; join needs ${stake(network, need)}. The faucet opens again for this wallet at ${when(faucet.opensAt)}${unstaking}`;
169
229
  }
170
230
  async function status(chain, network) {
171
- const [gas, w, min, held, record, balance, line] = await Promise.all([
231
+ const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
172
232
  chain.ethBalance(),
173
- chain.stake(),
174
- chain.minStake(),
175
- chain.inFlight(),
233
+ snapshot(chain),
234
+ chain.held(),
176
235
  chain.record(),
177
236
  chain.bountyBalance(),
178
- hireable(chain, network),
237
+ chain.stakeBalance(),
238
+ chain.bond(),
239
+ chain.faucet(),
240
+ chain.now(),
179
241
  ]);
242
+ const { w, min } = s;
180
243
  const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
181
244
  console.log(`Worker ${chain.account} on ${network.name}
182
- Hireable ${line}
245
+ Hireable ${hireable(s, network)}
183
246
  Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
247
+ Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
184
248
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
185
249
  Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
186
- Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, balance)}
250
+ Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
187
251
  Gas ${formatEther(gas)} ETH`);
188
252
  }
189
- async function run(chain, network, values, once) {
253
+ async function run(network, { key, account }, values, once) {
190
254
  const exec = values.get("exec");
191
255
  if (!exec)
192
256
  throw new UsageError(`--exec is required: the command that does the work, such as --exec 'claude -p --tools ""'`);
193
257
  const format = values.get("format") ?? "prompt";
194
258
  if (format !== "prompt" && format !== "json")
195
259
  throw new UsageError("--format is prompt or json");
196
- const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)} ${message}`);
197
- const work = createWork({ chain, command: shellCommand(exec), format, log });
198
- log(`worker ${chain.account} on ${network.name}; hireable: ${await hireable(chain, network)}`);
260
+ const minutes = values.get("timeout") ?? "20";
261
+ if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
262
+ throw new UsageError("--timeout is the most minutes the command may spend on one task: more than 0, at most 1440, and 20 by default");
263
+ }
264
+ const file = joinPath(stateDir(), account.address);
265
+ lock(`${file}.lock`);
266
+ const store = fileStore(`${file}.json`, network.contracts.taskBoard);
267
+ const chain = createChain(network, account, store);
268
+ const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)}Z ${message}`);
269
+ const command = shellCommand(exec);
270
+ const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code) });
271
+ const work = createWork({
272
+ chain,
273
+ command,
274
+ format,
275
+ log,
276
+ timeoutMs: Number(minutes) * 60_000,
277
+ secrets: [key, key.slice(2)],
278
+ store,
279
+ stopping: stop.stopping,
280
+ });
281
+ process.on("SIGINT", stop.signal);
282
+ process.on("SIGTERM", stop.signal);
283
+ const gas = await chain.ethBalance().catch((error) => {
284
+ log(`could not read the wallet's ETH balance: ${reason(error)}`);
285
+ return undefined;
286
+ });
287
+ if (gas === 0n)
288
+ throw noGas(chain, network);
289
+ const [line, sick] = await Promise.all([
290
+ snapshot(chain).then((s) => hireable(s, network), (error) => `could not check (${reason(error)})`),
291
+ unhealthy(network.service),
292
+ ]);
293
+ log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
294
+ if (sick)
295
+ log(`warning: ${sick}; results are uploaded there, so none can be delivered until it answers`);
199
296
  if (once) {
200
297
  const result = await work.round();
201
298
  if (result.hired === 0)
@@ -205,17 +302,13 @@ async function run(chain, network, values, once) {
205
302
  process.exitCode = 1;
206
303
  return;
207
304
  }
208
- let stopping = false;
209
- process.once("SIGINT", () => {
210
- stopping = true;
211
- log("stopping after the current task");
212
- });
213
305
  log("waiting for tasks (Ctrl-C to stop)");
214
- while (!stopping) {
215
- await work.round().catch((error) => log(`round failed: ${describe(error)}`));
216
- for (let i = 0; i < 15 && !stopping; i++)
306
+ while (!stop.stopping()) {
307
+ await work.round().catch((error) => log(`round failed: ${reason(error)}`));
308
+ for (let i = 0; i < 15 && !stop.stopping(); i++)
217
309
  await new Promise((resolve) => setTimeout(resolve, 1_000));
218
310
  }
311
+ log("stopped");
219
312
  }
220
313
  async function main(argv) {
221
314
  const [command, ...rest] = argv;
@@ -234,20 +327,21 @@ async function main(argv) {
234
327
  }
235
328
  const { positional, values, switches } = parse(command, rest);
236
329
  const network = resolveNetwork(process.env);
237
- const chain = createChain(network, account());
330
+ const worker = signer();
331
+ if (command === "run")
332
+ return run(network, worker, values, switches.has("once"));
333
+ const chain = createChain(network, worker.account);
238
334
  switch (command) {
239
335
  case "join":
240
336
  return join(chain, network, values);
241
337
  case "status":
242
338
  return status(chain, network);
243
- case "run":
244
- return run(chain, network, values, switches.has("once"));
245
339
  case "price": {
246
340
  if (positional[0] === undefined)
247
341
  throw new UsageError("Usage: brainchain-worker price <tUSDG>, such as price 0.25");
248
342
  const asking = price(positional[0]);
249
343
  if (!(await chain.stake()).registered)
250
- throw new UsageError("This worker has not staked yet. Run join, which sets the price too.");
344
+ throw new Error("This worker has not staked yet. Run join, which sets the price too.");
251
345
  await needGas(chain, network);
252
346
  await chain.setPrice(asking);
253
347
  console.log(`Price set to ${usdg(network, asking)} per task.`);
@@ -256,7 +350,7 @@ async function main(argv) {
256
350
  case "leave": {
257
351
  const w = await chain.stake();
258
352
  if (w.staked === 0n) {
259
- throw new UsageError(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
353
+ throw new Error(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
260
354
  }
261
355
  await needGas(chain, network);
262
356
  await chain.requestUnstake(w.staked);
@@ -265,13 +359,13 @@ async function main(argv) {
265
359
  return;
266
360
  }
267
361
  case "withdraw": {
268
- const [w, held, now] = await Promise.all([chain.stake(), chain.inFlight(), chain.now()]);
362
+ const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
269
363
  if (w.unstaking === 0n)
270
- throw new UsageError("Nothing is unstaking. Run leave first.");
364
+ throw new Error("Nothing is unstaking. Run leave first.");
271
365
  if (now < w.unlocksAt)
272
- throw new UsageError(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
366
+ throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
273
367
  if (held > 0)
274
- throw new UsageError(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
368
+ throw new Error(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
275
369
  await needGas(chain, network);
276
370
  await chain.withdraw();
277
371
  console.log(`Withdrew ${stake(network, w.unstaking)}.`);
@@ -279,15 +373,7 @@ async function main(argv) {
279
373
  }
280
374
  }
281
375
  }
282
- /** One readable line for an error, with the cause viem keeps apart from its summary. */
283
- function describe(error) {
284
- if (error instanceof HttpRequestError)
285
- return `Could not reach ${error.url}${error.details ? ` (${error.details})` : ""}`;
286
- if (error instanceof BaseError)
287
- return error.shortMessage;
288
- return error instanceof Error ? error.message : String(error);
289
- }
290
376
  main(process.argv.slice(2)).catch((error) => {
291
- console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${describe(error)}`);
377
+ console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${reason(error)}`);
292
378
  process.exit(1);
293
379
  });
package/dist/network.js CHANGED
@@ -26,10 +26,26 @@ export function resolveNetwork(env) {
26
26
  throw new Error(`BRAINCHAIN_NETWORK must be testnet, not "${env.BRAINCHAIN_NETWORK}"`);
27
27
  return {
28
28
  ...TESTNET,
29
- ...(env.BRAINCHAIN_RPC_URL ? { rpc: env.BRAINCHAIN_RPC_URL } : {}),
30
- ...(env.BRAINCHAIN_SERVICE_URL ? { service: env.BRAINCHAIN_SERVICE_URL.replace(/\/$/, "") } : {}),
29
+ ...(env.BRAINCHAIN_RPC_URL ? { rpc: rpcURL(env.BRAINCHAIN_RPC_URL.trim()) } : {}),
30
+ ...(env.BRAINCHAIN_SERVICE_URL ? { service: serviceURL(env.BRAINCHAIN_SERVICE_URL.trim()) } : {}),
31
31
  };
32
32
  }
33
+ /** The value is never echoed: an RPC URL can hold an API key, and a mistyped one still does. */
34
+ function rpcURL(value) {
35
+ const url = URL.canParse(value) ? new URL(value) : undefined;
36
+ if (url?.protocol !== "https:" && url?.protocol !== "http:")
37
+ throw new Error("BRAINCHAIN_RPC_URL is not a valid URL: it must start with https:// or http://");
38
+ return value;
39
+ }
40
+ /** The worker signs and sends its results to the service, so only https is accepted, or plain http to a service on this machine. */
41
+ function serviceURL(value) {
42
+ const url = URL.canParse(value) ? new URL(value) : undefined;
43
+ const local = url?.hostname === "localhost" || url?.hostname === "127.0.0.1";
44
+ if (url?.protocol !== "https:" && !(url?.protocol === "http:" && local)) {
45
+ throw new Error("BRAINCHAIN_SERVICE_URL must be an https URL, or http on localhost or 127.0.0.1");
46
+ }
47
+ return value.replace(/\/+$/, "");
48
+ }
33
49
  export function chainOf(network) {
34
50
  return defineChain({
35
51
  id: network.chainId,
package/dist/state.js ADDED
@@ -0,0 +1,77 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
5
+ const empty = (board) => ({ board: board.toLowerCase(), tasks: {}, done: [] });
6
+ export function memoryStore(board = "") {
7
+ return { state: empty(board), save: () => undefined };
8
+ }
9
+ /** State in a 0600 JSON file, replaced whole on every save so a crash never leaves half a file. */
10
+ export function fileStore(file, board) {
11
+ let state = empty(board);
12
+ let text;
13
+ try {
14
+ text = readFileSync(file, "utf8");
15
+ }
16
+ catch (error) {
17
+ if (error.code !== "ENOENT")
18
+ throw error;
19
+ }
20
+ if (text !== undefined) {
21
+ let saved;
22
+ try {
23
+ saved = JSON.parse(text);
24
+ }
25
+ catch {
26
+ throw new Error(`${file} is not valid JSON. Move it aside to start with no saved work.`);
27
+ }
28
+ if (saved.board === state.board)
29
+ state = { ...state, ...saved, tasks: saved.tasks ?? {}, done: saved.done ?? [] };
30
+ }
31
+ return {
32
+ state,
33
+ save() {
34
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
35
+ const temp = `${file}.${process.pid}.tmp`;
36
+ writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
37
+ renameSync(temp, file);
38
+ },
39
+ };
40
+ }
41
+ function alive(pid) {
42
+ // A container restarts its process with the same pid, so our own pid in the file is a stale lock.
43
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
44
+ return false;
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ return error.code === "EPERM";
51
+ }
52
+ }
53
+ /** Takes the lock in `file` for this process until it exits; refuses while a live process holds it. */
54
+ export function lock(file) {
55
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
56
+ for (;;) {
57
+ try {
58
+ writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
59
+ break;
60
+ }
61
+ catch (error) {
62
+ if (error.code !== "EEXIST")
63
+ throw error;
64
+ }
65
+ // The holder may have released it since the write failed; then the next try takes it.
66
+ const holder = existsSync(file) ? Number(readFileSync(file, "utf8").trim()) : 0;
67
+ if (alive(holder))
68
+ throw new Error(`Another run (pid ${holder}) is already working for this worker. Stop it before starting another; ${file} holds its lock.`);
69
+ rmSync(file, { force: true });
70
+ }
71
+ const release = () => {
72
+ if (existsSync(file) && readFileSync(file, "utf8") === String(process.pid))
73
+ rmSync(file, { force: true });
74
+ };
75
+ process.once("exit", release);
76
+ return release;
77
+ }