brainchain-worker 0.18.2 → 0.18.4

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/watch.js CHANGED
@@ -2,9 +2,11 @@ import { concat, createPublicClient, createWalletClient, keccak256, parseAbi, to
2
2
  import { brainchainTransport, executorAbi, reason } from "./chain.js";
3
3
  import { chainOf } from "./network.js";
4
4
  /**
5
- * The constitution's floors, by the function a change calls. A change that
6
- * crosses one is what the guardian's veto is for. Values in the units the
7
- * contracts take: basis points, seconds, and USDG in 6 decimals.
5
+ * The calls the constitution's monitor knows, by the function a change calls.
6
+ * A change that crosses a floor is what the guardian's veto is for, and any
7
+ * other call counts as crossing one. Three known calls have no floor, and say
8
+ * what they do instead. Values in the units the contracts take: basis points,
9
+ * seconds, and USDG in 6 decimals.
8
10
  */
9
11
  export const FLOORS = {
10
12
  "0x85327a44": {
@@ -32,9 +34,9 @@ export const FLOORS = {
32
34
  name: "setModel",
33
35
  check: (w) => [w[1] === 0n && "a house model with no account", w[2] > 5000000n && "a house model priced above 5 USDG"].filter((r) => typeof r === "string"),
34
36
  },
35
- "0x16c38b3c": { name: "setPaused", check: () => [] },
36
- "0x1f1e4d9f": { name: "setWorkerAllowed", check: () => [] },
37
- "0xa9059cbb": { name: "transfer", check: () => [] },
37
+ "0x16c38b3c": { name: "setPaused", check: () => [], does: "pauses or resumes new posts" },
38
+ "0x1f1e4d9f": { name: "setWorkerAllowed", check: () => [], does: "allows or removes a worker" },
39
+ "0xa9059cbb": { name: "transfer", check: () => [], does: "moves house treasury funds" },
38
40
  };
39
41
  /** The static words after the selector; a call with dynamic arguments is read as far as its words go. */
40
42
  export function argumentsOf(data) {
@@ -44,13 +46,13 @@ export function argumentsOf(data) {
44
46
  words.push(BigInt(`0x${body.slice(i, i + 64)}`));
45
47
  return words;
46
48
  }
47
- /** What a queued change does to the constitution: nothing, or the floors it crosses. */
49
+ /** What a queued change does to the constitution: nothing, the floors it crosses, or, for a known call with no floor, what it does. */
48
50
  export function judge(data) {
49
51
  const selector = data.slice(0, 10).toLowerCase();
50
52
  const floor = FLOORS[selector];
51
53
  if (!floor)
52
- return { name: selector, broken: ["a call the constitution does not list"] };
53
- return { name: floor.name, broken: floor.check(argumentsOf(data)) };
54
+ return { name: selector, broken: ["a call the monitor does not know, which counts as breaking a floor"] };
55
+ return { name: floor.name, broken: floor.check(argumentsOf(data)), ...(floor.does ? { does: floor.does } : {}) };
54
56
  }
55
57
  const gateAbi = parseAbi(["function veto(uint256 changeId, string reason)", "function watcher() view returns (address)"]);
56
58
  const logAbi = parseAbi(["function count() view returns (uint256)"]);
@@ -108,10 +110,53 @@ export async function requireWatcher(chain, log) {
108
110
  throw new Error(`This key cannot veto: the gate's watcher is ${watcher}, not ${chain.account}. Set BRAINCHAIN_WORKER_KEY to the watcher's key, or run watch without --veto.`);
109
111
  }
110
112
  }
113
+ /** How long after a decision was recorded a record that cannot be read may hold a round's place. */
114
+ export const UNREADABLE_MS = 86_400_000;
115
+ /**
116
+ * A record that cannot be read stops the round and holds its place, so a host's
117
+ * outage costs no check. Only for a day from the decision's block time: one dead
118
+ * address would otherwise blind a watcher to every decision after it, so past
119
+ * that it is a finding, reported once, and the round moves on.
120
+ */
121
+ function unreadable(why, recordedAt, now) {
122
+ if (recordedAt === undefined || !(now - Date.parse(recordedAt) >= UNREADABLE_MS))
123
+ throw new Error(why);
124
+ return { findings: [`the record could not be read a day or more after it was recorded: ${why}`] };
125
+ }
126
+ /**
127
+ * What one decision's checks come to. A check that failed is a finding, whatever
128
+ * else could not be read. A check that could not run says nothing about the
129
+ * decision, so it follows the rule for an unreadable record. A record served
130
+ * without its work leaves its hash checks unrun, which is neither a finding nor a
131
+ * verified decision: `withheld` names them. With `refetch`, a verified decision
132
+ * comes with the record's bytes as served, for the report, and a failure to fetch
133
+ * them follows the same rule.
134
+ */
135
+ export async function outcome(report, options = {}) {
136
+ const now = options.now ?? Date.now();
137
+ const said = (status) => report.checks.filter((c) => c.status === status).map((c) => `${c.name}: ${c.message}`);
138
+ const findings = said("fail");
139
+ if (findings.length > 0)
140
+ return { findings };
141
+ const unread = said("error");
142
+ if (unread.length > 0)
143
+ return unreadable(unread.join("; "), report.recordedAt, now);
144
+ if (report.withheld)
145
+ return { findings, withheld: report.checks.filter((c) => c.status === "skip" && c.name.endsWith("-hash")).map((c) => c.name) };
146
+ if (!options.refetch || !report.payloadURI)
147
+ return { findings };
148
+ try {
149
+ return { findings, record: await options.refetch(report.payloadURI) };
150
+ }
151
+ catch (error) {
152
+ return unreadable(reason(error), report.recordedAt, now);
153
+ }
154
+ }
111
155
  /** The service's prefix on a report; the worker signs the prefix and the report's JSON text. */
112
156
  export const REPORT_PREFIX = "BrainChain watchtower report\n";
113
- /** A report lists at most this many decisions checked. */
157
+ /** A report lists at most this many decisions checked and this many findings; a longer round sends several. */
114
158
  export const MAX_CHECKED = 200;
159
+ export const MAX_FINDINGS = 50;
115
160
  /** The proof a record was fetched: the hash of its bytes followed by the worker's address, which nothing on chain gives away. */
116
161
  export const fetchProof = (bytes, worker) => keccak256(concat([bytes, toBytes(worker)]));
117
162
  /** The service answered and will not take this report, so sending it again would not help. */
@@ -123,14 +168,17 @@ export class ReportRefused extends Error {
123
168
  * Resolves to the line to log.
124
169
  */
125
170
  export function createReporter(network, account, fetcher = fetch) {
126
- return async (checked, findings) => {
171
+ // The service takes a report only if it was signed later than the last it took, by the second.
172
+ let lastAt = 0;
173
+ async function send(checked, findings) {
174
+ lastAt = Math.max(Math.floor(Date.now() / 1000), lastAt + 1);
127
175
  const report = {
128
176
  worker: account.address,
129
177
  chainId: network.chainId,
130
178
  log: network.contracts.log,
131
- at: Math.floor(Date.now() / 1000),
132
- checked: checked.slice(0, MAX_CHECKED).map((c) => ({ id: Number(c.id), proof: c.proof })),
133
- findings: findings.slice(0, 50).map((f) => ({ id: Number(f.id), checks: f.checks.slice(0, 20).map((c) => c.slice(0, 400)) })),
179
+ at: lastAt,
180
+ checked: checked.map((c) => ({ id: Number(c.id), proof: c.proof })),
181
+ findings: findings.map((f) => ({ id: Number(f.id), checks: f.checks.slice(0, 20).map((c) => c.slice(0, 400)) })),
134
182
  };
135
183
  const text = JSON.stringify(report);
136
184
  const signature = await account.signMessage({ message: `${REPORT_PREFIX}${text}` });
@@ -148,6 +196,13 @@ export function createReporter(network, account, fetcher = fetch) {
148
196
  return body.mark
149
197
  ? `reported to ${network.name}: ${body.checked} decisions re-verified in all, watchtower mark since ${body.mark.since}`
150
198
  : `reported to ${network.name}: ${body.checked} of the ${body.markAfter} re-verified decisions that earn the watchtower mark`;
199
+ }
200
+ return async (checked, findings) => {
201
+ let line = await send(checked.slice(0, MAX_CHECKED), findings.slice(0, MAX_FINDINGS));
202
+ for (let i = 1; MAX_CHECKED * i < checked.length || MAX_FINDINGS * i < findings.length; i++) {
203
+ line = await send(checked.slice(MAX_CHECKED * i, MAX_CHECKED * (i + 1)), findings.slice(MAX_FINDINGS * i, MAX_FINDINGS * (i + 1)));
204
+ }
205
+ return line;
151
206
  };
152
207
  }
153
208
  /**
@@ -176,7 +231,7 @@ export function createWatch(options) {
176
231
  const key = change.changeId.toString();
177
232
  if (judged.has(key))
178
233
  continue;
179
- const { name, broken } = judge(change.data);
234
+ const { name, broken, does } = judge(change.data);
180
235
  // A failed read leaves the change unjudged and the block range unread, so the next round comes back to it.
181
236
  const open = await chain.open(change.changeId);
182
237
  judged.add(key);
@@ -185,9 +240,9 @@ export function createWatch(options) {
185
240
  log(`change ${change.changeId}: ${name} on ${change.target}${broken.length > 0 ? ` broke the constitution (${why})` : ""}; closed, it has run or was vetoed`);
186
241
  continue;
187
242
  }
188
- const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 16);
243
+ const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 19);
189
244
  if (broken.length === 0) {
190
- log(`change ${change.changeId}: ${name} on ${change.target}, within the constitution, runs after ${runsAt} UTC`);
245
+ log(`change ${change.changeId}: ${name} on ${change.target}${does ? ` ${does}, which no floor covers` : ", within the constitution"}, runs after ${runsAt} UTC`);
191
246
  continue;
192
247
  }
193
248
  findings += 1;
@@ -239,8 +294,11 @@ export function createWatch(options) {
239
294
  let reached = seenDecision;
240
295
  for (let id = seenDecision + 1n; id <= count; id++) {
241
296
  try {
242
- const { findings: wrong, record } = await verify(id);
243
- if (wrong.length === 0) {
297
+ const { findings: wrong, record, withheld } = await verify(id);
298
+ if (withheld) {
299
+ log(`decision ${id} withheld: its record is served without the work, so ${withheld.join(" and ")} did not run; the checks that ran agree with the chain`);
300
+ }
301
+ else if (wrong.length === 0) {
244
302
  log(`decision ${id} verified`);
245
303
  if (record)
246
304
  checked.push({ id, proof: fetchProof(record, chain.account) });
@@ -266,11 +324,10 @@ export function createWatch(options) {
266
324
  }
267
325
  catch (error) {
268
326
  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;
327
+ incomplete = true;
328
+ // Only an answer from the service settles a report: a refusal would only repeat, and these decisions are checked and sent again otherwise.
329
+ if (!(error instanceof ReportRefused))
272
330
  reached = seenDecision;
273
- }
274
331
  }
275
332
  }
276
333
  seenDecision = reached;
package/dist/work.js CHANGED
@@ -36,6 +36,16 @@ export const printable = (text) => stripAnsi(text)
36
36
  .replace(/[\t\n\r]+/g, " ")
37
37
  .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
38
38
  .replace(new RegExp(HIDDEN.source, "g"), "");
39
+ /** The longest title the log quotes. */
40
+ const LOGGED_TITLE = 80;
41
+ /**
42
+ * A title from a stranger as the log shows it: one line, cut short and JSON-quoted,
43
+ * so it cannot close its quotes and pass for the worker's own words.
44
+ */
45
+ export function loggedTitle(title) {
46
+ const characters = [...printable(title)];
47
+ return JSON.stringify(characters.length > LOGGED_TITLE ? `${characters.slice(0, LOGGED_TITLE - 1).join("")}…` : characters.join(""));
48
+ }
39
49
  export function parseBrief(text) {
40
50
  let value;
41
51
  try {
@@ -437,7 +447,7 @@ export function createWork(options) {
437
447
  // A stop asked for while the brief downloaded or the balance was read starts no command.
438
448
  if (options.stopping?.())
439
449
  continue;
440
- log(`task ${task.id}: working on "${brief.title}"`);
450
+ log(`task ${task.id}: working on ${loggedTitle(brief.title)}`);
441
451
  const input = options.format === "json" ? JSON.stringify(taskInput(task, brief)) : taskPrompt(task, brief);
442
452
  const timeoutMs = Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, Number(task.deadline - MARGIN_SEC - now) * 1000);
443
453
  const output = stripAnsi(await command.run(input, { BRAINCHAIN_TASK_ID: key }, timeoutMs, remember).finally(forget)).trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
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>",