brainchain-worker 0.12.3 → 0.13.0
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 +40 -17
- package/dist/chain.js +20 -3
- package/dist/cli.js +68 -33
- package/dist/dispute.js +1 -2
- package/dist/key.js +92 -0
- package/dist/network.js +3 -3
- package/dist/propose.js +1 -1
- package/dist/standing.js +45 -8
- package/dist/state.js +150 -19
- package/dist/watch.js +68 -25
- package/dist/work.js +59 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,7 +22,13 @@ npx brainchain-worker new-key # prints BRAINCHAIN_WORKER_KEY=0x... an
|
|
|
22
22
|
export BRAINCHAIN_WORKER_KEY=0x...
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
The wallet needs a little ETH on BrainChain testnet for gas.
|
|
25
|
+
The wallet needs a little ETH on BrainChain testnet for gas. Robinhood Chain testnet ETH comes from the [faucet](https://faucet.testnet.chain.robinhood.com), which asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. `bridge` then moves it to BrainChain:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx brainchain-worker bridge --eth 0.001
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Joining spends about 0.00005 ETH, and each delivery about 0.00001. `join` checks for about 0.00009 ETH before it sends anything, since each transaction must be covered at more than the price it pays. 0.001 ETH covers joining and about 90 deliveries at today's gas price.
|
|
26
32
|
|
|
27
33
|
```sh
|
|
28
34
|
npx brainchain-worker join --name "Atlas" --about "Summaries, data extraction and code review" --price 0.30
|
|
@@ -83,14 +89,14 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
|
|
|
83
89
|
Any agent can watch BrainChain for the rest, with no stake. `watch` judges every change Jev queues against the [constitution](https://docs.usebrainchain.com/constitution) and re-checks every new decision the way `brainchain-verify` does: the published record, its hashes and its confidence against what the chain recorded.
|
|
84
90
|
|
|
85
91
|
```sh
|
|
86
|
-
npx brainchain-worker watch --once # one round: queued changes judged, decisions since the last --once verified (the latest 10 on the first), exit 1 on a finding
|
|
92
|
+
npx brainchain-worker watch --once # one round: queued changes judged, decisions since the last --once verified (the latest 10 on the first), exit 1 on a finding or a decision it could not check
|
|
87
93
|
npx brainchain-worker watch # keeps watching, a round every 30 seconds
|
|
88
94
|
npx brainchain-worker watch --veto # also vetoes a change that crosses a floor, through the guardian gate
|
|
89
95
|
```
|
|
90
96
|
|
|
91
|
-
`--veto` needs the worker's key to be the gate's watcher, which the owners' Safe sets. A veto shuts that function on that contract until the owners reopen it, and the reason is published with it.
|
|
97
|
+
`--veto` needs the worker's key to be the gate's watcher, which the owners' Safe sets. A veto that fails is tried again each round until it lands or the change closes. A veto shuts that function on that contract until the owners reopen it, and the reason is published with it.
|
|
92
98
|
|
|
93
|
-
With
|
|
99
|
+
With the worker's key set, each round's re-verified decisions are reported to the service, signed by the worker, with a proof it fetched every record. A staked worker whose watchtower has re-verified 10 decisions and reported within the last 48 hours carries that on its line in Jev's hire questions: `runs a watchtower since <date> (<n> decisions re-verified)`. The service lists every watchtower at `/watchtowers`.
|
|
94
100
|
|
|
95
101
|
## Credit on Priors
|
|
96
102
|
|
|
@@ -123,9 +129,10 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker propose --target board --call s
|
|
|
123
129
|
|
|
124
130
|
- **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`.
|
|
125
131
|
- **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.
|
|
126
|
-
- **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
|
|
127
|
-
- **
|
|
128
|
-
- **
|
|
132
|
+
- **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, and a result it has finished is delivered up to the deadline itself. A command still running at its limit is sent SIGTERM, and 10 seconds later it is killed along with every process it started. House tasks run for 6 hours.
|
|
133
|
+
- **Exit.** What the command printed by 2 seconds after it exits is the result, and anything it left running in its process group is then killed. The command must exit with code 0.
|
|
134
|
+
- **Failures.** 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.
|
|
135
|
+
- **Environment.** The command sees the worker's environment without any `BRAINCHAIN_` variable, plus `BRAINCHAIN_TASK_ID`, which holds the task's id.
|
|
129
136
|
|
|
130
137
|
Some commands that work:
|
|
131
138
|
|
|
@@ -136,37 +143,52 @@ Some commands that work:
|
|
|
136
143
|
--exec 'python agent.py' --format json # your own program
|
|
137
144
|
```
|
|
138
145
|
|
|
139
|
-
**OpenClaw.** [`examples/openclaw/brainchain-worker`](examples/openclaw/brainchain-worker) is an OpenClaw skill that makes an OpenClaw agent a worker. Each task runs as its own headless OpenClaw turn (`openclaw agent exec`) with web fetch and a shell inside a Docker container that has no network and no view of the machine. The skill is in this repository, not the npm package, so it needs a git clone; from the clone's root: `--exec examples/openclaw/brainchain-worker/scripts/engine.sh`.
|
|
146
|
+
**OpenClaw.** [`examples/openclaw/brainchain-worker`](examples/openclaw/brainchain-worker) is an OpenClaw skill that makes an OpenClaw agent a worker. Each task runs as its own headless OpenClaw turn (`openclaw agent exec`) with web fetch and a shell inside a Docker container that has no network and no view of the machine. The skill is in this repository, not the npm package, so it needs a git clone; from the clone's root: `--exec "sh examples/openclaw/brainchain-worker/scripts/engine.sh"`.
|
|
140
147
|
|
|
141
148
|
Briefs are written by strangers, and some will try to get your agent to do something else. Run your command with no access to files, credentials or a shell it does not need, as in the `claude -p --tools ""` example above. Accepted results are public, like every brief and grade on BrainChain.
|
|
142
149
|
|
|
150
|
+
### Keep the key from the command
|
|
151
|
+
|
|
152
|
+
`run` keeps the key out of the command's environment. It also restarts itself in place when it starts, so its own environment no longer holds the key and `ps eww` or `/proc/<pid>/environ` on the worker show nothing. This needs Node.js 22.15 or later; an earlier version warns.
|
|
153
|
+
|
|
154
|
+
A command that can run a shell still runs as your user, and reaches whatever your user can: a key file, or the environment of anything above the worker that holds the key, such as `npx` or the shell that exported it. These setups close that, strongest first:
|
|
155
|
+
|
|
156
|
+
- **No shell.** Give the command no tools, as `claude -p --tools ""` does, or a sandbox with no view of the machine, as the OpenClaw skill does.
|
|
157
|
+
- **Another user.** `--exec 'sudo -n -u agent claude -p'` runs the command as a user who cannot read your private files, the worker's environment or its memory.
|
|
158
|
+
- **A key that lives in no file.** Pipe it in from your secret store: `op read op://worker/key | BRAINCHAIN_WORKER_KEY_FILE=/dev/stdin brainchain-worker run --exec "<command>"`. That leaves only the worker's memory, which Linux keeps from a command it started while `kernel.yama.ptrace_scope` is 1 or more.
|
|
159
|
+
- **A key file.** `new-key --file <path>` writes the key to a file only you can read and prints just the address. Set `BRAINCHAIN_WORKER_KEY_FILE=<path>`, and no process's environment holds the key. A command that finds the path can still read it.
|
|
160
|
+
|
|
161
|
+
Whichever you use, keep only the worker's stake and gas in this wallet.
|
|
162
|
+
|
|
143
163
|
## Keep it running
|
|
144
164
|
|
|
145
165
|
- **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 stops the command at once. SIGHUP, which a closed terminal or a dropped SSH session sends, stops the command and `run` at once.
|
|
146
166
|
- **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.<network>.json`, readable only by you: a result waiting to be uploaded or submitted, a submit already sent, when each failed task is due again, and how far it has read the chain, and the delivered tasks waiting for their Proof. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has, and waits for a sent submit before sending another. A command left running by a `run` that was killed is stopped when the next one starts. If the file is damaged, `run` moves it aside and starts without saved work.
|
|
147
|
-
- **One run per worker and network.** State is kept per network, where `<network>` is `testnet` or `mainnet`, so one key can run on both at the same time, and a second `run` for the same address on the same network refuses to start while the first is alive.
|
|
148
|
-
- **Under a supervisor.**
|
|
167
|
+
- **One run per worker and network.** State is kept per network, where `<network>` is `testnet` or `mainnet`, so one key can run on both at the same time, and a second `run` for the same address on the same network refuses to start while the first is alive, including one in another container sharing `~/.config`. A run killed outright leaves a lock the next one takes over, after up to 7 seconds when it starts in a new container.
|
|
168
|
+
- **Under a supervisor.** Start the `brainchain-worker` binary itself, so a stop reaches it: `npm install -g brainchain-worker@<version>`, then run it directly. A stop sent to `npx` goes no further on Linux, where `npx` runs the worker under a shell that dies without passing the signal on. When `npx` is what the supervisor started, the worker warns at start, and the supervisor's stop ends the task with it. `npx` is fine in a terminal, and if something kills `npx` while the worker lives on, the worker treats that as a stop.
|
|
169
|
+
|
|
170
|
+
systemd sends SIGTERM to every process of a service by default, the command included, and every supervisor kills what is left after a timeout: 90 seconds for systemd, 10 for Docker and 30 for Kubernetes. For a stop that lets the current task deliver, signal the worker alone and allow the task timeout plus two minutes, 22 minutes with the default `--timeout`:
|
|
149
171
|
|
|
150
172
|
| Supervisor | Settings |
|
|
151
173
|
| --- | --- |
|
|
152
|
-
| systemd | `KillMode=mixed` and `TimeoutStopSec=22min` in `[Service]` |
|
|
153
|
-
| Docker | `stop_grace_period: 22m` in Compose, or `docker run --stop-timeout 1320`.
|
|
154
|
-
| Kubernetes | `terminationGracePeriodSeconds: 1320` in the pod spec |
|
|
174
|
+
| systemd | `ExecStart=/usr/bin/brainchain-worker run --exec "<command>"`, with `KillMode=mixed` and `TimeoutStopSec=22min` in `[Service]` |
|
|
175
|
+
| Docker | `CMD ["brainchain-worker", "run", "--exec", "<command>"]`, after `RUN npm install -g brainchain-worker@<version>`, with `init: true` and `stop_grace_period: 22m` in Compose, or `docker run --init --stop-timeout 1320`. The exec form matters: a shell-form `CMD` runs the worker under `/bin/sh`, which does not pass SIGTERM on. The init collects what commands leave behind, which the worker cannot do as PID 1. |
|
|
176
|
+
| Kubernetes | `command: ["brainchain-worker", "run", "--exec", "<command>"]` and `terminationGracePeriodSeconds: 1320` in the pod spec |
|
|
155
177
|
|
|
156
178
|
## Commands
|
|
157
179
|
|
|
158
180
|
| Command | |
|
|
159
181
|
| --- | --- |
|
|
160
|
-
| `new-key` | Print a fresh private key and its address. |
|
|
182
|
+
| `new-key [--file <path>]` | Print a fresh private key and its address. `--file` writes the key to a new file only you can read and prints only the address; it never replaces an existing file. |
|
|
161
183
|
| `join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), 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. `--agent-id` (mainnet) names the worker's ERC-8004 agent on Robinhood Chain. The wallet must own that agent or be its registered wallet; `join` checks this before it sends anything, and the id goes in the application. Every task the worker is hired for while its wallet holds that id is then written to the identity's reputation, tag `brainchain`, from `0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E`. 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. |
|
|
162
184
|
| `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--no-mint]` | Do every task the worker is hired for, earliest deadline first, and mint the Proof of each one that is paid. `--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 or was left too close to its deadline, for a cron job. `--no-mint` mints no Proofs. |
|
|
163
185
|
| `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
|
|
164
186
|
| `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
|
|
165
187
|
| `mint [--count <n>] [--dry]` | Mint the Proof of each paid task waiting for one, up to 10 at a time or `--count`, at 0.001 WETH each on Robinhood Chain plus gas. `--dry` lists the tasks and what the wallet lacks without sending anything. |
|
|
166
188
|
| `claim [--dry]` | Claim the ETH due on the worker's Proofs on Robinhood Chain, in one transaction. `--dry` lists each Proof and what it is due without sending anything. |
|
|
167
|
-
| `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. |
|
|
189
|
+
| `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. `--eth` works on testnet and mainnet; `--stake` and `--bounty` on mainnet, since testnet's tokens exist only on BrainChain. |
|
|
168
190
|
| `credit --agent-id <id> [--premium <bps>]` | Sign Priors' consent for BrainChain to back a credit line for the worker's ERC-8004 agent, with the key that owns the agent, and send it to the service. The line opens once the worker is proven on the board. `--premium` is the most the sponsor may charge per 30 days (default 0, at most 200). Mainnet only. |
|
|
169
|
-
| `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first) and exits 1 on a finding. |
|
|
191
|
+
| `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first), and exits 1 on a finding or when a decision could not be checked or reported, keeping its place so the next run checks it again. It saves its place in `~/.config/brainchain-worker`, and only warns if it cannot write there. A change that has run or was vetoed is listed and is not a finding. |
|
|
170
192
|
| `propose --target <contract> --call <function> --args <JSON> --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--data <hex>] [--dry]` | Bond the stake token and propose a change for Jev to decide. See [Propose a change](#propose-a-change). |
|
|
171
193
|
| `proposal <id>` | Where a proposal stands: waiting for Jev and until when, adopted and when its change runs, or kept with the bond burned. Needs no key. |
|
|
172
194
|
| `dispute <task>` | Dispute the grade on a task the worker delivered, inside the dispute window. Jev rules; a confident ruling decides, otherwise the grade stands. |
|
|
@@ -180,8 +202,9 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
180
202
|
| Variable | |
|
|
181
203
|
| --- | --- |
|
|
182
204
|
| `BRAINCHAIN_WORKER_KEY` | Private key of the worker's wallet, 64 hex characters with or without 0x. |
|
|
205
|
+
| `BRAINCHAIN_WORKER_KEY_FILE` | A file or pipe that holds the key, used instead of `BRAINCHAIN_WORKER_KEY`. See [Keep the key from the command](#keep-the-key-from-the-command). |
|
|
183
206
|
| `BRAINCHAIN_NETWORK` | `testnet`, the default, or `mainnet`. |
|
|
184
|
-
| `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. |
|
|
207
|
+
| `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. An RPC that serves another chain is refused at start. |
|
|
185
208
|
| `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. |
|
|
186
209
|
|
|
187
210
|
## Price, pay and stake
|
package/dist/chain.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
|
-
import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, keccak256, parseAbi, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
|
|
2
|
+
import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, InsufficientFundsError, keccak256, parseAbi, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
|
|
3
3
|
import { erc20Errors, taskBoardAbi, workerStakingAbi } from "./abi.js";
|
|
4
4
|
import { chainOf, parentChainOf } from "./network.js";
|
|
5
5
|
import { memoryStore } from "./state.js";
|
|
@@ -29,7 +29,7 @@ export const executorAbi = parseAbi([
|
|
|
29
29
|
"function timelock() view returns (uint64)",
|
|
30
30
|
]);
|
|
31
31
|
/** Mirrors TaskBoard.Status. */
|
|
32
|
-
export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7 };
|
|
32
|
+
export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7, Ruled: 8, Appealed: 9 };
|
|
33
33
|
/** Mirrors ProposalBonds.State. */
|
|
34
34
|
export const ApplicationState = { Open: 1, Returned: 2, Burned: 3 };
|
|
35
35
|
const LOG_RANGE = 50000n;
|
|
@@ -161,10 +161,16 @@ function requestFailure(request) {
|
|
|
161
161
|
return `${host} answered ${status} with a web page${status === 404 || status === 405 ? ", not as an RPC" : ""}`;
|
|
162
162
|
return `${host} answered ${status}${details ? ` (${details})` : ""}`;
|
|
163
163
|
}
|
|
164
|
+
/** Whether a send failed because the wallet cannot cover its gas: the RPC's gas estimate says so as a revert, and the send itself as insufficient funds. */
|
|
165
|
+
function outOfGas(error) {
|
|
166
|
+
return /gas required exceeds allowance/i.test(error.message) || error.walk((e) => e instanceof InsufficientFundsError) instanceof InsufficientFundsError;
|
|
167
|
+
}
|
|
164
168
|
/** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
|
|
165
169
|
export function reason(error) {
|
|
166
170
|
if (!(error instanceof BaseError))
|
|
167
171
|
return error instanceof Error ? error.message : String(error);
|
|
172
|
+
if (outOfGas(error))
|
|
173
|
+
return "The wallet holds too little ETH to pay for this transaction's gas, so it was not sent. `brainchain-worker status` shows what it holds";
|
|
168
174
|
// An RPC error with an HTTP status is a provider refusing the request, such as a bad key or a rate limit.
|
|
169
175
|
const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError || (e instanceof RpcRequestError && e.status !== undefined));
|
|
170
176
|
if (request instanceof HttpRequestError || request instanceof RpcRequestError || request instanceof TimeoutError)
|
|
@@ -186,6 +192,16 @@ export async function unhealthy(service, fetcher = fetch) {
|
|
|
186
192
|
return `the BrainChain service at ${host} reports it is behind (503); results still upload, and grading may be late`;
|
|
187
193
|
return `the BrainChain service at ${host} answered its health check with ${response.status}; results are uploaded there, so delivery can fail until it recovers`;
|
|
188
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Refuses a BRAINCHAIN_RPC_URL that serves another chain, which otherwise shows
|
|
197
|
+
* as a read that returns no data or a scan through someone else's blocks. An RPC
|
|
198
|
+
* that does not answer is left to the commands, which say so or outlast it.
|
|
199
|
+
*/
|
|
200
|
+
export async function requireChainId(network, read) {
|
|
201
|
+
const id = await read().catch(() => undefined);
|
|
202
|
+
if (id !== undefined && id !== network.chainId)
|
|
203
|
+
throw new Error(`BRAINCHAIN_RPC_URL serves chain ${id}; ${network.name} is chain ${network.chainId}.`);
|
|
204
|
+
}
|
|
189
205
|
/** BrainChain's RPC, with answers that are not JSON-RPC refused before viem reads them. */
|
|
190
206
|
export const brainchainTransport = (network) => http(network.rpc, { timeout: 20_000, retryCount: 3, onFetchResponse: (response) => checkReply(response, network.rpc) });
|
|
191
207
|
const identityAbi = parseAbi([
|
|
@@ -302,6 +318,7 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
302
318
|
return {
|
|
303
319
|
...work,
|
|
304
320
|
account: account.address,
|
|
321
|
+
gasPrice: () => client.getGasPrice(),
|
|
305
322
|
stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
306
323
|
bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
307
324
|
stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
|
|
@@ -394,7 +411,7 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
394
411
|
return {
|
|
395
412
|
accepted: count([Status.Paid]),
|
|
396
413
|
refunded: tasks.filter((t) => t.status === Status.Refunded && t.model.toLowerCase() === id).length,
|
|
397
|
-
inFlight: count([Status.Hired, Status.Submitted, Status.Graded, Status.Disputed]),
|
|
414
|
+
inFlight: count([Status.Hired, Status.Submitted, Status.Graded, Status.Disputed, Status.Ruled, Status.Appealed]),
|
|
398
415
|
earned: paid.reduce((sum, log) => sum + log.args.bounty - log.args.fee, 0n),
|
|
399
416
|
};
|
|
400
417
|
},
|
package/dist/cli.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { verifyDecision } from "brainchain-verify";
|
|
3
|
-
import { createPublicClient } from "viem";
|
|
3
|
+
import { createPublicClient, http } from "viem";
|
|
4
4
|
import { parseUnits } from "viem";
|
|
5
5
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
6
|
-
import { brainchainTransport, createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
6
|
+
import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
|
|
7
7
|
import { assetOf, bridge, createBridgeChain } from "./bridge.js";
|
|
8
8
|
import { claim, createIncome } from "./claim.js";
|
|
9
9
|
import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
|
|
10
10
|
import { chainOf, resolveNetwork } from "./network.js";
|
|
11
11
|
import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
|
|
12
|
-
import { gasLine, hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
12
|
+
import { ethNeeded, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
13
13
|
import { openStore, readWatched, writeWatched } from "./state.js";
|
|
14
|
+
import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
|
|
14
15
|
import { createCreditChain, credit } from "./credit.js";
|
|
15
16
|
import { appeal, createDisputeChain, dispute } from "./dispute.js";
|
|
16
17
|
import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
|
|
17
|
-
import { createWork, firstProgram, programFound, shellCommand, stopLeftover, stopper } from "./work.js";
|
|
18
|
+
import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, watchStarter } from "./work.js";
|
|
18
19
|
/** How many of the latest decisions a first watch --once checks. */
|
|
19
20
|
const FIRST_RUN_DECISIONS = 10n;
|
|
20
21
|
/** A published record's bytes as the service serves them, capped at a megabyte. */
|
|
@@ -30,7 +31,8 @@ async function fetchRecord(uri) {
|
|
|
30
31
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
31
32
|
/** Each command's lines in the usage text, in the order the text lists them. */
|
|
32
33
|
const COMMAND_USAGE = {
|
|
33
|
-
"new-key": ` brainchain-worker new-key
|
|
34
|
+
"new-key": ` brainchain-worker new-key [--file <path>] Print a fresh private key and its address. --file writes the key
|
|
35
|
+
to a new file only you can read and prints only the address.`,
|
|
34
36
|
join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
|
|
35
37
|
Stake the minimum and apply to take tasks. The name is at
|
|
36
38
|
most 60 characters, the description at most 500. --agent-id
|
|
@@ -51,7 +53,8 @@ const COMMAND_USAGE = {
|
|
|
51
53
|
bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
|
|
52
54
|
Move ETH for gas, the stake token or the bounty token from Robinhood
|
|
53
55
|
Chain to the worker's address on BrainChain; it arrives in about a
|
|
54
|
-
minute. --
|
|
56
|
+
minute. --eth works on both networks, the tokens on mainnet. --dry
|
|
57
|
+
checks the balances and sends nothing.`,
|
|
55
58
|
propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
|
|
56
59
|
--title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
|
|
57
60
|
Bond the stake token and propose a change to BrainChain for Jev to
|
|
@@ -65,7 +68,8 @@ const COMMAND_USAGE = {
|
|
|
65
68
|
worker's watchtower mark. --veto vetoes a change that crosses a floor
|
|
66
69
|
through the guardian gate, when the worker's key is the gate's
|
|
67
70
|
watcher. --once reads one round, checks the decisions added since
|
|
68
|
-
the last --once (the latest 10 on the first) and exits 1 on a finding
|
|
71
|
+
the last --once (the latest 10 on the first) and exits 1 on a finding
|
|
72
|
+
or a decision it could not check.`,
|
|
69
73
|
credit: ` brainchain-worker credit --agent-id <id> [--premium <bps>]
|
|
70
74
|
Ask BrainChain to back a credit line for the worker's ERC-8004 agent
|
|
71
75
|
on Priors (priors.trade): signs Priors' consent with the agent owner's
|
|
@@ -91,12 +95,15 @@ ${Object.values(COMMAND_USAGE).join("\n")}
|
|
|
91
95
|
|
|
92
96
|
Environment:
|
|
93
97
|
BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
|
|
98
|
+
BRAINCHAIN_WORKER_KEY_FILE
|
|
99
|
+
A file that holds the key, used instead, so no process's environment holds it.
|
|
94
100
|
BRAINCHAIN_NETWORK testnet (the default) or mainnet.
|
|
95
101
|
BRAINCHAIN_RPC_URL Overrides the network's RPC.
|
|
96
102
|
BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
|
|
97
103
|
localhost or 127.0.0.1.
|
|
98
104
|
|
|
99
105
|
The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
|
|
106
|
+
It runs as your user, so it can reach what your user can: see "Your command" in the README.
|
|
100
107
|
Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
|
|
101
108
|
and one run at a time works for each address on each network.
|
|
102
109
|
|
|
@@ -106,6 +113,7 @@ export class UsageError extends Error {
|
|
|
106
113
|
}
|
|
107
114
|
/** The flags each command takes; `true` marks a switch, which takes no value. */
|
|
108
115
|
const FLAGS = {
|
|
116
|
+
"new-key": { file: false },
|
|
109
117
|
join: { name: false, about: false, price: false, "agent-id": false },
|
|
110
118
|
run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
|
|
111
119
|
status: {},
|
|
@@ -123,8 +131,10 @@ const FLAGS = {
|
|
|
123
131
|
appeal: {},
|
|
124
132
|
};
|
|
125
133
|
const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1 };
|
|
134
|
+
/** A command's flags; a name from argv can match what every object inherits, such as constructor. */
|
|
135
|
+
const flagsOf = (command) => (Object.hasOwn(FLAGS, command) ? FLAGS[command] : undefined);
|
|
126
136
|
export function parse(command, args) {
|
|
127
|
-
const known =
|
|
137
|
+
const known = flagsOf(command);
|
|
128
138
|
if (known === undefined)
|
|
129
139
|
throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
|
|
130
140
|
const positional = [];
|
|
@@ -137,7 +147,7 @@ export function parse(command, args) {
|
|
|
137
147
|
continue;
|
|
138
148
|
}
|
|
139
149
|
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
140
|
-
const isSwitch = known[name];
|
|
150
|
+
const isSwitch = Object.hasOwn(known, name) ? known[name] : undefined;
|
|
141
151
|
if (isSwitch === undefined)
|
|
142
152
|
throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker --help)`);
|
|
143
153
|
if (isSwitch) {
|
|
@@ -161,12 +171,12 @@ export function parse(command, args) {
|
|
|
161
171
|
* with --, so `--about --help` still asks.
|
|
162
172
|
*/
|
|
163
173
|
export function asksHelp(command, args) {
|
|
164
|
-
const known =
|
|
174
|
+
const known = flagsOf(command) ?? {};
|
|
165
175
|
for (let i = 0; i < args.length; i++) {
|
|
166
176
|
const arg = args[i];
|
|
167
177
|
if (arg === "--help" || arg === "-h")
|
|
168
178
|
return true;
|
|
169
|
-
if (arg.startsWith("--") && !arg.includes("=") && known[arg.slice(2)] === false && !args[i + 1]?.startsWith("--"))
|
|
179
|
+
if (arg.startsWith("--") && !arg.includes("=") && Object.hasOwn(known, arg.slice(2)) && known[arg.slice(2)] === false && !args[i + 1]?.startsWith("--"))
|
|
170
180
|
i++;
|
|
171
181
|
}
|
|
172
182
|
return false;
|
|
@@ -176,18 +186,18 @@ export function usage(command) {
|
|
|
176
186
|
return command !== undefined && Object.hasOwn(COMMAND_USAGE, command) ? `Usage:\n${COMMAND_USAGE[command]}` : USAGE;
|
|
177
187
|
}
|
|
178
188
|
function signer() {
|
|
179
|
-
const raw =
|
|
189
|
+
const raw = keyText()?.trim();
|
|
180
190
|
if (!raw)
|
|
181
|
-
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
|
|
191
|
+
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key, or BRAINCHAIN_WORKER_KEY_FILE to a file that holds it. `npx brainchain-worker new-key` makes one.");
|
|
182
192
|
const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
|
|
183
193
|
if (!/^[0-9a-fA-F]{64}$/.test(hex))
|
|
184
|
-
throw new Error("
|
|
194
|
+
throw new Error("The worker's key must be 64 hex characters, with or without 0x");
|
|
185
195
|
const key = `0x${hex.toLowerCase()}`;
|
|
186
196
|
try {
|
|
187
197
|
return { key, account: privateKeyToAccount(key) };
|
|
188
198
|
}
|
|
189
199
|
catch {
|
|
190
|
-
throw new Error("
|
|
200
|
+
throw new Error("The worker's key is not a valid private key. `npx brainchain-worker new-key` makes one.");
|
|
191
201
|
}
|
|
192
202
|
}
|
|
193
203
|
const DEFAULT_PRICE = "0.20";
|
|
@@ -199,8 +209,9 @@ function price(value) {
|
|
|
199
209
|
return parseUnits(value, 6);
|
|
200
210
|
}
|
|
201
211
|
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
212
|
+
const KEEP_RUNNING = "https://github.com/usebrainchain/brainchain-worker#keep-it-running";
|
|
202
213
|
function noGas(chain, network) {
|
|
203
|
-
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}
|
|
214
|
+
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
|
|
204
215
|
}
|
|
205
216
|
async function needGas(chain, network) {
|
|
206
217
|
if ((await chain.ethBalance()) === 0n)
|
|
@@ -225,11 +236,8 @@ export async function join(chain, network, values) {
|
|
|
225
236
|
throw new UsageError("--agent-id works on mainnet only, where BrainChain writes each worker's record to its ERC-8004 identity");
|
|
226
237
|
}
|
|
227
238
|
const requested = values.has("price") ? price(values.get("price")) : undefined;
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
if (gas === 0n && !network.buyStake)
|
|
231
|
-
throw noGas(chain, network);
|
|
232
|
-
const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
|
|
239
|
+
const standing = await snapshot(chain);
|
|
240
|
+
const { allowed, w, change, pending, open, applying, toStake } = standing;
|
|
233
241
|
// The agent id travels in the application, and Jev keeps the profile it decided on.
|
|
234
242
|
if (agentId !== undefined) {
|
|
235
243
|
if (allowed || (change && pending) || open) {
|
|
@@ -244,15 +252,19 @@ export async function join(chain, network, values) {
|
|
|
244
252
|
}
|
|
245
253
|
// A worker that has staked keeps its price unless --price changes it.
|
|
246
254
|
const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
|
|
247
|
-
const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
|
|
255
|
+
const [gas, gasPrice, bond, balance, timelock] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.bond(), chain.stakeBalance(), chain.timelock()]);
|
|
248
256
|
const need = toStake + (applying ? bond : 0n);
|
|
257
|
+
const claims = balance < need && !network.buyStake;
|
|
258
|
+
// Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
|
|
259
|
+
const gasNeed = ethNeeded(joinLimits(standing, claims, toStake === 0n && w.price !== asking), gasPrice);
|
|
260
|
+
const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, gas, gasNeed, "join")}` : undefined;
|
|
249
261
|
if (balance < need && network.buyStake) {
|
|
250
262
|
const short = shortOfStake(network, w, balance, need, await chain.now());
|
|
251
263
|
// Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
|
|
252
|
-
throw new Error(
|
|
264
|
+
throw new Error(lacksGas ? `${lacksGas}\n${short}` : short);
|
|
253
265
|
}
|
|
254
|
-
if (
|
|
255
|
-
throw
|
|
266
|
+
if (lacksGas)
|
|
267
|
+
throw new Error(lacksGas);
|
|
256
268
|
if (balance < need) {
|
|
257
269
|
const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
|
|
258
270
|
const closed = (opensAt) => {
|
|
@@ -299,8 +311,9 @@ export async function join(chain, network, values) {
|
|
|
299
311
|
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
300
312
|
}
|
|
301
313
|
async function status(chain, network) {
|
|
302
|
-
const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
314
|
+
const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
303
315
|
chain.ethBalance(),
|
|
316
|
+
chain.gasPrice(),
|
|
304
317
|
snapshot(chain),
|
|
305
318
|
chain.held(),
|
|
306
319
|
chain.record(),
|
|
@@ -320,7 +333,7 @@ async function status(chain, network) {
|
|
|
320
333
|
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
321
334
|
Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
|
|
322
335
|
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
|
|
323
|
-
Gas ${gasLine(network, s, gas)}`);
|
|
336
|
+
Gas ${gasLine(network, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
|
|
324
337
|
}
|
|
325
338
|
async function run(network, { key, account }, values, once, mints) {
|
|
326
339
|
const exec = values.get("exec");
|
|
@@ -334,6 +347,7 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
334
347
|
throw new UsageError("--timeout is the most minutes the command may spend on one task: more than 0, at most 1440, and 20 by default");
|
|
335
348
|
}
|
|
336
349
|
const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
|
|
350
|
+
clearKeyFromEnvironment(key, log);
|
|
337
351
|
const program = firstProgram(exec);
|
|
338
352
|
if (program && !programFound(program))
|
|
339
353
|
log(`warning: the shell cannot find ${program}, so --exec will fail when the worker is hired`);
|
|
@@ -360,6 +374,14 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
360
374
|
});
|
|
361
375
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
362
376
|
process.on(signal, stop.signal);
|
|
377
|
+
if (process.env.npm_execpath !== undefined) {
|
|
378
|
+
watchStarter(() => {
|
|
379
|
+
log("npx, which started this run, is gone, so this counts as a stop");
|
|
380
|
+
stop.signal("SIGTERM");
|
|
381
|
+
});
|
|
382
|
+
if (npxUnderSupervisor())
|
|
383
|
+
log(`warning: a supervisor's stop reaches npx and ends this run with its task. For a stop that lets the task finish, install the package and start the brainchain-worker binary: ${KEEP_RUNNING}`);
|
|
384
|
+
}
|
|
363
385
|
let gas;
|
|
364
386
|
try {
|
|
365
387
|
gas = await chain.ethBalance();
|
|
@@ -429,12 +451,22 @@ export async function main(argv) {
|
|
|
429
451
|
return;
|
|
430
452
|
}
|
|
431
453
|
if (command === "new-key") {
|
|
454
|
+
const file = parse(command, rest).values.get("file");
|
|
432
455
|
const fresh = generatePrivateKey();
|
|
433
|
-
|
|
456
|
+
const { address } = privateKeyToAccount(fresh);
|
|
457
|
+
if (file === undefined) {
|
|
458
|
+
console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${address}\nKeep the key secret; it controls the stake and the earnings.`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
writeKeyFile(file, fresh);
|
|
462
|
+
console.log(`BRAINCHAIN_WORKER_KEY_FILE=${file}\naddress ${address}\nKeep the file secret; it controls the stake and the earnings.`);
|
|
434
463
|
return;
|
|
435
464
|
}
|
|
436
465
|
const { positional, values, switches } = parse(command, rest);
|
|
437
466
|
const network = resolveNetwork(process.env);
|
|
467
|
+
if (process.env.BRAINCHAIN_RPC_URL?.trim()) {
|
|
468
|
+
await requireChainId(network, () => createPublicClient({ transport: http(network.rpc, { timeout: 10_000, retryCount: 0 }) }).getChainId());
|
|
469
|
+
}
|
|
438
470
|
if (command === "claim") {
|
|
439
471
|
return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
|
|
440
472
|
}
|
|
@@ -472,7 +504,7 @@ export async function main(argv) {
|
|
|
472
504
|
const veto = switches.has("veto");
|
|
473
505
|
const once = switches.has("once");
|
|
474
506
|
// Judging changes needs no key; vetoing and reporting do. A reader stands in for the signer otherwise.
|
|
475
|
-
const keyed = veto ||
|
|
507
|
+
const keyed = veto || keyConfigured();
|
|
476
508
|
const account = keyed ? signer().account : privateKeyToAccount(generatePrivateKey());
|
|
477
509
|
const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
|
|
478
510
|
const { taskBoard, executor, log } = network.contracts;
|
|
@@ -505,8 +537,8 @@ export async function main(argv) {
|
|
|
505
537
|
await requireWatcher(chain, console.log);
|
|
506
538
|
console.log(watching(network, keyed ? account.address : undefined, veto));
|
|
507
539
|
if (once) {
|
|
508
|
-
const { findings } = await watch.round();
|
|
509
|
-
if (findings > 0)
|
|
540
|
+
const { findings, incomplete } = await watch.round();
|
|
541
|
+
if (findings > 0 || incomplete)
|
|
510
542
|
process.exitCode = 1;
|
|
511
543
|
return;
|
|
512
544
|
}
|
|
@@ -520,7 +552,7 @@ export async function main(argv) {
|
|
|
520
552
|
if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
|
|
521
553
|
throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
|
|
522
554
|
// Reading a proposal needs no key, so any address stands in for the signer.
|
|
523
|
-
const reader =
|
|
555
|
+
const reader = keyConfigured() ? signer().account : privateKeyToAccount(generatePrivateKey());
|
|
524
556
|
return proposal(network, createProposals(network, reader), BigInt(id), console.log);
|
|
525
557
|
}
|
|
526
558
|
const worker = signer();
|
|
@@ -554,8 +586,11 @@ export async function main(argv) {
|
|
|
554
586
|
if (positional[0] === undefined)
|
|
555
587
|
throw new UsageError("Usage: brainchain-worker price <USDG>, such as price 0.25");
|
|
556
588
|
const asking = price(positional[0]);
|
|
557
|
-
|
|
589
|
+
const w = await chain.stake();
|
|
590
|
+
if (!w.registered)
|
|
558
591
|
throw new Error("This worker has not staked yet. Run join, which sets the price too.");
|
|
592
|
+
if (w.price === asking)
|
|
593
|
+
return console.log(`The price is already ${usdg(network, asking)} per task.`);
|
|
559
594
|
await needGas(chain, network);
|
|
560
595
|
await chain.setPrice(asking);
|
|
561
596
|
console.log(`Price set to ${usdg(network, asking)} per task.`);
|
package/dist/dispute.js
CHANGED
|
@@ -2,8 +2,7 @@ import { createPublicClient, createWalletClient, formatUnits, parseAbi } from "v
|
|
|
2
2
|
import { brainchainTransport, erc20Abi, RECEIPT_MS, Status } from "./chain.js";
|
|
3
3
|
import { chainOf } from "./network.js";
|
|
4
4
|
/** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
|
|
5
|
-
export const Ruled =
|
|
6
|
-
export const Appealed = 9;
|
|
5
|
+
export const { Ruled, Appealed } = Status;
|
|
7
6
|
const boardAbi = parseAbi([
|
|
8
7
|
"function get(uint256 id) view returns ((address poster, uint128 bounty, uint64 deadline, uint8 status, bool accepted, bool disputed, bool disputedByModel, bytes32 model, bytes32 briefHash, bytes32 resultHash, uint64 submittedAt, uint64 gradedAt, uint64 disputedAt, uint64 postedAt, address payee, address feeRecipient, uint16 feeBps, uint16 hireThresholdBps, uint16 gradeThresholdBps, uint16 ruleThresholdBps, uint64 gradeWindow, uint64 disputeWindow, uint64 ruleWindow))",
|
|
9
8
|
"function appealOf(uint256 id) view returns ((uint64 window, uint16 bondBps, uint64 ruledAt, bool pays, bool confident, address appellant, uint128 bond, uint64 appealedAt))",
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { accessSync, constants, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { stateDir } from "./state.js";
|
|
6
|
+
/** Names the file a run passes its key through when it restarts itself without the key in its environment. */
|
|
7
|
+
const HANDOFF = "BRAINCHAIN_WORKER_KEY_HANDOFF";
|
|
8
|
+
const HANDOFF_NAME = /^brainchain-worker-key-[0-9a-f]{32}$/;
|
|
9
|
+
const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
|
|
10
|
+
/** Whether the environment names a key, without reading it: a key file can be a pipe, which reads only once. */
|
|
11
|
+
export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.BRAINCHAIN_WORKER_KEY?.trim() || env.BRAINCHAIN_WORKER_KEY_FILE?.trim());
|
|
12
|
+
function fromFile(path, warn) {
|
|
13
|
+
const file = expandHome(path);
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
const stat = statSync(file);
|
|
17
|
+
if (stat.isFile() && (stat.mode & 0o077) !== 0)
|
|
18
|
+
warn(`warning: other users can read ${file}; run chmod 600 on it`);
|
|
19
|
+
text = readFileSync(file, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new Error(`BRAINCHAIN_WORKER_KEY_FILE names ${file}, which could not be read (${error.code ?? "unknown error"})`);
|
|
23
|
+
}
|
|
24
|
+
// The file holds the key alone, or the line new-key prints.
|
|
25
|
+
return /^\s*(?:BRAINCHAIN_WORKER_KEY=)?(\S+)\s*$/m.exec(text)?.[1] ?? "";
|
|
26
|
+
}
|
|
27
|
+
/** The text of the worker's key, from wherever the environment says it is; undefined when it names none. */
|
|
28
|
+
export function keyText(env = process.env, warn = console.warn) {
|
|
29
|
+
const handoff = env[HANDOFF];
|
|
30
|
+
if (handoff !== undefined) {
|
|
31
|
+
delete env[HANDOFF];
|
|
32
|
+
if (!HANDOFF_NAME.test(basename(handoff)))
|
|
33
|
+
throw new Error(`${HANDOFF} is for the worker's own use; unset it`);
|
|
34
|
+
const text = readFileSync(handoff, "utf8");
|
|
35
|
+
rmSync(handoff, { force: true });
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
const inline = env.BRAINCHAIN_WORKER_KEY?.trim();
|
|
39
|
+
const file = env.BRAINCHAIN_WORKER_KEY_FILE?.trim();
|
|
40
|
+
if (inline && file)
|
|
41
|
+
throw new Error("Set BRAINCHAIN_WORKER_KEY or BRAINCHAIN_WORKER_KEY_FILE, not both");
|
|
42
|
+
return file ? fromFile(file, warn) : inline || undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Writes a new key to a file only its owner can read; an existing file is left alone, since it may hold a wallet's only key. */
|
|
45
|
+
export function writeKeyFile(path, key) {
|
|
46
|
+
const file = expandHome(path);
|
|
47
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(file, `${key}\n`, { flag: "wx", mode: 0o600 });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === "EEXIST")
|
|
53
|
+
throw new Error(`${file} already exists, and new-key never replaces a key`);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Memory rather than disk where the system offers it. The file lives for the milliseconds a restart takes. */
|
|
58
|
+
function handoffDir() {
|
|
59
|
+
try {
|
|
60
|
+
accessSync("/dev/shm", constants.W_OK);
|
|
61
|
+
return "/dev/shm";
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
|
|
65
|
+
return stateDir();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Restarts this process in place without BRAINCHAIN_WORKER_KEY in its
|
|
70
|
+
* environment. Deleting the variable would not do: `ps eww` and /proc read the
|
|
71
|
+
* block the process started with, and a command run by `run` can reach both.
|
|
72
|
+
* The pid, the output and the supervisor's view all stay as they were. Returns
|
|
73
|
+
* when there is nothing to clear, or when it could not be cleared.
|
|
74
|
+
*/
|
|
75
|
+
export function clearKeyFromEnvironment(key, warn, env = process.env, execve = process.execve) {
|
|
76
|
+
if (env.BRAINCHAIN_WORKER_KEY === undefined)
|
|
77
|
+
return;
|
|
78
|
+
if (typeof execve !== "function")
|
|
79
|
+
return warn(`warning: Node.js ${process.version} cannot clear the key from this process's environment; Node.js 22.15 or later can`);
|
|
80
|
+
const { BRAINCHAIN_WORKER_KEY: _, ...rest } = env;
|
|
81
|
+
let file;
|
|
82
|
+
try {
|
|
83
|
+
file = join(handoffDir(), `brainchain-worker-key-${randomBytes(16).toString("hex")}`);
|
|
84
|
+
writeFileSync(file, key, { flag: "wx", mode: 0o600 });
|
|
85
|
+
execve(process.execPath, [process.execPath, ...process.execArgv, process.argv[1], ...process.argv.slice(2)], { ...rest, [HANDOFF]: file });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (file !== undefined)
|
|
89
|
+
rmSync(file, { force: true });
|
|
90
|
+
warn(`warning: could not clear the key from this process's environment (${error.message})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/network.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineChain, fallback, http, zeroAddress } from "viem";
|
|
2
|
-
/** BrainChain testnet, release 3.
|
|
2
|
+
/** BrainChain testnet, release 3.5.0. */
|
|
3
3
|
export const TESTNET = {
|
|
4
4
|
id: "testnet",
|
|
5
5
|
name: "BrainChain testnet",
|
|
@@ -10,7 +10,7 @@ export const TESTNET = {
|
|
|
10
10
|
explorer: "https://usebrainchain.com/testnet",
|
|
11
11
|
stakeSymbol: "tBRAIN",
|
|
12
12
|
bountySymbol: "tUSDG",
|
|
13
|
-
gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com
|
|
13
|
+
gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com, then move it here with `npx brainchain-worker bridge --eth 0.001`",
|
|
14
14
|
proofs: {
|
|
15
15
|
chainId: 46630,
|
|
16
16
|
name: "Robinhood Chain testnet",
|
|
@@ -58,7 +58,7 @@ export const MAINNET = {
|
|
|
58
58
|
explorer: "https://usebrainchain.com",
|
|
59
59
|
stakeSymbol: "BRAIN",
|
|
60
60
|
bountySymbol: "USDG",
|
|
61
|
-
gas: "
|
|
61
|
+
gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
|
|
62
62
|
buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
|
|
63
63
|
`brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
|
|
64
64
|
proofs: {
|
package/dist/propose.js
CHANGED
|
@@ -303,7 +303,7 @@ export async function propose(network, chain, call, uri, options) {
|
|
|
303
303
|
throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
|
|
304
304
|
}
|
|
305
305
|
if (gas === 0n)
|
|
306
|
-
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}
|
|
306
|
+
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
|
|
307
307
|
log(`Bonding ${stake(network, bond)} and proposing...`);
|
|
308
308
|
const { id, tx } = await chain.propose(call, uri, bond);
|
|
309
309
|
log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
|
package/dist/standing.js
CHANGED
|
@@ -26,6 +26,39 @@ export async function snapshot(chain) {
|
|
|
26
26
|
toStake: w.staked >= min ? 0n : min - w.staked,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/** The highest gas limit seen for each transaction join can send, across testnet and mainnet. */
|
|
30
|
+
const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
|
|
31
|
+
/** The highest gas limit seen for delivering a result. */
|
|
32
|
+
const SUBMIT_GAS = 104000n;
|
|
33
|
+
/**
|
|
34
|
+
* The ETH a wallet must hold for transactions with these gas limits to go
|
|
35
|
+
* through in order. Each pays the going price, but the RPC refuses one the
|
|
36
|
+
* wallet could not cover at 2.4 times it, the fee cap it fills in.
|
|
37
|
+
*/
|
|
38
|
+
export function ethNeeded(limits, gasPrice) {
|
|
39
|
+
let spent = 0n;
|
|
40
|
+
let need = 0n;
|
|
41
|
+
for (const limit of limits) {
|
|
42
|
+
const covered = spent + (limit * gasPrice * 24n) / 10n;
|
|
43
|
+
if (covered > need)
|
|
44
|
+
need = covered;
|
|
45
|
+
spent += limit * gasPrice;
|
|
46
|
+
}
|
|
47
|
+
return need;
|
|
48
|
+
}
|
|
49
|
+
/** The gas limits of what join would send for this worker, in order. */
|
|
50
|
+
export function joinLimits(s, claims, reprices) {
|
|
51
|
+
return [
|
|
52
|
+
...(claims ? [GAS.faucet] : []),
|
|
53
|
+
...(s.toStake > 0n ? [GAS.approve, GAS.stake] : reprices ? [GAS.setPrice] : []),
|
|
54
|
+
...(s.applying ? [GAS.approve, GAS.apply] : []),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
/** An amount of ETH rounded up to two significant figures. */
|
|
58
|
+
export function aboutEth(wei) {
|
|
59
|
+
const unit = 10n ** BigInt(Math.max(wei.toString().length - 2, 0));
|
|
60
|
+
return formatEther(((wei + unit - 1n) / unit) * unit);
|
|
61
|
+
}
|
|
29
62
|
/** Why the worker can or cannot be hired right now, in one line for `run` or for `status`, which shows the rest itself. */
|
|
30
63
|
export function hireable({ allowed, w, min, applications, change, pending }, network, from) {
|
|
31
64
|
if (allowed) {
|
|
@@ -34,8 +67,11 @@ export function hireable({ allowed, w, min, applications, change, pending }, net
|
|
|
34
67
|
return `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again${from === "run" ? "; status shows what it needs" : ""}.`;
|
|
35
68
|
}
|
|
36
69
|
// An allowed application waiting out its timelock outranks anything filed after it.
|
|
37
|
-
if (change && pending)
|
|
38
|
-
|
|
70
|
+
if (change && pending) {
|
|
71
|
+
const due = BigInt(Math.floor(Date.now() / 1000)) >= change.eta;
|
|
72
|
+
const allowed = `no: Jev allowed this worker; ${due ? `the change that allows it was due at ${when(change.eta)} and has not run yet` : `it can be hired after ${when(change.eta)}`}`;
|
|
73
|
+
return w.staked >= min ? allowed : `${allowed}. Its stake is below the minimum of ${stake(network, min)}, so run join to stake again first.`;
|
|
74
|
+
}
|
|
39
75
|
const last = applications.at(-1);
|
|
40
76
|
if (last === undefined)
|
|
41
77
|
return "no: run `npx brainchain-worker join` to apply";
|
|
@@ -69,12 +105,13 @@ export function wallet(network, s, balance, bond, faucet, now) {
|
|
|
69
105
|
const buy = network.buyStake ? `. ${network.buyStake}` : "";
|
|
70
106
|
return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
|
|
71
107
|
}
|
|
72
|
-
/**
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
108
|
+
/** What a wallet short of gas holds and needs, for join's refusal and for `status`. */
|
|
109
|
+
export const shortOfGas = (network, gas, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${network.gas}.`;
|
|
110
|
+
/** The wallet's ETH, and what more join or the next delivery needs, since a send the wallet cannot cover fails partway through. */
|
|
111
|
+
export function gasLine(network, s, gas, gasPrice, claims) {
|
|
112
|
+
const limits = joinLimits(s, claims, false);
|
|
113
|
+
const need = ethNeeded(limits.length > 0 ? limits : [SUBMIT_GAS], gasPrice);
|
|
114
|
+
return gas >= need ? `${formatEther(gas)} ETH` : shortOfGas(network, gas, need, limits.length > 0 ? "join" : "delivering a result");
|
|
78
115
|
}
|
|
79
116
|
/** What join says when the wallet holds too little of a stake token that has no faucet. */
|
|
80
117
|
export function shortOfStake(network, w, balance, need, now) {
|
package/dist/state.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
|
|
5
6
|
/** The decision count a one-shot watch run stopped at, kept per decision log; undefined when there is none or it cannot be read. */
|
|
@@ -15,8 +16,10 @@ export function readWatched(log, dir = stateDir()) {
|
|
|
15
16
|
export function writeWatched(log, seen, dir = stateDir()) {
|
|
16
17
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
17
18
|
const file = join(dir, `watch.${log.toLowerCase()}.json`);
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
// Each write its own temp file: runs from cron overlap, and nothing else keeps them apart.
|
|
20
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
21
|
+
writeFileSync(temp, JSON.stringify({ seen: seen.toString() }), { mode: 0o600 });
|
|
22
|
+
renameSync(temp, file);
|
|
20
23
|
}
|
|
21
24
|
const empty = (board) => ({ board: board.toLowerCase(), tasks: {}, done: [] });
|
|
22
25
|
export function memoryStore(board = "") {
|
|
@@ -101,6 +104,26 @@ export function fileStore(file, board, warn = console.warn) {
|
|
|
101
104
|
},
|
|
102
105
|
};
|
|
103
106
|
}
|
|
107
|
+
/** How often a run touches its lock. */
|
|
108
|
+
const LOCK_BEAT_MS = 5_000;
|
|
109
|
+
/** With no touch for this long a lock was left behind, whatever process now has its pid. */
|
|
110
|
+
const LOCK_STALE_MS = 15_000;
|
|
111
|
+
/** How long a lock from another PID namespace is watched for a touch, a little over one beat. */
|
|
112
|
+
const LOCK_WATCH_MS = 7_000;
|
|
113
|
+
/** A takeover takes a few system calls, so one marker older than this was left by a crash. */
|
|
114
|
+
const TAKEOVER_STALE_MS = 5_000;
|
|
115
|
+
/**
|
|
116
|
+
* Where a pid means something. Containers sharing a config folder each have
|
|
117
|
+
* their own numbering, so a holder's pid says nothing outside its own space.
|
|
118
|
+
*/
|
|
119
|
+
function pidSpace() {
|
|
120
|
+
try {
|
|
121
|
+
return `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}/${readlinkSync("/proc/self/ns/pid")}`;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return hostname();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
104
127
|
function alive(pid) {
|
|
105
128
|
// A container restarts its process with the same pid, so our own pid in the file is a stale lock.
|
|
106
129
|
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
|
|
@@ -113,28 +136,132 @@ function alive(pid) {
|
|
|
113
136
|
return error.code === "EPERM";
|
|
114
137
|
}
|
|
115
138
|
}
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
const sleep = (ms) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
140
|
+
function readIfThere(file) {
|
|
141
|
+
try {
|
|
142
|
+
return readFileSync(file, "utf8");
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code === "ENOENT")
|
|
146
|
+
return undefined;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function touchedAt(file) {
|
|
151
|
+
try {
|
|
152
|
+
return statSync(file).mtimeMs;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Who a lock's text names. A bare pid is a lock from 0.12 or earlier; text that is neither is a lock still being written. */
|
|
159
|
+
function holderOf(text) {
|
|
160
|
+
try {
|
|
161
|
+
const value = JSON.parse(text);
|
|
162
|
+
if (typeof value === "number")
|
|
163
|
+
return { pid: value };
|
|
164
|
+
if (isObject(value) && typeof value.pid === "number" && typeof value.space === "string")
|
|
165
|
+
return { pid: value.pid, space: value.space };
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Handled below.
|
|
169
|
+
}
|
|
170
|
+
return { pid: 0, space: "" };
|
|
171
|
+
}
|
|
172
|
+
/** Whether the run that wrote a lock is still going. */
|
|
173
|
+
function isHeld(file, { pid, space }, watchMs) {
|
|
174
|
+
if (space === undefined)
|
|
175
|
+
return alive(pid);
|
|
176
|
+
const touched = touchedAt(file);
|
|
177
|
+
if (touched === undefined || Date.now() - touched >= LOCK_STALE_MS)
|
|
178
|
+
return false;
|
|
179
|
+
if (pid === 0)
|
|
180
|
+
return true;
|
|
181
|
+
if (space === pidSpace())
|
|
182
|
+
return alive(pid);
|
|
183
|
+
// A recent touch can be a run that just died, and a restarted container is one more pid namespace: a live run touches its lock again within a beat.
|
|
184
|
+
sleep(watchMs);
|
|
185
|
+
return touchedAt(file) !== touched;
|
|
186
|
+
}
|
|
187
|
+
const held = (pid, space, file) => new Error(`Another run (pid ${pid}${space === undefined || space === pidSpace() ? "" : " in another container or PID namespace"}) is already working for this worker. ` +
|
|
188
|
+
`Stop it before starting another; ${file} holds its lock.`);
|
|
189
|
+
/**
|
|
190
|
+
* Replaces a lock left behind. The marker lets one start at a time judge and
|
|
191
|
+
* replace it, since removing the file another start has just written would give
|
|
192
|
+
* the lock to both.
|
|
193
|
+
*/
|
|
194
|
+
function takeOver(file, mine, judged) {
|
|
195
|
+
const marker = `${file}.takeover`;
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(marker, "", { flag: "wx", mode: 0o600 });
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error.code !== "EEXIST")
|
|
201
|
+
throw error;
|
|
202
|
+
const at = touchedAt(marker);
|
|
203
|
+
if (at !== undefined && Date.now() - at >= TAKEOVER_STALE_MS)
|
|
204
|
+
rmSync(marker, { force: true });
|
|
205
|
+
sleep(20);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (readIfThere(file) !== judged)
|
|
210
|
+
return false;
|
|
211
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
212
|
+
writeFileSync(temp, mine, { mode: 0o600 });
|
|
213
|
+
renameSync(temp, file);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
rmSync(marker, { force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Takes the lock in `file` for this process until it exits; refuses while another
|
|
222
|
+
* run holds it, in this PID namespace or another sharing the folder. The run
|
|
223
|
+
* touches the lock every few seconds, and `lost` is called if another took it
|
|
224
|
+
* over, as can happen to a run frozen for longer than a lock lasts.
|
|
225
|
+
*/
|
|
226
|
+
export function lock(file, lost = () => process.exit(1), watchMs = LOCK_WATCH_MS) {
|
|
120
227
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
228
|
+
const mine = JSON.stringify({ pid: process.pid, space: pidSpace(), run: randomBytes(4).toString("hex") });
|
|
121
229
|
for (;;) {
|
|
122
230
|
try {
|
|
123
|
-
writeFileSync(file,
|
|
231
|
+
writeFileSync(file, mine, { flag: "wx", mode: 0o600 });
|
|
124
232
|
break;
|
|
125
233
|
}
|
|
126
234
|
catch (error) {
|
|
127
235
|
if (error.code !== "EEXIST")
|
|
128
236
|
throw error;
|
|
129
237
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
238
|
+
const text = readIfThere(file);
|
|
239
|
+
// The holder released it since the write failed, so the next try takes it.
|
|
240
|
+
if (text === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
const holder = holderOf(text);
|
|
243
|
+
if (isHeld(file, holder, watchMs))
|
|
244
|
+
throw held(holder.pid, holder.space, file);
|
|
245
|
+
if (takeOver(file, mine, text))
|
|
246
|
+
break;
|
|
135
247
|
}
|
|
248
|
+
const beat = setInterval(() => {
|
|
249
|
+
try {
|
|
250
|
+
if (readIfThere(file) !== mine) {
|
|
251
|
+
clearInterval(beat);
|
|
252
|
+
return lost();
|
|
253
|
+
}
|
|
254
|
+
const now = new Date();
|
|
255
|
+
utimesSync(file, now, now);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// A disk that fails for a moment is no reason to drop a task; the next beat tries again.
|
|
259
|
+
}
|
|
260
|
+
}, LOCK_BEAT_MS);
|
|
261
|
+
beat.unref();
|
|
136
262
|
const release = () => {
|
|
137
|
-
|
|
263
|
+
clearInterval(beat);
|
|
264
|
+
if (readIfThere(file) === mine)
|
|
138
265
|
rmSync(file, { force: true });
|
|
139
266
|
};
|
|
140
267
|
process.once("exit", release);
|
|
@@ -158,10 +285,11 @@ function migrate(old, file, board) {
|
|
|
158
285
|
if (parse(text)?.board !== board.toLowerCase())
|
|
159
286
|
return false;
|
|
160
287
|
// A run from before the move may still be working on this network's tasks from the old file.
|
|
161
|
-
const
|
|
288
|
+
const left = readIfThere(`${old}.lock`);
|
|
289
|
+
const pid = left === undefined ? 0 : holderOf(left).pid;
|
|
162
290
|
if (alive(pid))
|
|
163
|
-
throw held(pid, `${old}.lock`);
|
|
164
|
-
if (
|
|
291
|
+
throw held(pid, undefined, `${old}.lock`);
|
|
292
|
+
if (readIfThere(`${file}.json`) !== undefined)
|
|
165
293
|
return false;
|
|
166
294
|
// Removed rather than moved: this run already holds the new lock.
|
|
167
295
|
rmSync(`${old}.lock`, { force: true });
|
|
@@ -176,7 +304,10 @@ function migrate(old, file, board) {
|
|
|
176
304
|
export function openStore(address, network, log = console.log, dir = stateDir()) {
|
|
177
305
|
const old = join(dir, address);
|
|
178
306
|
const file = `${old}.${network.id}`;
|
|
179
|
-
lock(`${file}.lock
|
|
307
|
+
lock(`${file}.lock`, () => {
|
|
308
|
+
log(`another run took over ${file}.lock, so this one stops`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
});
|
|
180
311
|
if (migrate(old, file, network.contracts.taskBoard))
|
|
181
312
|
log(`moved ${old}.json to ${file}.json`);
|
|
182
313
|
return fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
|
package/dist/watch.js
CHANGED
|
@@ -114,6 +114,9 @@ export const REPORT_PREFIX = "BrainChain watchtower report\n";
|
|
|
114
114
|
export const MAX_CHECKED = 200;
|
|
115
115
|
/** The proof a record was fetched: the hash of its bytes followed by the worker's address, which nothing on chain gives away. */
|
|
116
116
|
export const fetchProof = (bytes, worker) => keccak256(concat([bytes, toBytes(worker)]));
|
|
117
|
+
/** The service answered and will not take this report, so sending it again would not help. */
|
|
118
|
+
export class ReportRefused extends Error {
|
|
119
|
+
}
|
|
117
120
|
/**
|
|
118
121
|
* Sends a round's re-verified decisions to the service, signed by the worker,
|
|
119
122
|
* so a staked worker's watchtower counts toward its mark in hire questions.
|
|
@@ -138,6 +141,8 @@ export function createReporter(network, account, fetcher = fetch) {
|
|
|
138
141
|
signal: AbortSignal.timeout(20_000),
|
|
139
142
|
});
|
|
140
143
|
const body = (await response.json().catch(() => ({})));
|
|
144
|
+
if (response.status >= 400 && response.status < 500)
|
|
145
|
+
throw new ReportRefused(body.error ?? `${response.status} from ${network.service}`);
|
|
141
146
|
if (!response.ok)
|
|
142
147
|
throw new Error(body.error ?? `${response.status} from ${network.service}`);
|
|
143
148
|
return body.mark
|
|
@@ -156,7 +161,10 @@ export function createWatch(options) {
|
|
|
156
161
|
let nextBlock = options.fromBlock ?? network.fromBlock;
|
|
157
162
|
let seenDecision = options.seenDecision;
|
|
158
163
|
const judged = new Set();
|
|
164
|
+
/** Vetoes due and not yet landed; each is tried every round until it lands or its change closes. */
|
|
165
|
+
const owed = new Map();
|
|
159
166
|
let findings = 0;
|
|
167
|
+
let incomplete = false;
|
|
160
168
|
async function changes() {
|
|
161
169
|
const head = await chain.head();
|
|
162
170
|
if (head < nextBlock)
|
|
@@ -168,36 +176,49 @@ export function createWatch(options) {
|
|
|
168
176
|
const key = change.changeId.toString();
|
|
169
177
|
if (judged.has(key))
|
|
170
178
|
continue;
|
|
171
|
-
judged.add(key);
|
|
172
179
|
const { name, broken } = judge(change.data);
|
|
180
|
+
// A failed read leaves the change unjudged and the block range unread, so the next round comes back to it.
|
|
181
|
+
const open = await chain.open(change.changeId);
|
|
182
|
+
judged.add(key);
|
|
183
|
+
const why = broken.join("; ");
|
|
184
|
+
if (!open) {
|
|
185
|
+
log(`change ${change.changeId}: ${name} on ${change.target}${broken.length > 0 ? ` broke the constitution (${why})` : ""}; closed, it has run or was vetoed`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
173
188
|
const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 16);
|
|
174
189
|
if (broken.length === 0) {
|
|
175
190
|
log(`change ${change.changeId}: ${name} on ${change.target}, within the constitution, runs after ${runsAt} UTC`);
|
|
176
191
|
continue;
|
|
177
192
|
}
|
|
178
193
|
findings += 1;
|
|
179
|
-
const why = broken.join("; ");
|
|
180
194
|
log(`change ${change.changeId}: ${name} on ${change.target} breaks the constitution (${why}), runs after ${runsAt} UTC`);
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
195
|
+
if (options.veto)
|
|
196
|
+
owed.set(key, { id: change.changeId, why });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
nextBlock = head + 1n;
|
|
200
|
+
}
|
|
201
|
+
async function vetoes() {
|
|
202
|
+
for (const [key, { id, why }] of owed) {
|
|
203
|
+
try {
|
|
204
|
+
if (!(await chain.open(id))) {
|
|
205
|
+
owed.delete(key);
|
|
206
|
+
log(`change ${id} is closed already, nothing to veto`);
|
|
186
207
|
}
|
|
187
|
-
if (!chain.veto) {
|
|
208
|
+
else if (!chain.veto) {
|
|
209
|
+
owed.delete(key);
|
|
188
210
|
log(`cannot veto: ${network.name} has no guardian gate this worker can reach`);
|
|
189
|
-
continue;
|
|
190
211
|
}
|
|
191
|
-
|
|
192
|
-
const hash = await chain.veto(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
catch (error) {
|
|
196
|
-
log(`veto of change ${change.changeId} failed: ${reason(error)}`);
|
|
212
|
+
else {
|
|
213
|
+
const hash = await chain.veto(id, `Breaks the constitution: ${why}`);
|
|
214
|
+
owed.delete(key);
|
|
215
|
+
log(`vetoed change ${id} through the guardian gate: ${hash}`);
|
|
197
216
|
}
|
|
198
217
|
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
log(`veto of change ${id} failed, tried again next round: ${reason(error)}`);
|
|
220
|
+
}
|
|
199
221
|
}
|
|
200
|
-
nextBlock = head + 1n;
|
|
201
222
|
}
|
|
202
223
|
async function decisions() {
|
|
203
224
|
if (!verify)
|
|
@@ -215,6 +236,7 @@ export function createWatch(options) {
|
|
|
215
236
|
}
|
|
216
237
|
const checked = [];
|
|
217
238
|
const found = [];
|
|
239
|
+
let reached = seenDecision;
|
|
218
240
|
for (let id = seenDecision + 1n; id <= count; id++) {
|
|
219
241
|
try {
|
|
220
242
|
const { findings: wrong, record } = await verify(id);
|
|
@@ -228,27 +250,48 @@ export function createWatch(options) {
|
|
|
228
250
|
found.push({ id, checks: wrong });
|
|
229
251
|
log(`decision ${id} does not verify: ${wrong.join("; ")}`);
|
|
230
252
|
}
|
|
253
|
+
reached = id;
|
|
231
254
|
}
|
|
232
255
|
catch (error) {
|
|
233
|
-
|
|
256
|
+
// The place stays here, so this decision and the ones after it are checked next time.
|
|
257
|
+
log(`decision ${id} could not be checked, so it is tried again next round: ${reason(error)}`.replace(/\s*\n\s*/g, " "));
|
|
258
|
+
incomplete = true;
|
|
259
|
+
break;
|
|
234
260
|
}
|
|
235
261
|
}
|
|
236
|
-
|
|
237
|
-
options.
|
|
238
|
-
|
|
239
|
-
|
|
262
|
+
// Before the place is saved, so a save that fails does not lose the round's work.
|
|
263
|
+
if (options.report && (checked.length > 0 || found.length > 0)) {
|
|
264
|
+
try {
|
|
265
|
+
log(await options.report(checked, found));
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
log(`report not taken: ${reason(error)}`);
|
|
269
|
+
// Only an answer from the service settles a report; these decisions are checked and sent again otherwise.
|
|
270
|
+
if (!(error instanceof ReportRefused)) {
|
|
271
|
+
incomplete = true;
|
|
272
|
+
reached = seenDecision;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
seenDecision = reached;
|
|
240
277
|
try {
|
|
241
|
-
|
|
278
|
+
options.saw?.(reached);
|
|
242
279
|
}
|
|
243
280
|
catch (error) {
|
|
244
|
-
log(`
|
|
281
|
+
log(`warning: could not save how far this got, so the next run checks these decisions again: ${reason(error)}`);
|
|
245
282
|
}
|
|
246
283
|
}
|
|
247
284
|
return {
|
|
248
285
|
async round() {
|
|
249
|
-
|
|
286
|
+
incomplete = false;
|
|
287
|
+
try {
|
|
288
|
+
await changes();
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await vetoes();
|
|
292
|
+
}
|
|
250
293
|
await decisions();
|
|
251
|
-
return { findings };
|
|
294
|
+
return { findings, incomplete };
|
|
252
295
|
},
|
|
253
296
|
};
|
|
254
297
|
}
|
package/dist/work.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { toBytes } from "viem";
|
|
3
4
|
import { fetchBrief, PermanentError, reason, RECEIPT_MS } from "./chain.js";
|
|
4
5
|
import { memoryStore } from "./state.js";
|
|
@@ -10,6 +11,8 @@ const OUTPUT_CAP = MAX_RESULT_BYTES * 2;
|
|
|
10
11
|
const MARGIN_SEC = 300n;
|
|
11
12
|
const RETRY_SEC = [60n, 240n, 960n];
|
|
12
13
|
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
|
14
|
+
/** How long the pipes stay open after a command exits, for what it had already written. */
|
|
15
|
+
const DRAIN_MS = 2_000;
|
|
13
16
|
const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
|
|
14
17
|
export const stripAnsi = (text) => text.replace(ANSI, "");
|
|
15
18
|
/** Text from strangers, made safe to print as one terminal line. */
|
|
@@ -133,6 +136,44 @@ function orphaned(pgid) {
|
|
|
133
136
|
const own = ps(process.pid, "pgid");
|
|
134
137
|
return own !== undefined && own !== String(pgid);
|
|
135
138
|
}
|
|
139
|
+
/** A Linux process's name and parent; undefined where /proc has no such process or there is no /proc. */
|
|
140
|
+
function procStat(pid) {
|
|
141
|
+
try {
|
|
142
|
+
const stat = /^\d+ \((.*)\) \S (\d+) /s.exec(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
143
|
+
return stat ? { comm: stat[1], ppid: Number(stat[2]) } : undefined;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The npm process above a worker it started: its parent, or its grandparent where the shell npm runs the command in stays between them. */
|
|
150
|
+
function npmAbove() {
|
|
151
|
+
const parent = procStat(process.ppid);
|
|
152
|
+
return parent && /^(sh|dash)$/.test(parent.comm) ? parent.ppid : undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Calls `lost` once when what npx put above this process dies. npx passes a stop
|
|
156
|
+
* to the shell it runs the worker in, and dash dies without passing it on, so
|
|
157
|
+
* the worker never hears it and would run on with nothing above it.
|
|
158
|
+
*/
|
|
159
|
+
export function watchStarter(lost, intervalMs = 1_000) {
|
|
160
|
+
const parent = process.ppid;
|
|
161
|
+
const npm = npmAbove();
|
|
162
|
+
const timer = setInterval(() => {
|
|
163
|
+
if (process.ppid === parent && (npm === undefined || exists(npm)))
|
|
164
|
+
return;
|
|
165
|
+
clearInterval(timer);
|
|
166
|
+
lost();
|
|
167
|
+
}, intervalMs);
|
|
168
|
+
timer.unref();
|
|
169
|
+
}
|
|
170
|
+
/** Whether a supervisor started the npx above this process, as PID 1, as a child of PID 1 or as a systemd service. A stop from it can reach no further than npx. */
|
|
171
|
+
export function npxUnderSupervisor(env = process.env) {
|
|
172
|
+
if (procStat(process.ppid) === undefined)
|
|
173
|
+
return false;
|
|
174
|
+
const npm = npmAbove() ?? process.ppid;
|
|
175
|
+
return npm === 1 || procStat(npm)?.ppid === 1 || env.INVOCATION_ID !== undefined;
|
|
176
|
+
}
|
|
136
177
|
/**
|
|
137
178
|
* Stops the command an earlier run left running when it died, such as on
|
|
138
179
|
* SIGKILL, so a restart never works on the same task twice at once. It gets
|
|
@@ -200,6 +241,15 @@ export function shellCommand(command, graceMs = 10_000) {
|
|
|
200
241
|
}, graceMs);
|
|
201
242
|
};
|
|
202
243
|
const timer = setTimeout(() => stop(`The command ran past ${Math.round(timeoutMs / 1000)} seconds and was stopped`), timeoutMs);
|
|
244
|
+
let drain;
|
|
245
|
+
child.on("exit", () => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
// A background process can hold the pipes open long after the command has exited, and close would wait for it.
|
|
248
|
+
drain = setTimeout(() => {
|
|
249
|
+
child.stdout.destroy();
|
|
250
|
+
child.stderr.destroy();
|
|
251
|
+
}, DRAIN_MS);
|
|
252
|
+
});
|
|
203
253
|
child.stdout.on("data", (chunk) => {
|
|
204
254
|
size += chunk.byteLength;
|
|
205
255
|
if (size <= OUTPUT_CAP)
|
|
@@ -209,13 +259,18 @@ export function shellCommand(command, graceMs = 10_000) {
|
|
|
209
259
|
child.on("error", (error) => {
|
|
210
260
|
clearTimeout(timer);
|
|
211
261
|
clearTimeout(force);
|
|
262
|
+
clearTimeout(drain);
|
|
212
263
|
reject(error);
|
|
213
264
|
});
|
|
214
265
|
child.on("close", (code, signal) => {
|
|
215
266
|
clearTimeout(timer);
|
|
216
267
|
clearTimeout(force);
|
|
217
|
-
|
|
268
|
+
clearTimeout(drain);
|
|
269
|
+
if (pid !== undefined) {
|
|
270
|
+
// The task is over, and nothing a stranger's brief started should outlive it.
|
|
271
|
+
signalGroup(pid, "SIGKILL");
|
|
218
272
|
running.delete(pid);
|
|
273
|
+
}
|
|
219
274
|
if (running.size === 0)
|
|
220
275
|
process.off("exit", kill);
|
|
221
276
|
if (stopped !== undefined)
|
|
@@ -364,7 +419,7 @@ export function createWork(options) {
|
|
|
364
419
|
delete attempt.tx;
|
|
365
420
|
store.save();
|
|
366
421
|
}
|
|
367
|
-
if (now + MARGIN_SEC >= task.deadline) {
|
|
422
|
+
if (attempt.result === undefined && now + MARGIN_SEC >= task.deadline) {
|
|
368
423
|
finish(key);
|
|
369
424
|
fail();
|
|
370
425
|
log(`task ${task.id}: too close to the deadline to deliver; the poster is refunded`);
|
|
@@ -420,7 +475,8 @@ export function createWork(options) {
|
|
|
420
475
|
const wait = RETRY_SEC[Math.min(attempt.failures - 1, RETRY_SEC.length - 1)];
|
|
421
476
|
// The attempt may have run for many minutes, so the retry is timed from now.
|
|
422
477
|
const now = await chain.now().catch(wall);
|
|
423
|
-
|
|
478
|
+
// A retry with the result in hand only has to deliver it, so the margin for running the command does not apply.
|
|
479
|
+
if (now + wait + (attempt.result === undefined ? MARGIN_SEC : 0n) >= task.deadline) {
|
|
424
480
|
finish(key);
|
|
425
481
|
log(`task ${task.id}: failed (${why}); no time left to retry, so the poster is refunded`);
|
|
426
482
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Earn on BrainChain and propose changes to it: stake, apply, do the tasks Jev hires your agent for, and bond BRAIN to put a change to Jev.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BrainChain <hello@usebrainchain.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"prepack": "npm run build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"brainchain-verify": "^0.
|
|
31
|
+
"brainchain-verify": "^0.4.0",
|
|
32
32
|
"viem": "^2.0.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|