brainchain-worker 0.2.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 +12 -11
- package/dist/chain.js +70 -22
- package/dist/index.js +115 -72
- 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. 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,66 @@ 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 details = request.details ? excerpt(request.details) : "";
|
|
98
|
+
return `Could not reach ${hostOf(request.url)}${details ? ` (${details})` : ""}`;
|
|
70
99
|
}
|
|
71
|
-
const name =
|
|
100
|
+
const name = revertOf(error)?.errorName;
|
|
72
101
|
return name ? `${error.shortMessage} (${name})` : error.shortMessage;
|
|
73
102
|
}
|
|
103
|
+
/** Why the BrainChain service failed its health check, or undefined when it passed. */
|
|
104
|
+
export async function unhealthy(service, fetcher = fetch) {
|
|
105
|
+
const host = new URL(service).host;
|
|
106
|
+
const response = await fetcher(`${service}/health`, { signal: AbortSignal.timeout(10_000) }).catch(() => undefined);
|
|
107
|
+
if (response === undefined)
|
|
108
|
+
return `could not reach the BrainChain service at ${host}`;
|
|
109
|
+
await response.body?.cancel();
|
|
110
|
+
return response.ok ? undefined : `the BrainChain service at ${host} answered its health check with ${response.status}`;
|
|
111
|
+
}
|
|
74
112
|
export function createChain(network, account, store = memoryStore()) {
|
|
75
113
|
const chain = chainOf(network);
|
|
76
114
|
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
|
|
@@ -150,11 +188,11 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
150
188
|
throw new Error(`Could not reach the BrainChain service at ${new URL(origin).host}`);
|
|
151
189
|
});
|
|
152
190
|
const body = (await response.json().catch(() => ({})));
|
|
153
|
-
const
|
|
191
|
+
const why = `(${response.status}): ${body.error ?? "no reason given"}`;
|
|
154
192
|
if (REFUSED.includes(response.status))
|
|
155
|
-
throw new
|
|
193
|
+
throw new PermanentError(`The service refused the result ${why}`);
|
|
156
194
|
if (!response.ok || !body.uri)
|
|
157
|
-
throw new Error(`Uploading the result failed ${
|
|
195
|
+
throw new Error(`Uploading the result failed ${why}`);
|
|
158
196
|
if (body.hash?.toLowerCase() !== hash)
|
|
159
197
|
throw new Error("The service stored different bytes than were sent");
|
|
160
198
|
if (!body.uri.startsWith(`${origin}/`))
|
|
@@ -172,7 +210,7 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
172
210
|
bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
173
211
|
stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
|
|
174
212
|
minStake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "minStake" }),
|
|
175
|
-
|
|
213
|
+
held: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "held", args: [account.address] }),
|
|
176
214
|
allowed: () => client.readContract({ ...board, functionName: "workerAllowed", args: [account.address] }),
|
|
177
215
|
bond: () => client.readContract({ address: bonds, abi: bondsAbi, functionName: "bond" }),
|
|
178
216
|
async approve(token, spender, amount) {
|
|
@@ -182,6 +220,16 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
182
220
|
await send({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, amount] });
|
|
183
221
|
},
|
|
184
222
|
claimStakeToken: () => send({ address: stakeToken, abi: erc20Abi, functionName: "faucet" }),
|
|
223
|
+
/** What the stake token's faucet gives, and from when this wallet can claim it. */
|
|
224
|
+
async faucet() {
|
|
225
|
+
const token = { address: stakeToken, abi: erc20Abi };
|
|
226
|
+
const [amount, interval, last] = await Promise.all([
|
|
227
|
+
client.readContract({ ...token, functionName: "FAUCET_AMOUNT" }),
|
|
228
|
+
client.readContract({ ...token, functionName: "FAUCET_INTERVAL" }),
|
|
229
|
+
client.readContract({ ...token, functionName: "lastClaim", args: [account.address] }),
|
|
230
|
+
]);
|
|
231
|
+
return { amount, opensAt: last === 0n ? 0n : last + interval };
|
|
232
|
+
},
|
|
185
233
|
stakeTokens: (amount, price) => send({ address: staking, abi: workerStakingAbi, functionName: "stake", args: [amount, price] }),
|
|
186
234
|
setPrice: (price) => send({ address: staking, abi: workerStakingAbi, functionName: "setPrice", args: [price] }),
|
|
187
235
|
requestUnstake: (amount) => send({ address: staking, abi: workerStakingAbi, functionName: "requestUnstake", args: [amount] }),
|
|
@@ -241,10 +289,10 @@ function publicHost(hostname) {
|
|
|
241
289
|
}
|
|
242
290
|
async function download(uri, fetcher) {
|
|
243
291
|
if (!URL.canParse(uri))
|
|
244
|
-
throw new
|
|
292
|
+
throw new PermanentError("The brief's URL is not valid");
|
|
245
293
|
const url = new URL(uri);
|
|
246
294
|
if (!publicHost(url.hostname))
|
|
247
|
-
throw new
|
|
295
|
+
throw new PermanentError(`The brief is on ${url.hostname}, which is not a public host`);
|
|
248
296
|
const stop = new AbortController();
|
|
249
297
|
const response = await fetcher(url, { redirect: "error", signal: AbortSignal.any([stop.signal, AbortSignal.timeout(15_000)]) });
|
|
250
298
|
if (!response.ok)
|
|
@@ -255,7 +303,7 @@ async function download(uri, fetcher) {
|
|
|
255
303
|
size += chunk.byteLength;
|
|
256
304
|
if (size > MAX_BRIEF_BYTES) {
|
|
257
305
|
stop.abort();
|
|
258
|
-
throw new
|
|
306
|
+
throw new PermanentError(`The brief is larger than ${MAX_BRIEF_BYTES / 1024} KB`);
|
|
259
307
|
}
|
|
260
308
|
chunks.push(chunk);
|
|
261
309
|
}
|
|
@@ -267,23 +315,23 @@ export async function fetchBrief(uri, expected, fetcher = fetch) {
|
|
|
267
315
|
if (uri.startsWith("data:")) {
|
|
268
316
|
const comma = uri.indexOf(",");
|
|
269
317
|
if (comma < 0 || !uri.slice(0, comma).endsWith(";base64"))
|
|
270
|
-
throw new
|
|
318
|
+
throw new PermanentError("The brief's data URI is not base64");
|
|
271
319
|
bytes = Uint8Array.from(Buffer.from(uri.slice(comma + 1), "base64"));
|
|
272
320
|
}
|
|
273
321
|
else if (uri.startsWith("https://")) {
|
|
274
322
|
bytes = await download(uri, fetcher);
|
|
275
323
|
}
|
|
276
324
|
else {
|
|
277
|
-
throw new
|
|
325
|
+
throw new PermanentError("The brief is neither a data URI nor an https URL");
|
|
278
326
|
}
|
|
279
327
|
if (bytes.byteLength > MAX_BRIEF_BYTES)
|
|
280
|
-
throw new
|
|
328
|
+
throw new PermanentError(`The brief is larger than ${MAX_BRIEF_BYTES / 1024} KB`);
|
|
281
329
|
if (keccak256(bytes).toLowerCase() !== expected.toLowerCase())
|
|
282
|
-
throw new
|
|
330
|
+
throw new PermanentError("The brief does not match its hash on chain");
|
|
283
331
|
try {
|
|
284
332
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
285
333
|
}
|
|
286
334
|
catch {
|
|
287
|
-
throw new
|
|
335
|
+
throw new PermanentError("The brief is not UTF-8 text");
|
|
288
336
|
}
|
|
289
337
|
}
|
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
|
|
|
@@ -19,8 +19,8 @@ Usage:
|
|
|
19
19
|
brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
|
|
20
20
|
Do every task Jev hires you for: the command reads the
|
|
21
21
|
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.
|
|
22
|
+
--timeout minutes (20 by default, at most 1440).
|
|
23
|
+
brainchain-worker status Stake, tBRAIN balance, application, record and earnings.
|
|
24
24
|
brainchain-worker price <tUSDG> Change your asking price per task.
|
|
25
25
|
brainchain-worker leave Start unstaking (3-day cooldown).
|
|
26
26
|
brainchain-worker withdraw Withdraw stake once the cooldown has passed.
|
|
@@ -34,10 +34,11 @@ Environment:
|
|
|
34
34
|
localhost or 127.0.0.1.
|
|
35
35
|
|
|
36
36
|
The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
|
|
37
|
-
|
|
37
|
+
Unfinished work is kept in ~/.config/brainchain-worker/<address>.json across restarts, and one
|
|
38
38
|
run at a time works for each address.
|
|
39
39
|
|
|
40
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. */
|
|
41
42
|
class UsageError extends Error {
|
|
42
43
|
}
|
|
43
44
|
/** The flags each command takes; `true` marks a switch, which takes no value. */
|
|
@@ -53,7 +54,7 @@ const POSITIONAL = { price: 1 };
|
|
|
53
54
|
function parse(command, args) {
|
|
54
55
|
const known = FLAGS[command];
|
|
55
56
|
if (known === undefined)
|
|
56
|
-
throw new UsageError(`Unknown command "${command}"
|
|
57
|
+
throw new UsageError(`Unknown command "${command}" (see brainchain-worker help)`);
|
|
57
58
|
const positional = [];
|
|
58
59
|
const values = new Map();
|
|
59
60
|
const switches = new Set();
|
|
@@ -66,7 +67,7 @@ function parse(command, args) {
|
|
|
66
67
|
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
67
68
|
const isSwitch = known[name];
|
|
68
69
|
if (isSwitch === undefined)
|
|
69
|
-
throw new UsageError(
|
|
70
|
+
throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker help)`);
|
|
70
71
|
if (isSwitch) {
|
|
71
72
|
switches.add(name);
|
|
72
73
|
continue;
|
|
@@ -77,22 +78,22 @@ function parse(command, args) {
|
|
|
77
78
|
values.set(name, value);
|
|
78
79
|
}
|
|
79
80
|
if (positional.length > (POSITIONAL[command] ?? 0))
|
|
80
|
-
throw new UsageError(`Unexpected "${positional.at(-1)}"
|
|
81
|
+
throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker help)`);
|
|
81
82
|
return { positional, values, switches };
|
|
82
83
|
}
|
|
83
84
|
function signer() {
|
|
84
85
|
const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
|
|
85
86
|
if (!raw)
|
|
86
|
-
throw new
|
|
87
|
+
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
|
|
87
88
|
const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
|
|
88
89
|
if (!/^[0-9a-fA-F]{64}$/.test(hex))
|
|
89
|
-
throw new
|
|
90
|
+
throw new Error("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
|
|
90
91
|
const key = `0x${hex.toLowerCase()}`;
|
|
91
92
|
try {
|
|
92
93
|
return { key, account: privateKeyToAccount(key) };
|
|
93
94
|
}
|
|
94
95
|
catch {
|
|
95
|
-
throw new
|
|
96
|
+
throw new Error("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
const DEFAULT_PRICE = "0.20";
|
|
@@ -106,19 +107,39 @@ function price(value) {
|
|
|
106
107
|
const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
|
|
107
108
|
const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
|
|
108
109
|
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 ` +
|
|
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 ` +
|
|
113
113
|
`and bridge some at ${network.explorer}; 0.001 ETH is plenty.`);
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
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) {
|
|
117
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) {
|
|
118
139
|
if (allowed)
|
|
119
140
|
return w.staked >= min ? "yes" : `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again.`;
|
|
120
141
|
// An allowed application waiting out its timelock outranks anything filed after it.
|
|
121
|
-
if (change &&
|
|
142
|
+
if (change && pending)
|
|
122
143
|
return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
|
|
123
144
|
const last = applications.at(-1);
|
|
124
145
|
if (last === undefined)
|
|
@@ -135,27 +156,34 @@ async function hireable(chain, network) {
|
|
|
135
156
|
return `no: application ${last.id} closed without a decision; its bond was returned. Run join to apply again.`;
|
|
136
157
|
}
|
|
137
158
|
async function join(chain, network, values) {
|
|
138
|
-
const name = values.get("name")?.trim();
|
|
159
|
+
const name = values.get("name")?.trim() ?? "";
|
|
139
160
|
const about = values.get("about")?.trim() ?? "";
|
|
140
|
-
if (!name
|
|
161
|
+
if (!name)
|
|
141
162
|
throw new UsageError("--name is required, up to 60 characters");
|
|
142
|
-
if (
|
|
143
|
-
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");
|
|
144
167
|
}
|
|
168
|
+
if (about.length > 500)
|
|
169
|
+
throw new UsageError("--about is at most 500 characters");
|
|
145
170
|
const requested = values.has("price") ? price(values.get("price")) : undefined;
|
|
146
171
|
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;
|
|
172
|
+
const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
|
|
151
173
|
// A worker that has staked keeps its price unless --price changes it.
|
|
152
174
|
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
175
|
const [bond, balance] = await Promise.all([chain.bond(), chain.stakeBalance()]);
|
|
156
|
-
|
|
176
|
+
const need = toStake + (applying ? bond : 0n);
|
|
177
|
+
if (balance < need) {
|
|
157
178
|
console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
|
|
158
|
-
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
|
+
});
|
|
159
187
|
}
|
|
160
188
|
if (toStake > 0n) {
|
|
161
189
|
console.log(`Staking ${stake(network, toStake)}${w.staked > 0n ? " more, up to the minimum," : ""} at ${usdg(network, asking)} per task...`);
|
|
@@ -167,11 +195,11 @@ async function join(chain, network, values) {
|
|
|
167
195
|
await chain.setPrice(asking);
|
|
168
196
|
}
|
|
169
197
|
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
|
|
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}.`);
|
|
171
199
|
return;
|
|
172
200
|
}
|
|
173
|
-
if (pending) {
|
|
174
|
-
console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep
|
|
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.`);
|
|
175
203
|
return;
|
|
176
204
|
}
|
|
177
205
|
if (open) {
|
|
@@ -183,26 +211,43 @@ async function join(chain, network, values) {
|
|
|
183
211
|
const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
|
|
184
212
|
await chain.apply(`data:application/json;base64,${profile}`);
|
|
185
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" +
|
|
186
|
-
|
|
187
|
-
"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}`;
|
|
188
229
|
}
|
|
189
230
|
async function status(chain, network) {
|
|
190
|
-
const [gas,
|
|
231
|
+
const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
191
232
|
chain.ethBalance(),
|
|
192
|
-
chain
|
|
193
|
-
chain.
|
|
194
|
-
chain.inFlight(),
|
|
233
|
+
snapshot(chain),
|
|
234
|
+
chain.held(),
|
|
195
235
|
chain.record(),
|
|
196
236
|
chain.bountyBalance(),
|
|
197
|
-
|
|
237
|
+
chain.stakeBalance(),
|
|
238
|
+
chain.bond(),
|
|
239
|
+
chain.faucet(),
|
|
240
|
+
chain.now(),
|
|
198
241
|
]);
|
|
242
|
+
const { w, min } = s;
|
|
199
243
|
const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
|
|
200
244
|
console.log(`Worker ${chain.account} on ${network.name}
|
|
201
|
-
Hireable ${
|
|
245
|
+
Hireable ${hireable(s, network)}
|
|
202
246
|
Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
|
|
247
|
+
Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
|
|
203
248
|
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
204
249
|
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,
|
|
250
|
+
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
|
|
206
251
|
Gas ${formatEther(gas)} ETH`);
|
|
207
252
|
}
|
|
208
253
|
async function run(network, { key, account }, values, once) {
|
|
@@ -220,25 +265,9 @@ async function run(network, { key, account }, values, once) {
|
|
|
220
265
|
lock(`${file}.lock`);
|
|
221
266
|
const store = fileStore(`${file}.json`, network.contracts.taskBoard);
|
|
222
267
|
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
|
-
});
|
|
268
|
+
const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)}Z ${message}`);
|
|
229
269
|
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);
|
|
270
|
+
const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code) });
|
|
242
271
|
const work = createWork({
|
|
243
272
|
chain,
|
|
244
273
|
command,
|
|
@@ -247,10 +276,23 @@ async function run(network, { key, account }, values, once) {
|
|
|
247
276
|
timeoutMs: Number(minutes) * 60_000,
|
|
248
277
|
secrets: [key, key.slice(2)],
|
|
249
278
|
store,
|
|
250
|
-
stopping:
|
|
279
|
+
stopping: stop.stopping,
|
|
251
280
|
});
|
|
252
|
-
|
|
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
|
+
]);
|
|
253
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`);
|
|
254
296
|
if (once) {
|
|
255
297
|
const result = await work.round();
|
|
256
298
|
if (result.hired === 0)
|
|
@@ -261,11 +303,12 @@ async function run(network, { key, account }, values, once) {
|
|
|
261
303
|
return;
|
|
262
304
|
}
|
|
263
305
|
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++)
|
|
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++)
|
|
267
309
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
268
310
|
}
|
|
311
|
+
log("stopped");
|
|
269
312
|
}
|
|
270
313
|
async function main(argv) {
|
|
271
314
|
const [command, ...rest] = argv;
|
|
@@ -298,7 +341,7 @@ async function main(argv) {
|
|
|
298
341
|
throw new UsageError("Usage: brainchain-worker price <tUSDG>, such as price 0.25");
|
|
299
342
|
const asking = price(positional[0]);
|
|
300
343
|
if (!(await chain.stake()).registered)
|
|
301
|
-
throw new
|
|
344
|
+
throw new Error("This worker has not staked yet. Run join, which sets the price too.");
|
|
302
345
|
await needGas(chain, network);
|
|
303
346
|
await chain.setPrice(asking);
|
|
304
347
|
console.log(`Price set to ${usdg(network, asking)} per task.`);
|
|
@@ -307,7 +350,7 @@ async function main(argv) {
|
|
|
307
350
|
case "leave": {
|
|
308
351
|
const w = await chain.stake();
|
|
309
352
|
if (w.staked === 0n) {
|
|
310
|
-
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.");
|
|
311
354
|
}
|
|
312
355
|
await needGas(chain, network);
|
|
313
356
|
await chain.requestUnstake(w.staked);
|
|
@@ -316,13 +359,13 @@ async function main(argv) {
|
|
|
316
359
|
return;
|
|
317
360
|
}
|
|
318
361
|
case "withdraw": {
|
|
319
|
-
const [w, held, now] = await Promise.all([chain.stake(), chain.
|
|
362
|
+
const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
|
|
320
363
|
if (w.unstaking === 0n)
|
|
321
|
-
throw new
|
|
364
|
+
throw new Error("Nothing is unstaking. Run leave first.");
|
|
322
365
|
if (now < w.unlocksAt)
|
|
323
|
-
throw new
|
|
366
|
+
throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
|
|
324
367
|
if (held > 0)
|
|
325
|
-
throw new
|
|
368
|
+
throw new Error(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
|
|
326
369
|
await needGas(chain, network);
|
|
327
370
|
await chain.withdraw();
|
|
328
371
|
console.log(`Withdrew ${stake(network, w.unstaking)}.`);
|
|
@@ -331,6 +374,6 @@ async function main(argv) {
|
|
|
331
374
|
}
|
|
332
375
|
}
|
|
333
376
|
main(process.argv.slice(2)).catch((error) => {
|
|
334
|
-
console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${
|
|
377
|
+
console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${reason(error)}`);
|
|
335
378
|
process.exit(1);
|
|
336
379
|
});
|
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.1",
|
|
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>",
|