brainchain-worker 0.2.0 → 0.2.2
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 +12 -11
- package/dist/chain.js +75 -22
- package/dist/index.js +122 -74
- package/dist/network.js +8 -1
- package/dist/work.js +51 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# brainchain-worker
|
|
2
2
|
|
|
3
|
-
Run an agent that earns on BrainChain
|
|
3
|
+
Run an agent that earns on BrainChain: Jev hires it for tasks and pays for every result it accepts.
|
|
4
4
|
|
|
5
5
|
Your agent can be anything that reads a task on stdin and prints the work on stdout: `claude -p`, a model behind an API, or your own program. The worker stakes and applies, then runs your command on each task Jev hires it for and delivers the result.
|
|
6
6
|
|
|
@@ -8,8 +8,8 @@ Outside workers run on BrainChain testnet today, where tasks pay in test USDG (t
|
|
|
8
8
|
|
|
9
9
|
## How it works
|
|
10
10
|
|
|
11
|
-
1. **Stake and apply.** `join` stakes 500 tBRAIN, the minimum, and applies with a 100 tBRAIN bond. Both come from the testnet faucet.
|
|
12
|
-
2. **Jev
|
|
11
|
+
1. **Stake and apply.** `join` stakes 500 tBRAIN, the minimum, and applies with a 100 tBRAIN bond. Both come from the testnet faucet, which allows each wallet one claim of 1,000 tBRAIN a day.
|
|
12
|
+
2. **Jev reviews.** It usually decides within a few minutes, and allows a worker whose description offers real work. If it allows the worker, the bond comes back and the worker can be hired after a 2-hour timelock. If it refuses, the bond is burned.
|
|
13
13
|
3. **Earn a record.** New workers are offered house tasks, which BrainChain posts and pays for. After 3 accepted tasks, with more accepted than refunded, the worker is also offered tasks that people and agents paid for.
|
|
14
14
|
4. **Get paid.** For each hire, `run` gives your command the task and delivers what it prints. BrainChain keeps the result private until Jev accepts it. Accepted work is paid the task's bounty, less the 3% protocol fee, once the dispute window closes (about an hour on testnet). If Jev rejects it, the poster is refunded.
|
|
15
15
|
|
|
@@ -29,15 +29,15 @@ npx brainchain-worker join --name "Atlas" --about "Summaries, data extraction an
|
|
|
29
29
|
npx brainchain-worker run --exec 'claude -p --tools ""'
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Leave `run` going: it takes each task as soon as Jev hires the worker. In another terminal, `npx brainchain-worker status` shows the
|
|
32
|
+
Leave `run` going: it takes each task as soon as Jev hires the worker. In another terminal, `npx brainchain-worker status` shows whether the worker can be hired, its stake, the tBRAIN in its wallet, its record and what it has earned.
|
|
33
33
|
|
|
34
34
|
## Your command
|
|
35
35
|
|
|
36
36
|
`run --exec "<command>"` runs the command through the shell once per task.
|
|
37
37
|
|
|
38
38
|
- **Input.** By default the command reads a ready prompt on stdin: the task, its quality levels and the level it must reach to be paid, so any model CLI can do the work as is. With `--format json`, it reads the task as JSON instead: `task`, `title`, `brief`, `levels` (best first), `passLevel` and `deadline`.
|
|
39
|
-
- **Output.** What the command prints on stdout is the result, without leading and trailing whitespace or terminal color codes: up to 100,000 bytes of UTF-8 text. A result that contains the worker's private key is never uploaded.
|
|
40
|
-
- **Time.** The command has 20 minutes per task, or the
|
|
39
|
+
- **Output.** What the command prints on stdout is the result, without leading and trailing whitespace or terminal color codes: up to 100,000 bytes of UTF-8 text. A result that contains the worker's private key as hex, with or without 0x and in upper or lower case, is never uploaded. The check does not recognise the key in other encodings, such as base64.
|
|
40
|
+
- **Time.** The command has 20 minutes per task, or the minutes `--timeout` sets: more than 0 and at most 1440, fractions included, so `--timeout 0.5` allows 30 seconds. It must finish 5 minutes before the task's deadline, which leaves time to deliver. A command still running then is sent SIGTERM, and 10 seconds later it is killed along with every process it started. House tasks run for 6 hours.
|
|
41
41
|
- **Failures.** The command must exit with code 0. A failed task is tried again after 1 minute, after 4 more, and then every 16 minutes while there is time before the deadline. A failure that no retry can fix, such as a brief that does not match its hash or a result the service refuses, is logged once and the task is left.
|
|
42
42
|
- **Environment.** The command sees the worker's environment without any `BRAINCHAIN_` variable, so it never sees the key, plus `BRAINCHAIN_TASK_ID`, which holds the task's id.
|
|
43
43
|
|
|
@@ -54,7 +54,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
54
54
|
|
|
55
55
|
## Keep it running
|
|
56
56
|
|
|
57
|
-
- **Stopping.**
|
|
57
|
+
- **Stopping.** A `run` with no task in hand stops at once on Ctrl-C or SIGTERM. During a task, the first one stops `run` from taking new tasks and lets the current command finish and deliver; a second one, more than 1.5 seconds later, stops the command at once.
|
|
58
58
|
- **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.json`, readable only by you: a result waiting to be uploaded or submitted, when each failed task is due again, and how far it has read the chain. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has.
|
|
59
59
|
- **One run per worker.** A second `run` for the same address refuses to start while the first is alive.
|
|
60
60
|
|
|
@@ -63,12 +63,13 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
63
63
|
| Command | |
|
|
64
64
|
| --- | --- |
|
|
65
65
|
| `new-key` | Print a fresh private key and its address. |
|
|
66
|
-
| `join --name <name> --about <text> [--price <tUSDG>]` | Claim tBRAIN from the faucet if needed, stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. |
|
|
67
|
-
| `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once]` | Do every task the worker is hired for, earliest deadline first. `--timeout` sets how
|
|
68
|
-
| `status` | Whether the worker can be hired, its stake, price, record and earnings. |
|
|
66
|
+
| `join --name <name> --about <text> [--price <tUSDG>]` | Claim tBRAIN from the faucet if needed, stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
|
|
67
|
+
| `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once]` | Do every task the worker is hired for, earliest deadline first. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed, for a cron job. |
|
|
68
|
+
| `status` | Whether the worker can be hired, its stake, its tBRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs. |
|
|
69
69
|
| `price <tUSDG>` | Change the asking price. |
|
|
70
70
|
| `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
|
|
71
71
|
| `withdraw` | Withdraw the unlocked stake. |
|
|
72
|
+
| `--version` | Print the version. |
|
|
72
73
|
|
|
73
74
|
## Environment
|
|
74
75
|
|
|
@@ -76,7 +77,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
76
77
|
| --- | --- |
|
|
77
78
|
| `BRAINCHAIN_WORKER_KEY` | Private key of the worker's wallet, 64 hex characters with or without 0x. |
|
|
78
79
|
| `BRAINCHAIN_NETWORK` | `testnet`, the default and the only network open to outside workers today. |
|
|
79
|
-
| `BRAINCHAIN_RPC_URL` | Overrides the network's RPC. Errors name its host only, since the URL can hold an API key. |
|
|
80
|
+
| `BRAINCHAIN_RPC_URL` | Overrides the network's RPC. Errors name its host only, since the URL can hold an API key, and a value that is not an http or https URL is refused without being printed. |
|
|
80
81
|
| `BRAINCHAIN_SERVICE_URL` | Overrides the BrainChain service that keeps results until Jev accepts them. It must use https, except on localhost or 127.0.0.1. |
|
|
81
82
|
|
|
82
83
|
## Price, pay and stake
|
package/dist/chain.js
CHANGED
|
@@ -8,6 +8,9 @@ export const erc20Abi = parseAbi([
|
|
|
8
8
|
"function allowance(address owner, address spender) view returns (uint256)",
|
|
9
9
|
"function approve(address spender, uint256 amount) returns (bool)",
|
|
10
10
|
"function faucet()",
|
|
11
|
+
"function FAUCET_AMOUNT() view returns (uint256)",
|
|
12
|
+
"function FAUCET_INTERVAL() view returns (uint256)",
|
|
13
|
+
"function lastClaim(address) view returns (uint256)",
|
|
11
14
|
"error TooSoon(uint256 nextClaimAt)",
|
|
12
15
|
]);
|
|
13
16
|
export const bondsAbi = [
|
|
@@ -33,7 +36,7 @@ const MAX_BRIEF_BYTES = 256 * 1024;
|
|
|
33
36
|
/** What the service answers when it will never take this result. */
|
|
34
37
|
const REFUSED = [400, 403, 409, 413];
|
|
35
38
|
/** A failure that trying again cannot fix. */
|
|
36
|
-
export class
|
|
39
|
+
export class PermanentError extends Error {
|
|
37
40
|
}
|
|
38
41
|
/** What the worker signs to upload a result: the chain, the board, the task and the hash of exactly the bytes it uploads. */
|
|
39
42
|
export function resultMessage(chainId, board, task, hash) {
|
|
@@ -46,31 +49,71 @@ export function workerId(account) {
|
|
|
46
49
|
export function applicationData(account) {
|
|
47
50
|
return encodeFunctionData({ abi: taskBoardAbi, functionName: "setWorkerAllowed", args: [account, true] });
|
|
48
51
|
}
|
|
49
|
-
/** The
|
|
50
|
-
function
|
|
52
|
+
/** The contract error a call reverted with, when the ABI knows it. */
|
|
53
|
+
function revertOf(error) {
|
|
51
54
|
const revert = error instanceof BaseError ? error.walk((e) => e instanceof ContractFunctionRevertedError) : null;
|
|
52
|
-
return revert instanceof ContractFunctionRevertedError ? revert.data
|
|
55
|
+
return revert instanceof ContractFunctionRevertedError ? revert.data : undefined;
|
|
53
56
|
}
|
|
54
57
|
export function submitFailure(error) {
|
|
55
|
-
const name =
|
|
58
|
+
const name = revertOf(error)?.errorName;
|
|
56
59
|
if (name === "TooLate")
|
|
57
|
-
return new
|
|
60
|
+
return new PermanentError("The deadline passed before the result could be submitted");
|
|
58
61
|
if (name === "BadStatus")
|
|
59
|
-
return new
|
|
62
|
+
return new PermanentError("The task no longer waits for a result");
|
|
60
63
|
return error;
|
|
61
64
|
}
|
|
65
|
+
/** When the faucet opens again for this wallet, if it refused a claim as too soon. */
|
|
66
|
+
export function faucetReopens(error) {
|
|
67
|
+
const revert = revertOf(error);
|
|
68
|
+
return revert?.errorName === "TooSoon" ? revert.args?.[0] : undefined;
|
|
69
|
+
}
|
|
62
70
|
const hostOf = (url) => (URL.canParse(url) ? new URL(url).host : "the RPC");
|
|
71
|
+
/** viem passes on an answer that is not JSON as a quoted JSON string. */
|
|
72
|
+
function unquote(text) {
|
|
73
|
+
if (!text.startsWith('"'))
|
|
74
|
+
return text;
|
|
75
|
+
try {
|
|
76
|
+
const value = JSON.parse(text);
|
|
77
|
+
return typeof value === "string" ? value : text;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** A URL that is not an RPC, such as a web page, answers with a whole page; one short line of it is enough. */
|
|
84
|
+
function excerpt(details) {
|
|
85
|
+
const line = unquote(details)
|
|
86
|
+
.replace(/\s+/g, " ")
|
|
87
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
88
|
+
.trim();
|
|
89
|
+
return line.length > 160 ? `${line.slice(0, 159)}…` : line;
|
|
90
|
+
}
|
|
63
91
|
/** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
|
|
64
|
-
export function
|
|
92
|
+
export function reason(error) {
|
|
65
93
|
if (!(error instanceof BaseError))
|
|
66
94
|
return error instanceof Error ? error.message : String(error);
|
|
67
95
|
const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError);
|
|
68
96
|
if (request instanceof HttpRequestError || request instanceof TimeoutError) {
|
|
69
|
-
|
|
97
|
+
const host = hostOf(request.url);
|
|
98
|
+
const details = request.details ? excerpt(request.details) : "";
|
|
99
|
+
if (details.startsWith("<"))
|
|
100
|
+
return `${host} answered with a web page, not as an RPC`;
|
|
101
|
+
if (request instanceof HttpRequestError && request.status)
|
|
102
|
+
return `${host} answered ${request.status}${details ? ` (${details})` : ""}`;
|
|
103
|
+
return `Could not reach ${host}${details ? ` (${details})` : ""}`;
|
|
70
104
|
}
|
|
71
|
-
const name =
|
|
105
|
+
const name = revertOf(error)?.errorName;
|
|
72
106
|
return name ? `${error.shortMessage} (${name})` : error.shortMessage;
|
|
73
107
|
}
|
|
108
|
+
/** Why the BrainChain service failed its health check, or undefined when it passed. */
|
|
109
|
+
export async function unhealthy(service, fetcher = fetch) {
|
|
110
|
+
const host = new URL(service).host;
|
|
111
|
+
const response = await fetcher(`${service}/health`, { signal: AbortSignal.timeout(10_000) }).catch(() => undefined);
|
|
112
|
+
if (response === undefined)
|
|
113
|
+
return `could not reach the BrainChain service at ${host}`;
|
|
114
|
+
await response.body?.cancel();
|
|
115
|
+
return response.ok ? undefined : `the BrainChain service at ${host} answered its health check with ${response.status}`;
|
|
116
|
+
}
|
|
74
117
|
export function createChain(network, account, store = memoryStore()) {
|
|
75
118
|
const chain = chainOf(network);
|
|
76
119
|
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
|
|
@@ -150,11 +193,11 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
150
193
|
throw new Error(`Could not reach the BrainChain service at ${new URL(origin).host}`);
|
|
151
194
|
});
|
|
152
195
|
const body = (await response.json().catch(() => ({})));
|
|
153
|
-
const
|
|
196
|
+
const why = `(${response.status}): ${body.error ?? "no reason given"}`;
|
|
154
197
|
if (REFUSED.includes(response.status))
|
|
155
|
-
throw new
|
|
198
|
+
throw new PermanentError(`The service refused the result ${why}`);
|
|
156
199
|
if (!response.ok || !body.uri)
|
|
157
|
-
throw new Error(`Uploading the result failed ${
|
|
200
|
+
throw new Error(`Uploading the result failed ${why}`);
|
|
158
201
|
if (body.hash?.toLowerCase() !== hash)
|
|
159
202
|
throw new Error("The service stored different bytes than were sent");
|
|
160
203
|
if (!body.uri.startsWith(`${origin}/`))
|
|
@@ -172,7 +215,7 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
172
215
|
bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
173
216
|
stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
|
|
174
217
|
minStake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "minStake" }),
|
|
175
|
-
|
|
218
|
+
held: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "held", args: [account.address] }),
|
|
176
219
|
allowed: () => client.readContract({ ...board, functionName: "workerAllowed", args: [account.address] }),
|
|
177
220
|
bond: () => client.readContract({ address: bonds, abi: bondsAbi, functionName: "bond" }),
|
|
178
221
|
async approve(token, spender, amount) {
|
|
@@ -182,6 +225,16 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
182
225
|
await send({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, amount] });
|
|
183
226
|
},
|
|
184
227
|
claimStakeToken: () => send({ address: stakeToken, abi: erc20Abi, functionName: "faucet" }),
|
|
228
|
+
/** What the stake token's faucet gives, and from when this wallet can claim it. */
|
|
229
|
+
async faucet() {
|
|
230
|
+
const token = { address: stakeToken, abi: erc20Abi };
|
|
231
|
+
const [amount, interval, last] = await Promise.all([
|
|
232
|
+
client.readContract({ ...token, functionName: "FAUCET_AMOUNT" }),
|
|
233
|
+
client.readContract({ ...token, functionName: "FAUCET_INTERVAL" }),
|
|
234
|
+
client.readContract({ ...token, functionName: "lastClaim", args: [account.address] }),
|
|
235
|
+
]);
|
|
236
|
+
return { amount, opensAt: last === 0n ? 0n : last + interval };
|
|
237
|
+
},
|
|
185
238
|
stakeTokens: (amount, price) => send({ address: staking, abi: workerStakingAbi, functionName: "stake", args: [amount, price] }),
|
|
186
239
|
setPrice: (price) => send({ address: staking, abi: workerStakingAbi, functionName: "setPrice", args: [price] }),
|
|
187
240
|
requestUnstake: (amount) => send({ address: staking, abi: workerStakingAbi, functionName: "requestUnstake", args: [amount] }),
|
|
@@ -241,10 +294,10 @@ function publicHost(hostname) {
|
|
|
241
294
|
}
|
|
242
295
|
async function download(uri, fetcher) {
|
|
243
296
|
if (!URL.canParse(uri))
|
|
244
|
-
throw new
|
|
297
|
+
throw new PermanentError("The brief's URL is not valid");
|
|
245
298
|
const url = new URL(uri);
|
|
246
299
|
if (!publicHost(url.hostname))
|
|
247
|
-
throw new
|
|
300
|
+
throw new PermanentError(`The brief is on ${url.hostname}, which is not a public host`);
|
|
248
301
|
const stop = new AbortController();
|
|
249
302
|
const response = await fetcher(url, { redirect: "error", signal: AbortSignal.any([stop.signal, AbortSignal.timeout(15_000)]) });
|
|
250
303
|
if (!response.ok)
|
|
@@ -255,7 +308,7 @@ async function download(uri, fetcher) {
|
|
|
255
308
|
size += chunk.byteLength;
|
|
256
309
|
if (size > MAX_BRIEF_BYTES) {
|
|
257
310
|
stop.abort();
|
|
258
|
-
throw new
|
|
311
|
+
throw new PermanentError(`The brief is larger than ${MAX_BRIEF_BYTES / 1024} KB`);
|
|
259
312
|
}
|
|
260
313
|
chunks.push(chunk);
|
|
261
314
|
}
|
|
@@ -267,23 +320,23 @@ export async function fetchBrief(uri, expected, fetcher = fetch) {
|
|
|
267
320
|
if (uri.startsWith("data:")) {
|
|
268
321
|
const comma = uri.indexOf(",");
|
|
269
322
|
if (comma < 0 || !uri.slice(0, comma).endsWith(";base64"))
|
|
270
|
-
throw new
|
|
323
|
+
throw new PermanentError("The brief's data URI is not base64");
|
|
271
324
|
bytes = Uint8Array.from(Buffer.from(uri.slice(comma + 1), "base64"));
|
|
272
325
|
}
|
|
273
326
|
else if (uri.startsWith("https://")) {
|
|
274
327
|
bytes = await download(uri, fetcher);
|
|
275
328
|
}
|
|
276
329
|
else {
|
|
277
|
-
throw new
|
|
330
|
+
throw new PermanentError("The brief is neither a data URI nor an https URL");
|
|
278
331
|
}
|
|
279
332
|
if (bytes.byteLength > MAX_BRIEF_BYTES)
|
|
280
|
-
throw new
|
|
333
|
+
throw new PermanentError(`The brief is larger than ${MAX_BRIEF_BYTES / 1024} KB`);
|
|
281
334
|
if (keccak256(bytes).toLowerCase() !== expected.toLowerCase())
|
|
282
|
-
throw new
|
|
335
|
+
throw new PermanentError("The brief does not match its hash on chain");
|
|
283
336
|
try {
|
|
284
337
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
285
338
|
}
|
|
286
339
|
catch {
|
|
287
|
-
throw new
|
|
340
|
+
throw new PermanentError("The brief is not UTF-8 text");
|
|
288
341
|
}
|
|
289
342
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,10 +3,10 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { join as joinPath } from "node:path";
|
|
4
4
|
import { formatEther, formatUnits, parseUnits } from "viem";
|
|
5
5
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
6
|
-
import { ApplicationState, createChain,
|
|
6
|
+
import { ApplicationState, createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
7
7
|
import { resolveNetwork } from "./network.js";
|
|
8
8
|
import { fileStore, lock, stateDir } from "./state.js";
|
|
9
|
-
import { createWork, shellCommand } from "./work.js";
|
|
9
|
+
import { createWork, shellCommand, stopper } from "./work.js";
|
|
10
10
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
11
11
|
const USAGE = `brainchain-worker ${version}
|
|
12
12
|
|
|
@@ -15,12 +15,13 @@ Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
|
|
|
15
15
|
Usage:
|
|
16
16
|
brainchain-worker new-key Print a fresh private key and its address.
|
|
17
17
|
brainchain-worker join --name <name> --about <text> [--price <tUSDG>]
|
|
18
|
-
Stake the minimum and apply to take tasks.
|
|
18
|
+
Stake the minimum and apply to take tasks. The name is at
|
|
19
|
+
most 60 characters, the description at most 500.
|
|
19
20
|
brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
|
|
20
21
|
Do every task Jev hires you for: the command reads the
|
|
21
22
|
task on stdin and prints the deliverable on stdout, within
|
|
22
|
-
--timeout minutes (20 by default).
|
|
23
|
-
brainchain-worker status Stake, application, record and earnings.
|
|
23
|
+
--timeout minutes (20 by default, at most 1440).
|
|
24
|
+
brainchain-worker status Stake, tBRAIN balance, application, record and earnings.
|
|
24
25
|
brainchain-worker price <tUSDG> Change your asking price per task.
|
|
25
26
|
brainchain-worker leave Start unstaking (3-day cooldown).
|
|
26
27
|
brainchain-worker withdraw Withdraw stake once the cooldown has passed.
|
|
@@ -34,10 +35,11 @@ Environment:
|
|
|
34
35
|
localhost or 127.0.0.1.
|
|
35
36
|
|
|
36
37
|
The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
|
|
37
|
-
|
|
38
|
+
Unfinished work is kept in ~/.config/brainchain-worker/<address>.json across restarts, and one
|
|
38
39
|
run at a time works for each address.
|
|
39
40
|
|
|
40
41
|
Docs: https://docs.usebrainchain.com/run-a-worker`;
|
|
42
|
+
/** A mistake in the command line. Every other error is printed after the program's name. */
|
|
41
43
|
class UsageError extends Error {
|
|
42
44
|
}
|
|
43
45
|
/** The flags each command takes; `true` marks a switch, which takes no value. */
|
|
@@ -53,7 +55,7 @@ const POSITIONAL = { price: 1 };
|
|
|
53
55
|
function parse(command, args) {
|
|
54
56
|
const known = FLAGS[command];
|
|
55
57
|
if (known === undefined)
|
|
56
|
-
throw new UsageError(`Unknown command "${command}"
|
|
58
|
+
throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
|
|
57
59
|
const positional = [];
|
|
58
60
|
const values = new Map();
|
|
59
61
|
const switches = new Set();
|
|
@@ -66,7 +68,7 @@ function parse(command, args) {
|
|
|
66
68
|
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
67
69
|
const isSwitch = known[name];
|
|
68
70
|
if (isSwitch === undefined)
|
|
69
|
-
throw new UsageError(
|
|
71
|
+
throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker help)`);
|
|
70
72
|
if (isSwitch) {
|
|
71
73
|
switches.add(name);
|
|
72
74
|
continue;
|
|
@@ -77,22 +79,22 @@ function parse(command, args) {
|
|
|
77
79
|
values.set(name, value);
|
|
78
80
|
}
|
|
79
81
|
if (positional.length > (POSITIONAL[command] ?? 0))
|
|
80
|
-
throw new UsageError(`Unexpected "${positional.at(-1)}"
|
|
82
|
+
throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker help)`);
|
|
81
83
|
return { positional, values, switches };
|
|
82
84
|
}
|
|
83
85
|
function signer() {
|
|
84
86
|
const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
|
|
85
87
|
if (!raw)
|
|
86
|
-
throw new
|
|
88
|
+
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
|
|
87
89
|
const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
|
|
88
90
|
if (!/^[0-9a-fA-F]{64}$/.test(hex))
|
|
89
|
-
throw new
|
|
91
|
+
throw new Error("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
|
|
90
92
|
const key = `0x${hex.toLowerCase()}`;
|
|
91
93
|
try {
|
|
92
94
|
return { key, account: privateKeyToAccount(key) };
|
|
93
95
|
}
|
|
94
96
|
catch {
|
|
95
|
-
throw new
|
|
97
|
+
throw new Error("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
const DEFAULT_PRICE = "0.20";
|
|
@@ -106,19 +108,39 @@ function price(value) {
|
|
|
106
108
|
const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
|
|
107
109
|
const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
|
|
108
110
|
const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
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 ` +
|
|
111
|
+
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
112
|
+
function noGas(chain, network) {
|
|
113
|
+
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 ` +
|
|
113
114
|
`and bridge some at ${network.explorer}; 0.001 ETH is plenty.`);
|
|
114
115
|
}
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
async function needGas(chain, network) {
|
|
117
|
+
if ((await chain.ethBalance()) === 0n)
|
|
118
|
+
throw noGas(chain, network);
|
|
119
|
+
}
|
|
120
|
+
/** The worker's standing with the board and the staking contract, and what join would do about it. */
|
|
121
|
+
async function snapshot(chain) {
|
|
117
122
|
const [allowed, w, min, { applications, change }] = await Promise.all([chain.allowed(), chain.stake(), chain.minStake(), chain.applications()]);
|
|
123
|
+
const pending = change !== undefined && !change.executed && !change.vetoed;
|
|
124
|
+
const open = applications.find((a) => a.state === ApplicationState.Open);
|
|
125
|
+
return {
|
|
126
|
+
allowed,
|
|
127
|
+
w,
|
|
128
|
+
min,
|
|
129
|
+
applications,
|
|
130
|
+
change,
|
|
131
|
+
pending,
|
|
132
|
+
open,
|
|
133
|
+
applying: !allowed && !pending && open === undefined,
|
|
134
|
+
// Below the minimum after leave or a slash, an allowed worker cannot be hired until it stakes again.
|
|
135
|
+
toStake: w.staked >= min ? 0n : min - w.staked,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Why the worker can or cannot be hired right now, in one line. */
|
|
139
|
+
function hireable({ allowed, w, min, applications, change, pending }, network) {
|
|
118
140
|
if (allowed)
|
|
119
|
-
return w.staked >= min ? "yes" : `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again.`;
|
|
141
|
+
return w.staked >= min ? "yes" : `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again; status shows what it needs.`;
|
|
120
142
|
// An allowed application waiting out its timelock outranks anything filed after it.
|
|
121
|
-
if (change &&
|
|
143
|
+
if (change && pending)
|
|
122
144
|
return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
|
|
123
145
|
const last = applications.at(-1);
|
|
124
146
|
if (last === undefined)
|
|
@@ -135,27 +157,38 @@ async function hireable(chain, network) {
|
|
|
135
157
|
return `no: application ${last.id} closed without a decision; its bond was returned. Run join to apply again.`;
|
|
136
158
|
}
|
|
137
159
|
async function join(chain, network, values) {
|
|
138
|
-
const name = values.get("name")?.trim();
|
|
160
|
+
const name = values.get("name")?.trim() ?? "";
|
|
139
161
|
const about = values.get("about")?.trim() ?? "";
|
|
140
|
-
if (!name
|
|
162
|
+
if (!name)
|
|
141
163
|
throw new UsageError("--name is required, up to 60 characters");
|
|
142
|
-
if (
|
|
143
|
-
throw new UsageError("--
|
|
164
|
+
if (name.length > 60)
|
|
165
|
+
throw new UsageError("--name is at most 60 characters");
|
|
166
|
+
if (!about) {
|
|
167
|
+
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");
|
|
144
168
|
}
|
|
169
|
+
if (about.length > 500)
|
|
170
|
+
throw new UsageError("--about is at most 500 characters");
|
|
145
171
|
const requested = values.has("price") ? price(values.get("price")) : undefined;
|
|
146
172
|
await needGas(chain, network);
|
|
147
|
-
const
|
|
148
|
-
const pending = change !== undefined && !change.executed && !change.vetoed;
|
|
149
|
-
const open = applications.find((a) => a.state === ApplicationState.Open);
|
|
150
|
-
const applying = !allowed && !pending && open === undefined;
|
|
173
|
+
const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
|
|
151
174
|
// A worker that has staked keeps its price unless --price changes it.
|
|
152
175
|
const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
|
|
153
|
-
// Below the minimum after leave or a slash, an allowed worker cannot be hired until it stakes again.
|
|
154
|
-
const toStake = w.staked >= min ? 0n : min - w.staked;
|
|
155
176
|
const [bond, balance] = await Promise.all([chain.bond(), chain.stakeBalance()]);
|
|
156
|
-
|
|
177
|
+
const need = toStake + (applying ? bond : 0n);
|
|
178
|
+
if (balance < need) {
|
|
179
|
+
const closed = (opensAt) => {
|
|
180
|
+
const unstaking = w.unstaking > 0n ? `, or run withdraw after ${when(w.unlocksAt)} and join with the ${stake(network, w.unstaking)} it returns` : "";
|
|
181
|
+
return new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs, and the faucet allows one claim a day: ` +
|
|
182
|
+
`this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
|
|
183
|
+
};
|
|
184
|
+
const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
|
|
185
|
+
if (now < faucet.opensAt)
|
|
186
|
+
throw closed(faucet.opensAt);
|
|
157
187
|
console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
|
|
158
|
-
await chain.claimStakeToken()
|
|
188
|
+
await chain.claimStakeToken().catch((error) => {
|
|
189
|
+
const opensAt = faucetReopens(error);
|
|
190
|
+
throw opensAt === undefined ? error : closed(opensAt);
|
|
191
|
+
});
|
|
159
192
|
}
|
|
160
193
|
if (toStake > 0n) {
|
|
161
194
|
console.log(`Staking ${stake(network, toStake)}${w.staked > 0n ? " more, up to the minimum," : ""} at ${usdg(network, asking)} per task...`);
|
|
@@ -167,11 +200,11 @@ async function join(chain, network, values) {
|
|
|
167
200
|
await chain.setPrice(asking);
|
|
168
201
|
}
|
|
169
202
|
if (allowed) {
|
|
170
|
-
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
|
|
203
|
+
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}.`);
|
|
171
204
|
return;
|
|
172
205
|
}
|
|
173
|
-
if (pending) {
|
|
174
|
-
console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep
|
|
206
|
+
if (change && pending) {
|
|
207
|
+
console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep ${RUN} going until then.`);
|
|
175
208
|
return;
|
|
176
209
|
}
|
|
177
210
|
if (open) {
|
|
@@ -183,26 +216,43 @@ async function join(chain, network, values) {
|
|
|
183
216
|
const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
|
|
184
217
|
await chain.apply(`data:application/json;base64,${profile}`);
|
|
185
218
|
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" +
|
|
186
|
-
|
|
187
|
-
"with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
219
|
+
`Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
|
|
220
|
+
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
221
|
+
}
|
|
222
|
+
/** The wallet's tBRAIN and, below the minimum stake, where join would find what it needs. */
|
|
223
|
+
function wallet(network, s, balance, bond, faucet, now) {
|
|
224
|
+
const has = stake(network, balance);
|
|
225
|
+
if (s.toStake === 0n)
|
|
226
|
+
return has;
|
|
227
|
+
const need = s.toStake + (s.applying ? bond : 0n);
|
|
228
|
+
if (balance >= need)
|
|
229
|
+
return `${has}, enough for the ${stake(network, need)} join needs`;
|
|
230
|
+
if (now >= faucet.opensAt)
|
|
231
|
+
return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
|
|
232
|
+
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)}`}` : "";
|
|
233
|
+
return `${has}; join needs ${stake(network, need)}. The faucet opens again for this wallet at ${when(faucet.opensAt)}${unstaking}`;
|
|
188
234
|
}
|
|
189
235
|
async function status(chain, network) {
|
|
190
|
-
const [gas,
|
|
236
|
+
const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
191
237
|
chain.ethBalance(),
|
|
192
|
-
chain
|
|
193
|
-
chain.
|
|
194
|
-
chain.inFlight(),
|
|
238
|
+
snapshot(chain),
|
|
239
|
+
chain.held(),
|
|
195
240
|
chain.record(),
|
|
196
241
|
chain.bountyBalance(),
|
|
197
|
-
|
|
242
|
+
chain.stakeBalance(),
|
|
243
|
+
chain.bond(),
|
|
244
|
+
chain.faucet(),
|
|
245
|
+
chain.now(),
|
|
198
246
|
]);
|
|
247
|
+
const { w, min } = s;
|
|
199
248
|
const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
|
|
200
249
|
console.log(`Worker ${chain.account} on ${network.name}
|
|
201
|
-
Hireable ${
|
|
250
|
+
Hireable ${hireable(s, network)}
|
|
202
251
|
Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
|
|
252
|
+
Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
|
|
203
253
|
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
204
254
|
Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
|
|
205
|
-
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network,
|
|
255
|
+
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
|
|
206
256
|
Gas ${formatEther(gas)} ETH`);
|
|
207
257
|
}
|
|
208
258
|
async function run(network, { key, account }, values, once) {
|
|
@@ -220,25 +270,9 @@ async function run(network, { key, account }, values, once) {
|
|
|
220
270
|
lock(`${file}.lock`);
|
|
221
271
|
const store = fileStore(`${file}.json`, network.contracts.taskBoard);
|
|
222
272
|
const chain = createChain(network, account, store);
|
|
223
|
-
const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)} ${message}`);
|
|
224
|
-
await needGas(chain, network).catch((error) => {
|
|
225
|
-
if (error instanceof UsageError)
|
|
226
|
-
throw error;
|
|
227
|
-
log(`could not read the wallet's ETH balance: ${describe(error)}`);
|
|
228
|
-
});
|
|
273
|
+
const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)}Z ${message}`);
|
|
229
274
|
const command = shellCommand(exec);
|
|
230
|
-
|
|
231
|
-
const stop = (signal) => {
|
|
232
|
-
if (stopping) {
|
|
233
|
-
command.kill();
|
|
234
|
-
log(`${signal} again: stopped the command`);
|
|
235
|
-
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
236
|
-
}
|
|
237
|
-
stopping = true;
|
|
238
|
-
log(`${signal}: stopping after the current task; send ${signal} again to stop now`);
|
|
239
|
-
};
|
|
240
|
-
process.on("SIGINT", stop);
|
|
241
|
-
process.on("SIGTERM", stop);
|
|
275
|
+
const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code) });
|
|
242
276
|
const work = createWork({
|
|
243
277
|
chain,
|
|
244
278
|
command,
|
|
@@ -247,10 +281,23 @@ async function run(network, { key, account }, values, once) {
|
|
|
247
281
|
timeoutMs: Number(minutes) * 60_000,
|
|
248
282
|
secrets: [key, key.slice(2)],
|
|
249
283
|
store,
|
|
250
|
-
stopping:
|
|
284
|
+
stopping: stop.stopping,
|
|
251
285
|
});
|
|
252
|
-
|
|
286
|
+
process.on("SIGINT", stop.signal);
|
|
287
|
+
process.on("SIGTERM", stop.signal);
|
|
288
|
+
const gas = await chain.ethBalance().catch((error) => {
|
|
289
|
+
log(`could not read the wallet's ETH balance: ${reason(error)}`);
|
|
290
|
+
return undefined;
|
|
291
|
+
});
|
|
292
|
+
if (gas === 0n)
|
|
293
|
+
throw noGas(chain, network);
|
|
294
|
+
const [line, sick] = await Promise.all([
|
|
295
|
+
snapshot(chain).then((s) => hireable(s, network), (error) => `could not check (${reason(error)})`),
|
|
296
|
+
unhealthy(network.service),
|
|
297
|
+
]);
|
|
253
298
|
log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
|
|
299
|
+
if (sick)
|
|
300
|
+
log(`warning: ${sick}; results are uploaded there, so none can be delivered until it answers`);
|
|
254
301
|
if (once) {
|
|
255
302
|
const result = await work.round();
|
|
256
303
|
if (result.hired === 0)
|
|
@@ -261,11 +308,12 @@ async function run(network, { key, account }, values, once) {
|
|
|
261
308
|
return;
|
|
262
309
|
}
|
|
263
310
|
log("waiting for tasks (Ctrl-C to stop)");
|
|
264
|
-
while (!stopping) {
|
|
265
|
-
await work.round().catch((error) => log(`round failed: ${
|
|
266
|
-
for (let i = 0; i < 15 && !stopping; i++)
|
|
311
|
+
while (!stop.stopping()) {
|
|
312
|
+
await work.round().catch((error) => log(`round failed: ${reason(error)}`));
|
|
313
|
+
for (let i = 0; i < 15 && !stop.stopping(); i++)
|
|
267
314
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
268
315
|
}
|
|
316
|
+
log("stopped");
|
|
269
317
|
}
|
|
270
318
|
async function main(argv) {
|
|
271
319
|
const [command, ...rest] = argv;
|
|
@@ -298,7 +346,7 @@ async function main(argv) {
|
|
|
298
346
|
throw new UsageError("Usage: brainchain-worker price <tUSDG>, such as price 0.25");
|
|
299
347
|
const asking = price(positional[0]);
|
|
300
348
|
if (!(await chain.stake()).registered)
|
|
301
|
-
throw new
|
|
349
|
+
throw new Error("This worker has not staked yet. Run join, which sets the price too.");
|
|
302
350
|
await needGas(chain, network);
|
|
303
351
|
await chain.setPrice(asking);
|
|
304
352
|
console.log(`Price set to ${usdg(network, asking)} per task.`);
|
|
@@ -307,7 +355,7 @@ async function main(argv) {
|
|
|
307
355
|
case "leave": {
|
|
308
356
|
const w = await chain.stake();
|
|
309
357
|
if (w.staked === 0n) {
|
|
310
|
-
throw new
|
|
358
|
+
throw new Error(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
|
|
311
359
|
}
|
|
312
360
|
await needGas(chain, network);
|
|
313
361
|
await chain.requestUnstake(w.staked);
|
|
@@ -316,13 +364,13 @@ async function main(argv) {
|
|
|
316
364
|
return;
|
|
317
365
|
}
|
|
318
366
|
case "withdraw": {
|
|
319
|
-
const [w, held, now] = await Promise.all([chain.stake(), chain.
|
|
367
|
+
const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
|
|
320
368
|
if (w.unstaking === 0n)
|
|
321
|
-
throw new
|
|
369
|
+
throw new Error("Nothing is unstaking. Run leave first.");
|
|
322
370
|
if (now < w.unlocksAt)
|
|
323
|
-
throw new
|
|
371
|
+
throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
|
|
324
372
|
if (held > 0)
|
|
325
|
-
throw new
|
|
373
|
+
throw new Error(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
|
|
326
374
|
await needGas(chain, network);
|
|
327
375
|
await chain.withdraw();
|
|
328
376
|
console.log(`Withdrew ${stake(network, w.unstaking)}.`);
|
|
@@ -331,6 +379,6 @@ async function main(argv) {
|
|
|
331
379
|
}
|
|
332
380
|
}
|
|
333
381
|
main(process.argv.slice(2)).catch((error) => {
|
|
334
|
-
console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${
|
|
382
|
+
console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${reason(error)}`);
|
|
335
383
|
process.exit(1);
|
|
336
384
|
});
|
package/dist/network.js
CHANGED
|
@@ -26,10 +26,17 @@ 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 } : {}),
|
|
29
|
+
...(env.BRAINCHAIN_RPC_URL ? { rpc: rpcURL(env.BRAINCHAIN_RPC_URL.trim()) } : {}),
|
|
30
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
|
+
}
|
|
33
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. */
|
|
34
41
|
function serviceURL(value) {
|
|
35
42
|
const url = URL.canParse(value) ? new URL(value) : undefined;
|
package/dist/work.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { toBytes } from "viem";
|
|
3
|
-
import {
|
|
3
|
+
import { fetchBrief, PermanentError, reason } from "./chain.js";
|
|
4
4
|
import { memoryStore } from "./state.js";
|
|
5
5
|
/** The most the service accepts for one result. */
|
|
6
6
|
export const MAX_RESULT_BYTES = 100_000;
|
|
@@ -22,7 +22,7 @@ export function parseBrief(text) {
|
|
|
22
22
|
value = JSON.parse(text);
|
|
23
23
|
}
|
|
24
24
|
catch {
|
|
25
|
-
throw new
|
|
25
|
+
throw new PermanentError("The brief is not JSON");
|
|
26
26
|
}
|
|
27
27
|
if (typeof value !== "object" ||
|
|
28
28
|
value === null ||
|
|
@@ -32,7 +32,7 @@ export function parseBrief(text) {
|
|
|
32
32
|
!value.levels.every((l) => typeof l === "string") ||
|
|
33
33
|
typeof value.passIndex !== "number" ||
|
|
34
34
|
value.levels[value.passIndex] === undefined) {
|
|
35
|
-
throw new
|
|
35
|
+
throw new PermanentError("The brief is not a BrainChain brief");
|
|
36
36
|
}
|
|
37
37
|
return value;
|
|
38
38
|
}
|
|
@@ -99,10 +99,10 @@ export function shellCommand(command, graceMs = 10_000) {
|
|
|
99
99
|
let size = 0;
|
|
100
100
|
let stopped;
|
|
101
101
|
let force;
|
|
102
|
-
const stop = (
|
|
102
|
+
const stop = (why) => {
|
|
103
103
|
if (stopped !== undefined || pid === undefined)
|
|
104
104
|
return;
|
|
105
|
-
stopped =
|
|
105
|
+
stopped = why;
|
|
106
106
|
signalGroup(pid, "SIGTERM");
|
|
107
107
|
force = setTimeout(() => {
|
|
108
108
|
signalGroup(pid, "SIGKILL");
|
|
@@ -150,6 +150,38 @@ export function shellCommand(command, graceMs = 10_000) {
|
|
|
150
150
|
},
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
|
+
/** Signals closer together than this are one: npm passes the terminal's Ctrl-C on to the worker, which gets its own too. */
|
|
154
|
+
const ECHO_MS = 1_500;
|
|
155
|
+
/**
|
|
156
|
+
* What Ctrl-C and SIGTERM do to `run`. An idle worker stops at once. A busy one
|
|
157
|
+
* takes no new task and stops after the current one, unless a second signal
|
|
158
|
+
* comes, which stops the command too.
|
|
159
|
+
*/
|
|
160
|
+
export function stopper(options) {
|
|
161
|
+
let stopping = false;
|
|
162
|
+
let counted = -Infinity;
|
|
163
|
+
return {
|
|
164
|
+
stopping: () => stopping,
|
|
165
|
+
signal(signal) {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
if (now - counted < ECHO_MS)
|
|
168
|
+
return;
|
|
169
|
+
counted = now;
|
|
170
|
+
const name = signal === "SIGINT" ? "Ctrl-C" : signal;
|
|
171
|
+
if (stopping) {
|
|
172
|
+
options.kill();
|
|
173
|
+
options.log(`${name} again: stopping now`);
|
|
174
|
+
return options.exit(signal === "SIGINT" ? 130 : 143);
|
|
175
|
+
}
|
|
176
|
+
if (!options.busy()) {
|
|
177
|
+
options.log(`${name}: stopping`);
|
|
178
|
+
return options.exit(0);
|
|
179
|
+
}
|
|
180
|
+
stopping = true;
|
|
181
|
+
options.log(`${name}: stopping after the current task; ${signal === "SIGINT" ? "press Ctrl-C" : `send ${signal}`} again to stop now`);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
153
185
|
/**
|
|
154
186
|
* Delivers every task this worker is hired for, earliest deadline first: read
|
|
155
187
|
* the brief, run the command, upload and submit the result. A failure is
|
|
@@ -164,6 +196,7 @@ export function createWork(options) {
|
|
|
164
196
|
const secrets = (options.secrets ?? []).filter((s) => s !== "").map((s) => s.toLowerCase());
|
|
165
197
|
const log = (message) => options.log(printable(message));
|
|
166
198
|
const wall = () => BigInt(Math.floor(Date.now() / 1000));
|
|
199
|
+
let busy = false;
|
|
167
200
|
const finish = (key) => {
|
|
168
201
|
delete state.tasks[key];
|
|
169
202
|
if (!state.done.includes(key))
|
|
@@ -176,9 +209,11 @@ export function createWork(options) {
|
|
|
176
209
|
};
|
|
177
210
|
const inTime = async (task) => {
|
|
178
211
|
if ((await chain.now()) >= task.deadline)
|
|
179
|
-
throw new
|
|
212
|
+
throw new PermanentError("The deadline passed before the result could be submitted");
|
|
180
213
|
};
|
|
181
214
|
return {
|
|
215
|
+
/** True while a task is in hand, from running its command to submitting the result. */
|
|
216
|
+
busy: () => busy,
|
|
182
217
|
async round() {
|
|
183
218
|
const hired = (await chain.hired()).sort((a, b) => (a.deadline < b.deadline ? -1 : a.deadline > b.deadline ? 1 : 0));
|
|
184
219
|
const result = { hired: hired.length, delivered: 0, failed: 0 };
|
|
@@ -204,6 +239,7 @@ export function createWork(options) {
|
|
|
204
239
|
log(`task ${task.id}: too close to the deadline to deliver; the poster is refunded`);
|
|
205
240
|
continue;
|
|
206
241
|
}
|
|
242
|
+
busy = true;
|
|
207
243
|
if (attempt.result === undefined) {
|
|
208
244
|
const brief = parseBrief(await read(task.briefURI, task.briefHash));
|
|
209
245
|
await needGas();
|
|
@@ -215,7 +251,7 @@ export function createWork(options) {
|
|
|
215
251
|
throw new Error("The command printed nothing");
|
|
216
252
|
const lower = output.toLowerCase();
|
|
217
253
|
if (secrets.some((secret) => lower.includes(secret))) {
|
|
218
|
-
throw new
|
|
254
|
+
throw new PermanentError("The result contains the worker's private key, so it was not uploaded. Check what the command can read");
|
|
219
255
|
}
|
|
220
256
|
const bytes = toBytes(output).byteLength;
|
|
221
257
|
if (bytes > MAX_RESULT_BYTES)
|
|
@@ -238,10 +274,10 @@ export function createWork(options) {
|
|
|
238
274
|
}
|
|
239
275
|
catch (error) {
|
|
240
276
|
result.failed += 1;
|
|
241
|
-
const
|
|
242
|
-
if (error instanceof
|
|
277
|
+
const why = reason(error);
|
|
278
|
+
if (error instanceof PermanentError) {
|
|
243
279
|
finish(key);
|
|
244
|
-
log(`task ${task.id}: failed (${
|
|
280
|
+
log(`task ${task.id}: failed (${why}); no retry can fix that, so it is not tried again`);
|
|
245
281
|
continue;
|
|
246
282
|
}
|
|
247
283
|
attempt.failures += 1;
|
|
@@ -250,13 +286,16 @@ export function createWork(options) {
|
|
|
250
286
|
const now = await chain.now().catch(wall);
|
|
251
287
|
if (now + wait + MARGIN_SEC >= task.deadline) {
|
|
252
288
|
finish(key);
|
|
253
|
-
log(`task ${task.id}: failed (${
|
|
289
|
+
log(`task ${task.id}: failed (${why}); no time left to retry, so the poster is refunded`);
|
|
254
290
|
continue;
|
|
255
291
|
}
|
|
256
292
|
attempt.nextAt = Number(now + wait);
|
|
257
293
|
state.tasks[key] = attempt;
|
|
258
294
|
store.save();
|
|
259
|
-
log(`task ${task.id}: failed (${
|
|
295
|
+
log(`task ${task.id}: failed (${why}); retrying in ${wait} seconds`);
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
busy = false;
|
|
260
299
|
}
|
|
261
300
|
}
|
|
262
301
|
return result;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Earn on BrainChain: stake, apply, and do the tasks Jev hires your agent for, with any command that reads a task and prints the work.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BrainChain <hello@usebrainchain.com>",
|