brainchain-worker 0.13.1 → 0.15.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/dist/mint.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs, zeroAddress, } from "viem";
2
- import { reason, RECEIPT_MS } from "./chain.js";
2
+ import { reason, RECEIPT_MS, REFUND_REASONS } from "./chain.js";
3
3
  import { proofsChainOf, proofsTransport } from "./network.js";
4
- import { when } from "./standing.js";
4
+ import { stake, usdg, when } from "./standing.js";
5
5
  export const collectionAbi = parseAbi([
6
6
  "function PRICE_QUOTE() view returns (uint256)",
7
7
  "function mint(uint256 quantity, uint256 minRoutedTotal) payable",
@@ -23,13 +23,13 @@ export const wethAbi = parseAbi([
23
23
  export const MAX_MINT = 10;
24
24
  /** How often a delivered task's status is read until it settles. */
25
25
  const STATUS_SEC = 300;
26
- /** Grading, a dispute and its ruling take up to 6 hours each on mainnet, so a task unsettled after a day is left. */
27
- const WATCH_SEC = 86_400;
26
+ /** Each step from grading to a ruling on appeal has a window of at most 6 hours, so a task unsettled two days after delivery is left. */
27
+ const WATCH_SEC = 2 * 86_400;
28
28
  /** A day after payment the task joins the public pool, so the worker's own attempts stop an hour short of it. */
29
29
  const OWN_SEC = 23 * 3_600;
30
30
  /** The least time between two mint attempts for one task. */
31
31
  const RETRY_SEC = 3_600;
32
- /** The service lists a payment a round or two after it lands; a task still unlisted this long after has its piece. */
32
+ /** The service lists a payment a round or two after it lands, which has taken over ten minutes: this long, the listing is read every minute. */
33
33
  const LISTING_SEC = 900;
34
34
  /** The piece a mint's receipt shows the collection minting to `to`. */
35
35
  export function mintedToken(logs, collection, to) {
@@ -140,7 +140,11 @@ async function afford(proofs, chain, count, options) {
140
140
  : `The test WETH faucet allows one claim a day, and this wallet can claim again at ${when(opensAt)}.`);
141
141
  }
142
142
  const holds = `${chain.account} holds ${formatEther(weth)} WETH${needEth ? " and no ETH" : ""} on ${proofs.name}`;
143
- return { price, missing: `${holds}, and ${proofCount(count)} cost${count === 1 ? "s" : ""} ${formatEther(cost)} WETH plus gas. ${fixes.join(" ")}` };
143
+ return {
144
+ price,
145
+ missing: `${holds}, and ${proofCount(count)} cost${count === 1 ? "s" : ""} ${formatEther(cost)} WETH plus gas. ${fixes.join(" ")}`,
146
+ ...(needEth ? { noEth: true } : {}),
147
+ };
144
148
  }
145
149
  /**
146
150
  * Mints a piece for each of the worker's paid tasks the service lists without
@@ -178,13 +182,14 @@ export async function mint(network, chain, options) {
178
182
  log(rest.trim());
179
183
  }
180
184
  /**
181
- * What `run` does for Proofs: it watches each task the worker delivers until
182
- * it settles and mints a piece for each paid one, at most once an hour per
183
- * task, and only while the service lists the task as waiting for a piece. The
184
- * watched tasks live in the state, so a restart neither forgets nor repeats a
185
- * mint. With `enabled` false, as under --no-mint, it does nothing.
185
+ * What `run` does once a task is delivered: it watches the task until it
186
+ * settles and logs how: paid and how much, or refunded, why and any stake
187
+ * slashed. With `mints`, it then mints a piece for each paid task, at most once
188
+ * an hour per task, while the service lists the task as waiting for one and
189
+ * the worker can still mint it. The watched tasks live in the state, so a
190
+ * restart neither forgets nor repeats a mint.
186
191
  */
187
- export function createProofMinter(options) {
192
+ export function createSettlementWatch(options) {
188
193
  const { network, chain, store, log } = options;
189
194
  const clock = options.now ?? wall;
190
195
  const watched = () => (store.state.proofs ??= {});
@@ -198,49 +203,77 @@ export function createProofMinter(options) {
198
203
  entry.nextAt = clock() + seconds;
199
204
  store.save();
200
205
  };
201
- /** Reads whether a delivered task settled; true once it is paid. */
206
+ /** Reads whether a delivered task settled and logs how; true once it is paid and has a Proof to mint. */
202
207
  async function settle(key, entry) {
203
208
  let outcome;
204
209
  try {
205
- outcome = await options.settled(BigInt(key));
210
+ outcome = await options.settlement(BigInt(key));
206
211
  }
207
212
  catch (error) {
208
213
  later(entry, STATUS_SEC);
209
- log(`task ${key}: could not read whether it was paid (${reason(error)})`);
210
- return false;
211
- }
212
- if (outcome === "refunded") {
213
- drop(key, "refunded to its poster, so it gets no proof");
214
+ log(`task ${key}: could not read whether it settled (${reason(error)})`);
214
215
  return false;
215
216
  }
216
217
  if (outcome === undefined) {
217
218
  if (clock() - entry.deliveredAt >= WATCH_SEC)
218
- drop(key, "not settled a day after delivery, so the worker mints no proof for it");
219
+ drop(key, "not settled two days after delivery, so the worker stops watching it");
219
220
  else
220
221
  later(entry, STATUS_SEC);
221
222
  return false;
222
223
  }
223
- entry.paidAt = clock();
224
+ if (!outcome.paid) {
225
+ const slashed = outcome.slashed > 0n ? `; ${stake(network, outcome.slashed)} of the worker's stake was slashed` : "";
226
+ drop(key, `refunded to its poster: ${REFUND_REASONS[outcome.reason] ?? `reason ${outcome.reason}`}${slashed}`);
227
+ return false;
228
+ }
229
+ log(`task ${key}: paid ${usdg(network, outcome.amount)}`);
230
+ if (!options.mints) {
231
+ drop(key);
232
+ return false;
233
+ }
234
+ entry.paidAt = Number(outcome.at);
224
235
  store.save();
225
236
  return true;
226
237
  }
227
238
  async function mintFor(key, entry, listed) {
228
- const paidAt = entry.paidAt;
229
- if (clock() - paidAt >= OWN_SEC)
239
+ const until = entry.paidAt + OWN_SEC;
240
+ if (clock() >= until) {
241
+ if (Array.isArray(listed) && !listed.includes(BigInt(key)) && !entry.listed) {
242
+ return drop(key, "the BrainChain service never listed it as waiting for a proof, so the worker minted none");
243
+ }
230
244
  return drop(key, "no proof minted within a day of payment, so the task joins the public pool");
245
+ }
231
246
  if (Array.isArray(listed) && !listed.includes(BigInt(key))) {
232
- if (clock() - paidAt < LISTING_SEC)
247
+ // Once listed, a task leaves the list when it has its piece.
248
+ if (entry.listed)
249
+ return drop(key, "the BrainChain service no longer lists it as waiting for a proof, so it has one");
250
+ if (clock() - entry.paidAt < LISTING_SEC)
233
251
  return later(entry, 60);
234
- return drop(key, "the BrainChain service lists no proof waiting for it, so the worker mints none");
252
+ if (!entry.waited) {
253
+ entry.waited = true;
254
+ log(`task ${key}: the BrainChain service does not list it as waiting for a proof yet; checking every ${STATUS_SEC / 60} minutes until ${when(BigInt(until))}`);
255
+ }
256
+ return later(entry, STATUS_SEC);
235
257
  }
258
+ if (Array.isArray(listed))
259
+ entry.listed = true;
236
260
  // Counted before anything is sent, so a restart during the mint waits out the hour too.
237
261
  later(entry, RETRY_SEC);
238
262
  if (listed instanceof Error)
239
263
  return log(`task ${key}: no proof minted yet (${reason(listed)}); trying again in an hour`);
240
264
  try {
241
265
  const funds = await afford(network.proofs, chain, 1, { dry: false, claiming: () => log("claiming test WETH from the faucet"), now: clock });
242
- if (funds.missing)
243
- return log(`task ${key}: no proof minted yet. ${funds.missing} Trying again in an hour.`);
266
+ if (funds.missing) {
267
+ if (network.id !== "testnet" || !funds.noEth)
268
+ return log(`task ${key}: no proof minted yet. ${funds.missing} Trying again in an hour.`);
269
+ // No agent route leads to ETH on Robinhood Chain testnet, so the hourly retries say nothing new.
270
+ if (entry.told)
271
+ return;
272
+ entry.told = true;
273
+ store.save();
274
+ return log(`task ${key}: no proof minted. ${funds.missing} It tries again every hour, without another line, until ${when(BigInt(until))}; ` +
275
+ "an hour later the Proof goes to the public pool. Proofs earn no income, and `run` without `--mint` mints none.");
276
+ }
244
277
  await chain.approve(funds.price);
245
278
  const { tokenId, tx } = await chain.mint();
246
279
  drop(key);
@@ -253,15 +286,13 @@ export function createProofMinter(options) {
253
286
  return {
254
287
  /** Watches a task the worker just delivered. */
255
288
  track(task) {
256
- if (!options.enabled)
257
- return;
258
289
  const now = clock();
259
290
  watched()[task.toString()] = { deliveredAt: now, nextAt: now + STATUS_SEC };
260
291
  store.save();
261
292
  },
262
- /** Reads every watched task that is due, and mints for each paid one. */
293
+ /** Reads every watched task that is due, logs each that settled, and mints for each paid one. */
263
294
  async round() {
264
- if (!options.enabled || store.state.proofs === undefined)
295
+ if (store.state.proofs === undefined)
265
296
  return;
266
297
  const due = Object.entries(watched()).filter(([, entry]) => clock() >= entry.nextAt);
267
298
  const paid = [];
@@ -270,6 +301,12 @@ export function createProofMinter(options) {
270
301
  paid.push([key, entry]);
271
302
  if (paid.length === 0)
272
303
  return;
304
+ // A run under --no-mint after one that minted leaves those tasks' Proofs to `mint`.
305
+ if (!options.mints) {
306
+ for (const [key] of paid)
307
+ drop(key);
308
+ return;
309
+ }
273
310
  const listed = await pendingTasks(network, chain.account, options.fetcher).catch((error) => (error instanceof Error ? error : new Error(String(error))));
274
311
  for (const [key, entry] of paid)
275
312
  await mintFor(key, entry, listed);
package/dist/network.js CHANGED
@@ -10,10 +10,13 @@ export const TESTNET = {
10
10
  explorer: "https://usebrainchain.com/testnet",
11
11
  stakeSymbol: "tBRAIN",
12
12
  bountySymbol: "tUSDG",
13
- // The drip needs no browser, so an agent can fund itself; the faucet asks for a browser check and a Google account.
14
- gas: (address) => "Ask the testnet gas drip first: `curl -X POST https://brain.testnet.usebrainchain.com/testnet/gas -H 'content-type: application/json' " +
15
- `-d '{"address":"${address}"}'\` sends 0.0001 ETH, enough to join, once per address. After that, get Robinhood Chain testnet ETH from ` +
16
- "https://faucet.testnet.chain.robinhood.com and move it here with `npx brainchain-worker bridge --eth 0.001`",
13
+ bountyFaucet: true,
14
+ gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com, which asks for a browser check and a Google account, " +
15
+ "so a person claims it, and move it here with `npx brainchain-worker bridge --eth 0.001`",
16
+ drip: {
17
+ wei: 100000000000000n,
18
+ request: (address) => `\`curl -X POST https://brain.testnet.usebrainchain.com/testnet/gas -H 'content-type: application/json' -d '{"address":"${address}"}'\``,
19
+ },
17
20
  proofs: {
18
21
  chainId: 46630,
19
22
  name: "Robinhood Chain testnet",
@@ -22,7 +25,9 @@ export const TESTNET = {
22
25
  collection: "0x324B52EE2182641ec29652076ab541bc9F9907d2",
23
26
  weth: "0x1F852eF3Afa13156b807204d838BbB46B4b452eb",
24
27
  faucet: true,
25
- gas: "Get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com",
28
+ // The gas drip funds BrainChain testnet only, and this faucet asks for a browser check, so a person has to claim it.
29
+ gas: "A person can get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com, which asks for a browser check and a Google account, " +
30
+ "and send it to this address; BrainChain's gas drip sends ETH on BrainChain testnet only",
26
31
  },
27
32
  bridge: {
28
33
  chainId: 46630,
@@ -61,7 +66,7 @@ export const MAINNET = {
61
66
  explorer: "https://usebrainchain.com",
62
67
  stakeSymbol: "BRAIN",
63
68
  bountySymbol: "USDG",
64
- gas: () => "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
69
+ gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
65
70
  buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
66
71
  `brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
67
72
  proofs: {
package/dist/proc.js ADDED
@@ -0,0 +1,34 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ /** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
4
+ export function ps(pid, field) {
5
+ const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
6
+ return result.status === 0 ? result.stdout.trim() : undefined;
7
+ }
8
+ /**
9
+ * When a Linux process started, from /proc, which slim container images have
10
+ * without ps. The kernel counts it in ticks since boot, 100 a second on every
11
+ * architecture Node.js runs on.
12
+ */
13
+ function procStart(pid) {
14
+ try {
15
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
16
+ const ticks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
17
+ const boot = Number(/^btime (\d+)$/m.exec(readFileSync("/proc/stat", "utf8"))?.[1]);
18
+ return Number.isSafeInteger(ticks) && boot > 0 ? boot * 1000 + ticks * 10 : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /** When a process started, in ms since the epoch, or undefined when it is gone or neither /proc nor ps can tell. */
25
+ export function startTime(pid) {
26
+ const started = procStart(pid);
27
+ if (started !== undefined)
28
+ return started;
29
+ const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
30
+ if (!elapsed)
31
+ return undefined;
32
+ const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
33
+ return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
34
+ }
package/dist/propose.js CHANGED
@@ -2,7 +2,7 @@ import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, d
2
2
  import { erc20Errors } from "./abi.js";
3
3
  import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
4
4
  import { chainOf } from "./network.js";
5
- import { stake, when } from "./standing.js";
5
+ import { gasHelp, stake, when } from "./standing.js";
6
6
  /** Every call the executor can be asked to make, by the contract it calls. */
7
7
  export const GOVERNED = {
8
8
  board: parseAbi([
@@ -198,6 +198,7 @@ export function createProposals(network, account) {
198
198
  account: account.address,
199
199
  now: async () => (await client.getBlock()).timestamp,
200
200
  ethBalance: () => client.getBalance({ address: account.address }),
201
+ sent: () => client.getTransactionCount({ address: account.address }),
201
202
  stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
202
203
  bond: () => client.readContract({ ...proposals, functionName: "bond" }),
203
204
  decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
@@ -303,7 +304,7 @@ export async function propose(network, chain, call, uri, options) {
303
304
  throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
304
305
  }
305
306
  if (gas === 0n)
306
- throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas(chain.account)}.`);
307
+ throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
307
308
  log(`Bonding ${stake(network, bond)} and proposing...`);
308
309
  const { id, tx } = await chain.propose(call, uri, bond);
309
310
  log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
package/dist/standing.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { formatEther, formatUnits } from "viem";
2
- import { ApplicationState } from "./chain.js";
2
+ import { ApplicationState, Status } from "./chain.js";
3
3
  /** A token amount with thousands separators, such as 1,250,000; a fraction is kept as it is. */
4
- function grouped(amount, decimals) {
4
+ export function grouped(amount, decimals) {
5
5
  const [whole, fraction] = formatUnits(amount, decimals).split(".");
6
6
  return `${whole.replace(/\B(?=(\d{3})+$)/g, ",")}${fraction === undefined ? "" : `.${fraction}`}`;
7
7
  }
@@ -27,7 +27,7 @@ export async function snapshot(chain) {
27
27
  };
28
28
  }
29
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 };
30
+ export const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
31
31
  /** The highest gas limit seen for delivering a result. */
32
32
  const SUBMIT_GAS = 104000n;
33
33
  /**
@@ -105,13 +105,42 @@ export function wallet(network, s, balance, bond, faucet, now) {
105
105
  const buy = network.buyStake ? `. ${network.buyStake}` : "";
106
106
  return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
107
107
  }
108
+ /**
109
+ * Where a wallet gets ETH for gas, given what it holds and how many
110
+ * transactions it has sent. The drip serves an address once and turns away one
111
+ * that holds a drip's worth, so only a wallet that has sent nothing and holds
112
+ * less is sure to get it; any other is sent to the faucet.
113
+ */
114
+ export function gasHelp(network, address, gas, sent) {
115
+ const { drip } = network;
116
+ if (!drip)
117
+ return network.gas;
118
+ if (sent > 0 || gas >= drip.wei)
119
+ return `${network.gas}. The gas drip serves each address once`;
120
+ const after = `${network.gas[0].toLowerCase()}${network.gas.slice(1)}`;
121
+ return `Ask the testnet gas drip first: ${drip.request(address)} sends ${formatEther(drip.wei)} ETH, enough to join, once per address. After that, ${after}`;
122
+ }
108
123
  /** What a wallet short of gas holds and needs, for join's refusal and for `status`. */
109
- export const shortOfGas = (network, address, gas, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${network.gas(address)}.`;
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, address, s, gas, gasPrice, claims) {
124
+ export const shortOfGas = (network, address, gas, sent, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${gasHelp(network, address, gas, sent)}.`;
125
+ /** How many results the wallet's ETH delivers at this gas price, the last of them at the fee cap the RPC checks. */
126
+ export function deliveriesLeft(gas, gasPrice) {
127
+ const price = gasPrice > 0n ? gasPrice : 1n;
128
+ const last = ethNeeded([SUBMIT_GAS], price);
129
+ return gas < last ? 0n : (gas - last) / (SUBMIT_GAS * price) + 1n;
130
+ }
131
+ /** About how many results the wallet's ETH delivers, for `status` and `run`. */
132
+ export const deliveries = (count) => `about ${count} ${count === 1n ? "delivery" : "deliveries"}`;
133
+ /**
134
+ * The wallet's ETH, and what more join or the next delivery needs, since a
135
+ * send the wallet cannot cover fails partway through. A worker with nothing to
136
+ * join hears how many results its ETH delivers.
137
+ */
138
+ export function gasLine(network, address, s, gas, gasPrice, claims, sent) {
112
139
  const limits = joinLimits(s, claims, false);
113
140
  const need = ethNeeded(limits.length > 0 ? limits : [SUBMIT_GAS], gasPrice);
114
- return gas >= need ? `${formatEther(gas)} ETH` : shortOfGas(network, address, gas, need, limits.length > 0 ? "join" : "delivering a result");
141
+ if (gas < need)
142
+ return shortOfGas(network, address, gas, sent, need, limits.length > 0 ? "join" : "delivering a result");
143
+ return limits.length > 0 ? `${formatEther(gas)} ETH` : `${formatEther(gas)} ETH, enough for ${deliveries(deliveriesLeft(gas, gasPrice))} at the current gas price`;
115
144
  }
116
145
  /** What join says when the wallet holds too little of a stake token that has no faucet. */
117
146
  export function shortOfStake(network, w, balance, need, now) {
@@ -119,3 +148,30 @@ export function shortOfStake(network, w, balance, need, now) {
119
148
  const unstaking = w.unstaking > 0n ? ` Or run withdraw ${withdraw} and join with the ${stake(network, w.unstaking)} it returns.` : "";
120
149
  return `The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs. ${network.buyStake}${unstaking}`;
121
150
  }
151
+ /** Where a task in flight stands and what happens next, for `status`. */
152
+ export function flightLine(f) {
153
+ const task = `task ${f.id}`;
154
+ switch (f.status) {
155
+ case Status.Hired:
156
+ return `${task}: hired, due by ${when(f.deadline)}`;
157
+ case Status.Submitted:
158
+ return `${task}: delivered, waiting for Jev's grade`;
159
+ case Status.Graded: {
160
+ const closes = when(f.gradedAt + f.disputeWindow);
161
+ return f.accepted
162
+ ? `${task}: accepted, paid after ${closes} unless the grade is disputed`
163
+ : `${task}: not accepted, refunded to the poster after ${closes} unless the grade is disputed with \`dispute ${f.id}\``;
164
+ }
165
+ case Status.Disputed:
166
+ return `${task}: grade disputed by ${f.disputedByModel ? "this worker" : "the poster"}, Jev rules by ${when(f.disputedAt + f.ruleWindow)}`;
167
+ case Status.Ruled: {
168
+ const a = f.appeal;
169
+ const closes = when(a.ruledAt + a.window);
170
+ return a.pays
171
+ ? `${task}: the ruling pays this worker after ${closes} unless the poster appeals`
172
+ : `${task}: the ruling refunds the poster after ${closes} unless appealed with \`appeal ${f.id}\``;
173
+ }
174
+ default:
175
+ return `${task}: ruling appealed, Jev rules again by ${when(f.appeal.appealedAt + f.ruleWindow)}`;
176
+ }
177
+ }
package/dist/state.js CHANGED
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { startTime } from "./proc.js";
5
6
  export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
6
7
  /** The decision count a one-shot watch run stopped at, kept per decision log; undefined when there is none or it cannot be read. */
7
8
  export function readWatched(log, dir = stateDir()) {
@@ -38,10 +39,11 @@ function isTask(value) {
38
39
  return false;
39
40
  return value.stored === undefined || (isObject(value.stored) && typeof value.stored.uri === "string" && isHash(value.stored.hash));
40
41
  }
42
+ const isFlag = (value) => value === undefined || value === true;
41
43
  function isProofs(value) {
42
44
  if (!isObject(value) || !isIds(Object.keys(value)))
43
45
  return false;
44
- return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)));
46
+ return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)) && isFlag(p.listed) && isFlag(p.waited) && isFlag(p.told));
45
47
  }
46
48
  /** Whether a parsed file is a state this worker can go on from; any other shape fails every round that reads it. */
47
49
  function isState(value) {
@@ -112,6 +114,8 @@ const LOCK_STALE_MS = 15_000;
112
114
  const LOCK_WATCH_MS = 7_000;
113
115
  /** A takeover takes a few system calls, so one marker older than this was left by a crash. */
114
116
  const TAKEOVER_STALE_MS = 5_000;
117
+ /** How far a start time read from ps, which gives it to the second, can fall after the true one. */
118
+ const START_SLACK_MS = 2_000;
115
119
  /**
116
120
  * Where a pid means something. Containers sharing a config folder each have
117
121
  * their own numbering, so a holder's pid says nothing outside its own space.
@@ -169,14 +173,27 @@ function holderOf(text) {
169
173
  }
170
174
  return { pid: 0, space: "" };
171
175
  }
176
+ /**
177
+ * Whether the run that wrote a bare-pid lock is still going. 0.12 and earlier
178
+ * wrote the pid once and never touched the file again, so the lock's age says
179
+ * nothing. A process that started after the lock was written, as pid 1 does
180
+ * when a container restarts, has only come to reuse the pid.
181
+ */
182
+ function bareHeld(pid, writtenAt) {
183
+ if (!alive(pid))
184
+ return false;
185
+ const started = startTime(pid);
186
+ return started === undefined || started <= writtenAt + START_SLACK_MS;
187
+ }
172
188
  /** Whether the run that wrote a lock is still going. */
173
189
  function isHeld(file, { pid, space }, watchMs) {
174
190
  const touched = touchedAt(file);
175
- if (touched === undefined || Date.now() - touched >= LOCK_STALE_MS)
191
+ if (touched === undefined)
176
192
  return false;
177
- // A bare pid was written in this PID namespace or one gone since, so after a restart a live pid can be another process.
178
193
  if (space === undefined)
179
- return alive(pid);
194
+ return bareHeld(pid, touched);
195
+ if (Date.now() - touched >= LOCK_STALE_MS)
196
+ return false;
180
197
  if (pid === 0)
181
198
  return true;
182
199
  if (space === pidSpace())
package/dist/work.js CHANGED
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { toBytes } from "viem";
4
4
  import { fetchBrief, PermanentError, reason, RECEIPT_MS } from "./chain.js";
5
+ import { ps, startTime } from "./proc.js";
5
6
  import { memoryStore } from "./state.js";
6
7
  /** The most the service accepts for one result. */
7
8
  export const MAX_RESULT_BYTES = 100_000;
@@ -15,10 +16,26 @@ const DEFAULT_TIMEOUT_MS = 20 * 60_000;
15
16
  const DRAIN_MS = 2_000;
16
17
  const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
17
18
  export const stripAnsi = (text) => text.replace(ANSI, "");
19
+ /** Characters that show nothing or turn the text around them, so text that holds them can read as other text. */
20
+ export const HIDDEN = /[\u00ad\u061c\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
21
+ /**
22
+ * What a worker's name and description may not hold: control, format and
23
+ * separator characters, and the default-ignorable code points Unicode files
24
+ * as letters or marks, which no property escape reaches, plus the blank
25
+ * braille pattern. Each shows nothing, breaks the line or turns the text
26
+ * around, so a name that holds one can read as another.
27
+ */
28
+ const UNSEEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}͏ᅟᅠ឴឵᠋-᠏⁥⠀ㅤ︀-️ᅠ￰-￸\u{e0000}-\u{e0fff}]/u;
29
+ /** The first character of `text` that UNSEEN refuses, as U+XXXX, or undefined when there is none. */
30
+ export function unseen(text) {
31
+ const found = UNSEEN.exec(text)?.[0];
32
+ return found === undefined ? undefined : `U+${found.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
33
+ }
18
34
  /** Text from strangers, made safe to print as one terminal line. */
19
35
  export const printable = (text) => stripAnsi(text)
20
36
  .replace(/[\t\n\r]+/g, " ")
21
- .replace(/[\x00-\x1f\x7f-\x9f]/g, "");
37
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
38
+ .replace(new RegExp(HIDDEN.source, "g"), "");
22
39
  export function parseBrief(text) {
23
40
  let value;
24
41
  try {
@@ -93,19 +110,6 @@ function signalGroup(pid, signal) {
93
110
  throw error;
94
111
  }
95
112
  }
96
- /** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
97
- function ps(pid, field) {
98
- const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
99
- return result.status === 0 ? result.stdout.trim() : undefined;
100
- }
101
- /** When a process started, in ms since the epoch, or undefined when it is gone or ps cannot tell. */
102
- function startTime(pid) {
103
- const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
104
- if (!elapsed)
105
- return undefined;
106
- const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
107
- return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
108
- }
109
113
  function exists(pid) {
110
114
  try {
111
115
  process.kill(pid, 0);
@@ -355,8 +359,10 @@ export function createWork(options) {
355
359
  store.save();
356
360
  };
357
361
  const needGas = async () => {
358
- if ((await chain.ethBalance()) === 0n)
359
- throw new Error("The wallet has no ETH to pay gas for the submit. Add some and the task is tried again");
362
+ if ((await chain.ethBalance()) === 0n) {
363
+ const help = await options.gasHelp?.().catch(() => undefined);
364
+ throw new Error(`The wallet has no ETH to pay gas for the submit${help ? `. ${help}` : ""}. The task is tried again`);
365
+ }
360
366
  };
361
367
  const inTime = async (task) => {
362
368
  if ((await chain.now()) >= task.deadline)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.13.1",
3
+ "version": "0.15.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>",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "brainchain-verify": "^0.4.0",
32
- "viem": "^2.0.0"
32
+ "viem": "^2.57.12"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.10.2",