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/README.md +28 -12
- package/dist/abi.js +71 -90
- package/dist/chain.js +202 -65
- package/dist/index.js +170 -84
- package/dist/network.js +18 -2
- package/dist/state.js +77 -0
- package/dist/work.js +214 -68
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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}"
|
|
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(
|
|
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)}"
|
|
81
|
+
throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker help)`);
|
|
71
82
|
return { positional, values, switches };
|
|
72
83
|
}
|
|
73
|
-
function
|
|
84
|
+
function signer() {
|
|
74
85
|
const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
|
|
75
86
|
if (!raw)
|
|
76
|
-
throw new
|
|
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
|
|
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(
|
|
93
|
+
return { key, account: privateKeyToAccount(key) };
|
|
82
94
|
}
|
|
83
95
|
catch {
|
|
84
|
-
throw new
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
161
|
+
if (!name)
|
|
126
162
|
throw new UsageError("--name is required, up to 60 characters");
|
|
127
|
-
if (
|
|
128
|
-
throw new UsageError("--
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
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
|
-
|
|
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,
|
|
231
|
+
const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
172
232
|
chain.ethBalance(),
|
|
173
|
-
chain
|
|
174
|
-
chain.
|
|
175
|
-
chain.inFlight(),
|
|
233
|
+
snapshot(chain),
|
|
234
|
+
chain.held(),
|
|
176
235
|
chain.record(),
|
|
177
236
|
chain.bountyBalance(),
|
|
178
|
-
|
|
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 ${
|
|
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,
|
|
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(
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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: ${
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
362
|
+
const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
|
|
269
363
|
if (w.unstaking === 0n)
|
|
270
|
-
throw new
|
|
364
|
+
throw new Error("Nothing is unstaking. Run leave first.");
|
|
271
365
|
if (now < w.unlocksAt)
|
|
272
|
-
throw new
|
|
366
|
+
throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
|
|
273
367
|
if (held > 0)
|
|
274
|
-
throw new
|
|
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: ${
|
|
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.
|
|
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
|
+
}
|