aamio 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/bin/aamio.js +30 -2
- package/package.json +1 -1
- package/src/node-archive.js +6 -3
- package/src/node-doctor.js +25 -3
- package/src/node-mcp.js +55 -13
- package/src/node-runtime.js +315 -24
package/README.md
CHANGED
|
@@ -31,8 +31,13 @@ SHA-256 hash and its eight-character hash prefix, the inbox and when it
|
|
|
31
31
|
expires, the service and the tags presence publishes, as aamio-python and
|
|
32
32
|
aamio-php do; `init` opens no inbox, so it needs no service, and the first
|
|
33
33
|
read opens one. The service is `--host URL`, else `AAMIO_HOST`, else
|
|
34
|
-
https://aamio.at.
|
|
35
|
-
`
|
|
34
|
+
https://aamio.at. `--host` moves the service and nothing else: the board is
|
|
35
|
+
`AAMIO_BOARD`, else https://board.aamio.at, and a receipt is anchored at
|
|
36
|
+
`AAMIO_VERIFYUM`, else Verifyum's MCP server. For a copy of both on your own
|
|
37
|
+
machine, name both before a post, as in
|
|
38
|
+
`AAMIO_BOARD=http://127.0.0.1:8081 aamio --host http://127.0.0.1:8080 board find`.
|
|
39
|
+
The tags are `init --tags`, else the home's own, else `AAMIO_TAGS`. Save the
|
|
40
|
+
home if you want to keep the identity.
|
|
36
41
|
|
|
37
42
|
`doctor` answers three questions as the other two runtimes answer them:
|
|
38
43
|
whether this client fits the service, read from `/health` and from what the
|
|
@@ -66,7 +71,7 @@ else: the message is handed over and the send is delivered, with
|
|
|
66
71
|
`archive_failed` in `attention` or `archive_error` on the answer.
|
|
67
72
|
|
|
68
73
|
To connect an MCP client, set its command to `npx` and its arguments to
|
|
69
|
-
`["-y", "aamio@0.7.
|
|
74
|
+
`["-y", "aamio@0.7.2", "--home", "/absolute/path/to/home", "serve"]`, or from a
|
|
70
75
|
checkout the command `node` and the arguments
|
|
71
76
|
`["/absolute/path/to/aamio-js/bin/aamio.js", "--home", "/absolute/path/to/home", "serve"]`.
|
|
72
77
|
The server speaks JSON-RPC over stdio and creates a new identity on first
|
|
@@ -176,9 +181,9 @@ instructions. Answer a post with `board answer POST_ID` rather than by sending
|
|
|
176
181
|
to the address on it: the answer names the post and is sealed to the key that
|
|
177
182
|
signed it, from any process. The server has the same as `aamio_board_post`,
|
|
178
183
|
`aamio_board_find`, `aamio_board_answer`, `aamio_board_withdraw` and
|
|
179
|
-
`aamio_board_tags`.
|
|
180
|
-
|
|
181
|
-
three.
|
|
184
|
+
`aamio_board_tags`. An add-on brings its own commands, under `aamio addon`,
|
|
185
|
+
as the section on add-ons below says. The files are those aamio-python and
|
|
186
|
+
aamio-php keep, so a home opens in any of the three.
|
|
182
187
|
|
|
183
188
|
`board replies` reads the board inboxes, and only those, and lists the answers
|
|
184
189
|
to your posts: what this run read, and what the archive holds from the runs
|
package/bin/aamio.js
CHANGED
|
@@ -9,7 +9,7 @@ import { AddonError, describe, disable, enable, enabledButMissing, installWords,
|
|
|
9
9
|
import { parseCommand, probe, runCommand, usageOf } from "../src/node-addon-process.js";
|
|
10
10
|
import { archiveStatus, parsePolicy } from "../src/node-archive.js";
|
|
11
11
|
import { doctor, homeCheck } from "../src/node-doctor.js";
|
|
12
|
-
import { CHANNEL_TTL, NodeRuntime, SEND_STOPS, checkChannelTtl, checkGate, checkLabel, checkPartner, checkRe, stopFields, tagsFrom } from "../src/node-runtime.js";
|
|
12
|
+
import { BODY_MAX_DEPTH, CHANNEL_TTL, NodeRuntime, SEND_STOPS, checkChannelTtl, checkGate, checkLabel, checkPartner, checkRe, stopFields, tagsFrom, unfitJson } from "../src/node-runtime.js";
|
|
13
13
|
import { checkAccess, checkScopeAdd, checkScopeName } from "../src/node-scopes.js";
|
|
14
14
|
|
|
15
15
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -70,6 +70,10 @@ An add-on is an npm package of its own, installed in the home's folder for
|
|
|
70
70
|
add-ons, addon-packages/npm, and run in a process of its own for each call.
|
|
71
71
|
The default home is AAMIO_HOME or ~/.aamio. A new home needs an existing
|
|
72
72
|
parent directory. The service is --host, else AAMIO_HOST, else https://aamio.at.
|
|
73
|
+
--host moves the service and nothing else: the board is AAMIO_BOARD, else
|
|
74
|
+
https://board.aamio.at, and a receipt is anchored at AAMIO_VERIFYUM, else
|
|
75
|
+
Verifyum's MCP server. For a copy of both on this machine, name both, as in
|
|
76
|
+
AAMIO_BOARD=http://127.0.0.1:8081 aamio --host http://127.0.0.1:8080 board find
|
|
73
77
|
The tags presence publishes are init --tags, else the home's own, else
|
|
74
78
|
AAMIO_TAGS. With --no-archive nothing decrypted is written by this command,
|
|
75
79
|
whatever the home's choice is. Run with --version to print the version.
|
|
@@ -218,11 +222,34 @@ function scopeName(value) {
|
|
|
218
222
|
|
|
219
223
|
function jsonData(value) {
|
|
220
224
|
if (value === undefined) return null;
|
|
225
|
+
let parsed;
|
|
221
226
|
try {
|
|
222
|
-
|
|
227
|
+
parsed = JSON.parse(value);
|
|
223
228
|
} catch {
|
|
224
229
|
throw Object.assign(new Error("--data is not JSON. Give a JSON value, quoted for the shell."), { code: "usage" });
|
|
225
230
|
}
|
|
231
|
+
return holdable(parsed, "--data", BODY_MAX_DEPTH - 1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A JSON option every reader can hold, or the usage error that says why not:
|
|
236
|
+
* a number that is not finite, as 1e999 is, which JSON.parse makes Infinity
|
|
237
|
+
* and JSON.stringify sends as null, and --data nested so deep that the
|
|
238
|
+
* message around it passes BODY_MAX_DEPTH, which a reader hands over as text.
|
|
239
|
+
* The three runtimes refuse both alike (a security review of 13 November 2026).
|
|
240
|
+
*/
|
|
241
|
+
function holdable(parsed, option, limit) {
|
|
242
|
+
const unreadable = unfitJson(parsed, limit);
|
|
243
|
+
if (unreadable && unreadable[1] === "half a pair") {
|
|
244
|
+
throw Object.assign(new Error(`${option} holds half of a UTF-16 surrogate pair, as "\\ud800" is: JSON can write it, and UTF-8 cannot carry it.`), { code: "usage" });
|
|
245
|
+
}
|
|
246
|
+
if (unreadable && unreadable[1] === "not finite") {
|
|
247
|
+
throw Object.assign(new Error(`${option} holds a number that is not finite, as 1e999 is: JSON can write it, and nothing that reads it can hold it.`), { code: "usage" });
|
|
248
|
+
}
|
|
249
|
+
if (unreadable) {
|
|
250
|
+
throw Object.assign(new Error(`${option} is nested too deep: with the message around it, it is deeper than ${BODY_MAX_DEPTH}, and a reader hands such a message over as text.`), { code: "usage" });
|
|
251
|
+
}
|
|
252
|
+
return parsed;
|
|
226
253
|
}
|
|
227
254
|
|
|
228
255
|
/**
|
|
@@ -345,6 +372,7 @@ function plan(command, positional, options) {
|
|
|
345
372
|
} catch {
|
|
346
373
|
throw wrong("--gate is not JSON. Quote it as one argument, as in --gate '{\"require\": {\"pow\": {\"bits\": 20}}}'.");
|
|
347
374
|
}
|
|
375
|
+
holdable(gate, "--gate", 1000000);
|
|
348
376
|
usage(() => checkGate(gate));
|
|
349
377
|
}
|
|
350
378
|
return (runtime) => runtime.channelOpen(first, { ttl, allow, gate });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aamio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"mcpName": "at.aamio/aamio",
|
|
5
5
|
"description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers, and for Node a local runtime with a command line and an MCP server on stdio.",
|
|
6
6
|
"type": "module",
|
package/src/node-archive.js
CHANGED
|
@@ -132,14 +132,17 @@ export function parsePolicy(text, maxMb = null) {
|
|
|
132
132
|
|
|
133
133
|
/** One sentence a person or a model can act on. The words are aamio-python's. */
|
|
134
134
|
export function describePolicy(policy) {
|
|
135
|
-
|
|
135
|
+
// The ceiling holds each time the archive is tidied, not at every write:
|
|
136
|
+
// under one of 128 bytes a row of 415 stayed until the next tidying, where
|
|
137
|
+
// this said never more than (the fifth release check of 14 November 2026).
|
|
138
|
+
const size = policy.max_mb ? ` Each time it is tidied, when a runtime starts and at most once an hour after, the oldest go first until it holds no more than ${policy.max_mb} MB, so in between it can hold more.` : "";
|
|
136
139
|
if (policy.mode === "off") {
|
|
137
140
|
return "Nothing decrypted is written to this machine. The key, the read keys of open threads and the outbox with sealed bytes still are, since the runtime cannot work without them.";
|
|
138
141
|
}
|
|
139
142
|
if (policy.mode === "days") {
|
|
140
|
-
return `Decrypted messages and receipts are kept in archive/ for ${policy.days} days
|
|
143
|
+
return `Decrypted messages and receipts are kept in archive/ for ${policy.days} days, then removed.${size} The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine.`;
|
|
141
144
|
}
|
|
142
|
-
return `Decrypted messages and receipts are kept in archive/ until you remove them
|
|
145
|
+
return `Decrypted messages and receipts are kept in archive/ until you remove them.${size} The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine. \`aamio archive days:30\` gives it a lifetime, \`aamio archive off\` stops it.`;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
/** After a rename the new name is on disk only when the folder is. Windows has no such call for a folder, and journals the rename. */
|
package/src/node-doctor.js
CHANGED
|
@@ -265,15 +265,37 @@ async function ask(runtime, path) {
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* What /health said when it is not aamio's health answer, an object with
|
|
270
|
+
* "service": "aamio" and "status": "ok" as the service gives it, and null when
|
|
271
|
+
* it is. A 200 from a proxy or a login page in front of the host was said as
|
|
272
|
+
* healthy, beside "answered 200 and not 200" (a check of 13 November 2026).
|
|
273
|
+
*/
|
|
274
|
+
function notHealth(body) {
|
|
275
|
+
if (body === null || body === "") return "an empty body";
|
|
276
|
+
if (typeof body === "string") return "a body that is not JSON";
|
|
277
|
+
if (!isObject(body) || body.service !== "aamio" || body.status !== "ok") return "JSON that is not aamio's health answer";
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
268
281
|
/** One answer to three questions: does this client fit the service, who can read the home, what is kept. */
|
|
269
282
|
export async function doctor(runtime, version) {
|
|
270
283
|
const health = await ask(runtime, "/health");
|
|
271
|
-
|
|
272
|
-
|
|
284
|
+
// Whether the service answered and whether it is well are two things, and
|
|
285
|
+
// both apart from whether this client fits it. A 503 from /health was said
|
|
286
|
+
// as no answer, beside a fit read from the descriptor, which had answered
|
|
287
|
+
// (a check of 13 November 2026). As aamio-python and aamio-php say it.
|
|
288
|
+
const unlike = health.status === 200 ? notHealth(health.body) : null;
|
|
289
|
+
const service = { host: runtime.host, reachable: health.status > 0, healthy: health.status === 200 && unlike === null, status: health.status };
|
|
290
|
+
if (service.healthy) {
|
|
273
291
|
service.version = health.body.version ?? null;
|
|
274
292
|
service.revision = health.body.revision ?? null;
|
|
293
|
+
} else if (health.status === 0) {
|
|
294
|
+
service.problem = "no answer from /health, so the service's version and health were not read";
|
|
295
|
+
} else if (health.status !== 200) {
|
|
296
|
+
service.problem = `/health answered ${health.status} and not 200: the service is reachable and not healthy, and its version was not read`;
|
|
275
297
|
} else {
|
|
276
|
-
service.problem =
|
|
298
|
+
service.problem = `/health answered 200 with ${unlike}, so the service's version and health were not read: check that --host names the aamio service and not a page in front of it`;
|
|
277
299
|
}
|
|
278
300
|
const descriptor = await ask(runtime, "/.well-known/aamio.json");
|
|
279
301
|
const fits = compatCheck(descriptor.status === 200 ? descriptor.body : null);
|
package/src/node-mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { GateStop, isKey } from "./aamio.js";
|
|
4
4
|
import { readIdentity } from "./node-identity.js";
|
|
5
|
-
import { OUTBOX_NOTES, SEND_STOPS, checkGate, outboxOutcome, stopFields } from "./node-runtime.js";
|
|
5
|
+
import { OUTBOX_NOTES, SEND_STOPS, checkGate, outboxOutcome, stopFields, unfitJson, unfitText } from "./node-runtime.js";
|
|
6
6
|
import { toolsOf } from "./node-addons.js";
|
|
7
7
|
import { HOST_QUESTION, callTool } from "./node-addon-process.js";
|
|
8
8
|
|
|
@@ -510,6 +510,8 @@ async function addPartner(runtime, args, ask) {
|
|
|
510
510
|
|
|
511
511
|
/** What a call whose arguments do not fit is told to do, in the three runtimes alike. */
|
|
512
512
|
const ARGUMENTS_FIX = "Check each argument against the tool's inputSchema and call again.";
|
|
513
|
+
/** And one whose gate does not fit: there is no changing a gate once the channel is open. */
|
|
514
|
+
const GATE_FIX = "Send a gate with require, advise or both, as in {\"require\": {\"pow\": {\"bits\": 20}, \"per_key\": 5}}, or leave gate out to take writes from anyone on the allowlist.";
|
|
513
515
|
|
|
514
516
|
/**
|
|
515
517
|
* Arguments as the tool's schema says: an object of known names, each of its
|
|
@@ -519,22 +521,53 @@ const ARGUMENTS_FIX = "Check each argument against the tool's inputSchema and ca
|
|
|
519
521
|
*/
|
|
520
522
|
function wrongArguments(tool, args) {
|
|
521
523
|
if (args === undefined) args = {};
|
|
522
|
-
if (!object(args)) return "arguments must be an object";
|
|
523
524
|
const properties = tool.inputSchema.properties;
|
|
524
525
|
for (const [name, value] of Object.entries(args)) {
|
|
525
|
-
|
|
526
|
-
if (!
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
: want.type === "boolean" ? typeof value === "boolean"
|
|
530
|
-
: want.type === "object" ? object(value)
|
|
531
|
-
: want.type === "array" ? Array.isArray(value) && value.every((item) => typeof item === "string") && value.length <= (want.maxItems ?? Infinity) : false;
|
|
532
|
-
if (!ok) return `${name} is not what ${tool.name} takes: ${JSON.stringify(want)}`;
|
|
526
|
+
// Own names only: constructor or __proto__ is no argument of any tool.
|
|
527
|
+
if (!Object.hasOwn(properties, name)) return { path: name, message: `${tool.name} takes no argument called ${name}` };
|
|
528
|
+
const wrong = wrongValue(tool, name, properties[name], value);
|
|
529
|
+
if (wrong) return wrong;
|
|
533
530
|
}
|
|
534
|
-
for (const name of tool.inputSchema.required ?? []) if (!(name
|
|
531
|
+
for (const name of tool.inputSchema.required ?? []) if (!Object.hasOwn(args, name)) return { path: name, message: `${tool.name} needs ${name}` };
|
|
535
532
|
return null;
|
|
536
533
|
}
|
|
537
534
|
|
|
535
|
+
/**
|
|
536
|
+
* What is wrong with one value against its schema, named by its path, as the
|
|
537
|
+
* three runtimes say it; or null. An object whose fields the schema declares,
|
|
538
|
+
* as a gate's, is checked field by field: a pow of "5" bits went on to the
|
|
539
|
+
* service, and a field no gate has was passed over (a check of 13 November
|
|
540
|
+
* 2026). One that declares none, as data, takes any field.
|
|
541
|
+
*/
|
|
542
|
+
function wrongValue(tool, path, want, value) {
|
|
543
|
+
const ok = want.type === "string" ? typeof value === "string" && (!want.pattern || new RegExp(want.pattern).test(value)) && [...value].length >= (want.minLength ?? 0) && [...value].length <= (want.maxLength ?? Infinity) && (!want.enum || want.enum.includes(value))
|
|
544
|
+
: want.type === "integer" ? Number.isInteger(value) && value >= (want.minimum ?? -Infinity) && value <= (want.maximum ?? Infinity)
|
|
545
|
+
: want.type === "boolean" ? typeof value === "boolean"
|
|
546
|
+
: want.type === "object" ? object(value)
|
|
547
|
+
: want.type === "array" ? Array.isArray(value) && value.every((item) => typeof item === "string") && value.length <= (want.maxItems ?? Infinity) : false;
|
|
548
|
+
if (!ok) return { path, message: `${path} is not what ${tool.name} takes: ${JSON.stringify(want)}` };
|
|
549
|
+
if (want.type === "object" && object(want.properties)) {
|
|
550
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
551
|
+
if (!Object.hasOwn(want.properties, key)) {
|
|
552
|
+
if (want.additionalProperties === false) return { path: `${path}.${key}`, message: `${path} has no field called ${key}` };
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const wrong = wrongValue(tool, `${path}.${key}`, want.properties[key], inner);
|
|
556
|
+
if (wrong) return wrong;
|
|
557
|
+
}
|
|
558
|
+
for (const key of want.required ?? []) if (!Object.hasOwn(value, key)) return { path: `${path}.${key}`, message: `${path} needs ${key}` };
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** What does not fit, as the call's answer: inside a gate it is the gate's refusal, with its fix; anything else is invalid_arguments. */
|
|
564
|
+
function unfit(tool, wrong, args) {
|
|
565
|
+
if (tool.name === "aamio_open_channel" && (wrong.path === "gate" || wrong.path.startsWith("gate."))) {
|
|
566
|
+
return { error: wrong.message, error_code: "invalid_gate", fix: GATE_FIX, given: args.gate };
|
|
567
|
+
}
|
|
568
|
+
return { error: wrong.message, error_code: "invalid_arguments", fix: ARGUMENTS_FIX };
|
|
569
|
+
}
|
|
570
|
+
|
|
538
571
|
/**
|
|
539
572
|
* One tool of an add-on enabled in this home. The core settles what is the
|
|
540
573
|
* core's, in turn with everything else that uses the home: the key behind a
|
|
@@ -562,6 +595,15 @@ async function callAddonTool(id, params, session, version, reply, complete, mani
|
|
|
562
595
|
/** One tool of the runtime. A call and the presence timer take turns with the home. */
|
|
563
596
|
async function callRuntimeTool(id, params, session, version, reply, complete) {
|
|
564
597
|
const runtime = session.runtime;
|
|
598
|
+
// A number that is not finite, as 1e999 is, or nesting deeper than a reader
|
|
599
|
+
// reads as JSON, is refused before any tool runs, an add-on's included, as in
|
|
600
|
+
// the other two runtimes. JSON.parse made 1e999 Infinity here and it went on
|
|
601
|
+
// as null; in aamio-php the arguments vanished and an add-on ran with its
|
|
602
|
+
// defaults (a security review of 13 November 2026).
|
|
603
|
+
if (object(params.arguments)) {
|
|
604
|
+
const unreadable = unfitJson(params.arguments);
|
|
605
|
+
if (unreadable) return complete(toolResult({ error: unfitText(unreadable), error_code: "invalid_arguments", fix: ARGUMENTS_FIX }, true));
|
|
606
|
+
}
|
|
565
607
|
const tool = RUNTIME_TOOLS.find((t) => t.name === params.name);
|
|
566
608
|
if (!tool) {
|
|
567
609
|
const manifest = session.addons.find((held) => held.tools.some((offered) => offered.name === params.name));
|
|
@@ -587,7 +629,7 @@ async function callRuntimeTool(id, params, session, version, reply, complete) {
|
|
|
587
629
|
// of 13 November 2026).
|
|
588
630
|
if (params.arguments !== undefined && !object(params.arguments)) return rpcError(id, -32602, "arguments must be an object");
|
|
589
631
|
const wrong = wrongArguments(tool, params.arguments);
|
|
590
|
-
if (wrong) return complete(toolResult(
|
|
632
|
+
if (wrong) return complete(toolResult(unfit(tool, wrong, params.arguments ?? {}), true));
|
|
591
633
|
const args = params.arguments ?? {};
|
|
592
634
|
try {
|
|
593
635
|
const data = await runtime.serial(async () => {
|
|
@@ -611,7 +653,7 @@ async function callRuntimeTool(id, params, session, version, reply, complete) {
|
|
|
611
653
|
try {
|
|
612
654
|
checkGate(args.gate);
|
|
613
655
|
} catch (wrong) {
|
|
614
|
-
throw new ToolRefusal({ error: wrong.message, error_code: "invalid_gate", fix:
|
|
656
|
+
throw new ToolRefusal({ error: wrong.message, error_code: "invalid_gate", fix: GATE_FIX, given: args.gate });
|
|
615
657
|
}
|
|
616
658
|
}
|
|
617
659
|
return runtime.channelOpen(args.label, { ttl: args.ttl, allow: args.allow ?? null, gate: args.gate ?? null, to: args.to ?? null, note: args.note ?? null });
|
package/src/node-runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { open, readFile, rename, stat as fsStat, unlink } from "node:fs/promises";
|
|
2
3
|
import { constants } from "node:fs";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -249,6 +250,80 @@ export function checkGate(gate) {
|
|
|
249
250
|
return gate;
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* What a body may be and still be read as JSON, in all three runtimes: every
|
|
256
|
+
* number finite and nested at most this deep, the outermost array or object
|
|
257
|
+
* counted as one. Anything else is handed over as text. A body nested 5 000
|
|
258
|
+
* deep made every read of its thread throw here, and the messages other
|
|
259
|
+
* channels had handed over in the same read, their cursors saved, were lost.
|
|
260
|
+
* 1e999 became Infinity, and went on as null (a security review of 13 November
|
|
261
|
+
* 2026).
|
|
262
|
+
*/
|
|
263
|
+
export const BODY_MAX_DEPTH = 64;
|
|
264
|
+
// How far back from the end of a channel's archive a read looks for a delivery
|
|
265
|
+
// an earlier process wrote down and did not get past with its cursor.
|
|
266
|
+
const ARCHIVE_TAIL_BYTES = 16 * 1024 * 1024;
|
|
267
|
+
|
|
268
|
+
/** One step of a path: a member name with half a pair in it is written escaped, so the path can be printed. */
|
|
269
|
+
function segment(key) {
|
|
270
|
+
return key.isWellFormed() ? key : JSON.stringify(key).slice(1, -1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* [path, what] for the first part of value no reader can hold, in document
|
|
275
|
+
* order, or null: "not finite" for a number such as 1e999, "too deep" for
|
|
276
|
+
* arrays and objects nested deeper than limit, and "half a pair" for a string
|
|
277
|
+
* or member name with half of a UTF-16 surrogate pair in it, as "\\ud800"
|
|
278
|
+
* decodes to: JSON can write it and UTF-8 cannot carry it, so it went out as
|
|
279
|
+
* U+FFFD in its place, and aamio-php reads no such body as JSON (the release
|
|
280
|
+
* check of 14 November 2026). The path names keys and indices with dots, and
|
|
281
|
+
* is "" for value itself.
|
|
282
|
+
*/
|
|
283
|
+
export function unfitJson(value, limit = BODY_MAX_DEPTH) {
|
|
284
|
+
const stack = [[value, "", 0]];
|
|
285
|
+
while (stack.length) {
|
|
286
|
+
const [node, path, level] = stack.pop();
|
|
287
|
+
if (typeof node === "number") {
|
|
288
|
+
if (!Number.isFinite(node)) return [path, "not finite"];
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (typeof node === "string") {
|
|
292
|
+
if (!node.isWellFormed()) return [path, "half a pair"];
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (node === null || typeof node !== "object") continue;
|
|
296
|
+
if (level + 1 > limit) return [path, "too deep"];
|
|
297
|
+
const items = Array.isArray(node) ? node.map((child, index) => [String(index), child]) : Object.entries(node);
|
|
298
|
+
for (const [key, child] of items.reverse()) {
|
|
299
|
+
const where = path ? `${path}.${segment(key)}` : segment(key);
|
|
300
|
+
stack.push([child, where, level + 1]);
|
|
301
|
+
// The member name, where it stands: before its value.
|
|
302
|
+
if (!Array.isArray(node)) stack.push([key, where, level + 1]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** What is wrong with an argument unfitJson found, as all three runtimes say it. */
|
|
309
|
+
export function unfitText([path, what]) {
|
|
310
|
+
const where = path || "arguments";
|
|
311
|
+
if (what === "not finite") return `${where} is a number that is not finite, as 1e999 is: JSON can write it, and nothing that reads it can hold it`;
|
|
312
|
+
if (what === "half a pair") return `${where} holds half of a UTF-16 surrogate pair, as "\\ud800" is: JSON can write it, and UTF-8 cannot carry it`;
|
|
313
|
+
return `${where} is nested deeper than ${BODY_MAX_DEPTH}: a reader hands a message nested that deep over as text`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A body's JSON as { value }, or null when it is to be read as text: not JSON, a number that is not finite, or too deep. */
|
|
317
|
+
function bodyJson(text) {
|
|
318
|
+
let value;
|
|
319
|
+
try {
|
|
320
|
+
value = JSON.parse(text);
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return unfitJson(value) === null ? { value } : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
252
327
|
/**
|
|
253
328
|
* A label for a channel of the caller's own. It names the channel in every
|
|
254
329
|
* answer and its file in the archive, so it is made of what a file's name may
|
|
@@ -373,6 +448,12 @@ export class NodeRuntime {
|
|
|
373
448
|
this.archivePolicy = { ...DEFAULT_POLICY };
|
|
374
449
|
this.archiveEnabled = false;
|
|
375
450
|
this.prunedAt = 0;
|
|
451
|
+
// The read an async call belongs to, how many reads are saying what the
|
|
452
|
+
// archive holds, and a tidying under way: tidying waits for those reads
|
|
453
|
+
// (the fifth release check of 14 November 2026).
|
|
454
|
+
this.readScope = new AsyncLocalStorage();
|
|
455
|
+
this.receiptWindows = 0;
|
|
456
|
+
this.tidying = null;
|
|
376
457
|
// What this process read on each channel, by write address, as it was
|
|
377
458
|
// handed over, with the thread's instance and the archive's mark: what
|
|
378
459
|
// board replies lists beside the archive. In memory only, and gone with
|
|
@@ -380,7 +461,16 @@ export class NodeRuntime {
|
|
|
380
461
|
this.received = new Map();
|
|
381
462
|
// Messages the archive holds from a read whose cursor could not be
|
|
382
463
|
// written: read again, they are not written down a second time.
|
|
383
|
-
|
|
464
|
+
// By delivery, the name of the archive file each went to.
|
|
465
|
+
this.writtenDown = new Map();
|
|
466
|
+
// What earlier processes wrote to each channel's archive, from the end of
|
|
467
|
+
// the file, by file name: read once a process.
|
|
468
|
+
this.archiveBefore = new Map();
|
|
469
|
+
// How often a delivery was handed over here with a row an earlier process
|
|
470
|
+
// had written: copies held here that wrote none, by board replies' key.
|
|
471
|
+
this.rowsReused = new Map();
|
|
472
|
+
// How far back from the end of a channel's archive that is read.
|
|
473
|
+
this.archiveTailBytes = ARCHIVE_TAIL_BYTES;
|
|
384
474
|
this.host = host.replace(/\/+$/, "");
|
|
385
475
|
// Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
|
|
386
476
|
this.verifyum = String(verifyum).replace(/\/+$/, "");
|
|
@@ -498,7 +588,7 @@ export class NodeRuntime {
|
|
|
498
588
|
for (const channel of this.state.channels.filter(gone)) {
|
|
499
589
|
this.received.delete(channel.w);
|
|
500
590
|
// And what was written down of it at a read that could not be saved: it will not be read again.
|
|
501
|
-
for (const delivery of this.writtenDown) if (delivery.startsWith(channel.w + "\u0000")) this.writtenDown.delete(delivery);
|
|
591
|
+
for (const delivery of [...this.writtenDown.keys()]) if (delivery.startsWith(channel.w + "\u0000")) this.writtenDown.delete(delivery);
|
|
502
592
|
}
|
|
503
593
|
this.state.channels = this.state.channels.filter((channel) => !gone(channel));
|
|
504
594
|
}
|
|
@@ -511,6 +601,50 @@ export class NodeRuntime {
|
|
|
511
601
|
// fails costs the record, and is said, and the message, the send or the
|
|
512
602
|
// post it is about stands.
|
|
513
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Tidies the archive when an hour has passed since it was tidied last, and
|
|
606
|
+
* no read is saying what the archive holds. Never within a read, and never
|
|
607
|
+
* while another read is between its first word on the archive and its
|
|
608
|
+
* return: one read() over two channels tidied between them, took the first
|
|
609
|
+
* channel's row and still said archived of it (the fifth release check of
|
|
610
|
+
* 14 November 2026). A tidying that has to wait waits for the next chance.
|
|
611
|
+
*/
|
|
612
|
+
async pruneWhenDue() {
|
|
613
|
+
if (this.now() - this.prunedAt <= 3600) return;
|
|
614
|
+
if (this.readScope?.getStore() !== undefined) return;
|
|
615
|
+
if (this.receiptWindows > 0 || this.tidying) return;
|
|
616
|
+
this.tidying = this.prune().finally(() => {
|
|
617
|
+
this.tidying = null;
|
|
618
|
+
});
|
|
619
|
+
await this.tidying;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* A read as its caller asked for it: it tidies first, when that is due, and
|
|
624
|
+
* nothing within it tidies. The channels it reads one by one, the reads
|
|
625
|
+
* within it and the rows it writes are one read for the archive, so what it
|
|
626
|
+
* says of each message is so when it returns.
|
|
627
|
+
*/
|
|
628
|
+
async outerRead(read) {
|
|
629
|
+
if (!this.readScope || this.readScope.getStore() !== undefined) return read();
|
|
630
|
+
if (this.archiveEnabled && !this.closed) await this.pruneWhenDue();
|
|
631
|
+
const scope = { receipts: false };
|
|
632
|
+
try {
|
|
633
|
+
return await this.readScope.run(scope, read);
|
|
634
|
+
} finally {
|
|
635
|
+
if (scope.receipts) this.receiptWindows--;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** From here until the outer read returns, what it says of the archive is to stay so: tidying waits. */
|
|
640
|
+
async givingReceipts() {
|
|
641
|
+
const scope = this.readScope?.getStore();
|
|
642
|
+
if (!scope || scope.receipts) return;
|
|
643
|
+
while (this.tidying) await this.tidying;
|
|
644
|
+
this.receiptWindows++;
|
|
645
|
+
scope.receipts = true;
|
|
646
|
+
}
|
|
647
|
+
|
|
514
648
|
/** Removes what the policy no longer keeps. A failure is said, since the archive then keeps growing in plain text. */
|
|
515
649
|
async prune() {
|
|
516
650
|
const policy = this.archivePolicy;
|
|
@@ -518,6 +652,11 @@ export class NodeRuntime {
|
|
|
518
652
|
this.prunedAt = this.now();
|
|
519
653
|
try {
|
|
520
654
|
await pruneArchive(this.home, policy, { now: this.now() });
|
|
655
|
+
// What the end of each file held may be gone with it, and with it a row
|
|
656
|
+
// this process wrote at a read whose cursor was not written: the next
|
|
657
|
+
// read looks in the file again.
|
|
658
|
+
this.archiveBefore.clear();
|
|
659
|
+
this.writtenDown.clear();
|
|
521
660
|
} catch (error) {
|
|
522
661
|
this.note("archive", "unpruned", `the archive was not pruned: ${error.code || error.name}: ${error.message}. It keeps growing, and what is in it stays readable to whoever can read this folder.`);
|
|
523
662
|
}
|
|
@@ -530,11 +669,100 @@ export class NodeRuntime {
|
|
|
530
669
|
*/
|
|
531
670
|
async archive(label, record) {
|
|
532
671
|
if (!this.archiveEnabled || this.closed) return false;
|
|
672
|
+
// Before the row, never after it: a ceiling under one row took the row just
|
|
673
|
+
// written, and the read said archived (the fourth release check of 14
|
|
674
|
+
// November 2026). Within a read, never: the outer read tidies before it
|
|
675
|
+
// begins.
|
|
676
|
+
await this.pruneWhenDue();
|
|
533
677
|
await appendRecord(this.home, archiveName(label), record);
|
|
534
|
-
if (this.now() - this.prunedAt > 3600) await this.prune();
|
|
535
678
|
return true;
|
|
536
679
|
}
|
|
537
680
|
|
|
681
|
+
/**
|
|
682
|
+
* What earlier processes wrote to this channel's archive, from the end of
|
|
683
|
+
* the file, as deliveries. The end is enough for what an earlier process
|
|
684
|
+
* wrote and did not get past with its cursor, which was written last: a
|
|
685
|
+
* read takes at most a thread's megabyte of messages. A row further back is
|
|
686
|
+
* not found, and that delivery is written down again. The rows are the ones
|
|
687
|
+
* the archive's own reader counts, by its rules: past a byte order mark at
|
|
688
|
+
* the start of the file, UTF-8, JSON. The first line of the end counts when
|
|
689
|
+
* it starts where a line starts, and is passed by when it is the tail of a
|
|
690
|
+
* line cut by where the end begins.
|
|
691
|
+
*
|
|
692
|
+
* Read again when the file is not the one that was read: replaced, as a
|
|
693
|
+
* prune replaces it, or shorter. A row a prune had removed was still taken
|
|
694
|
+
* as there, and the message said archived: true with no row behind it (the
|
|
695
|
+
* release check of 14 November 2026).
|
|
696
|
+
*/
|
|
697
|
+
async archivedBefore(label) {
|
|
698
|
+
const name = archiveName(label);
|
|
699
|
+
const path = join(this.home, "archive", name + ".jsonl");
|
|
700
|
+
let found = null;
|
|
701
|
+
try {
|
|
702
|
+
found = await fsStat(path, { bigint: true });
|
|
703
|
+
} catch {
|
|
704
|
+
found = null;
|
|
705
|
+
}
|
|
706
|
+
const version = found === null ? null : `${found.dev}:${found.ino}`;
|
|
707
|
+
const size = found === null ? 0 : Number(found.size);
|
|
708
|
+
const held = this.archiveBefore.get(name);
|
|
709
|
+
if (held && held.version === version && size >= held.size) return held.rows;
|
|
710
|
+
// Another file now, or a shorter one: what this process wrote to it at a
|
|
711
|
+
// read that did not get past its cursor is looked for in it again.
|
|
712
|
+
if (held) for (const [delivery, at] of [...this.writtenDown]) if (at === name) this.writtenDown.delete(delivery);
|
|
713
|
+
const rows = new Set();
|
|
714
|
+
if (found !== null) {
|
|
715
|
+
let handle = null;
|
|
716
|
+
try {
|
|
717
|
+
handle = await open(path, "r");
|
|
718
|
+
const start = Math.max(0, size - this.archiveTailBytes);
|
|
719
|
+
// The byte before the end says whether its first line is whole.
|
|
720
|
+
const from = Math.max(0, start - 1);
|
|
721
|
+
const buffer = Buffer.alloc(size - from);
|
|
722
|
+
let filled = 0;
|
|
723
|
+
while (filled < buffer.length) {
|
|
724
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, from + filled);
|
|
725
|
+
if (bytesRead === 0) break;
|
|
726
|
+
filled += bytesRead;
|
|
727
|
+
}
|
|
728
|
+
let bytes = buffer.subarray(0, filled);
|
|
729
|
+
let whole = true;
|
|
730
|
+
if (start > 0) {
|
|
731
|
+
whole = bytes[0] === 0x0a;
|
|
732
|
+
bytes = bytes.subarray(1);
|
|
733
|
+
} else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
734
|
+
bytes = bytes.subarray(3);
|
|
735
|
+
}
|
|
736
|
+
const lines = [];
|
|
737
|
+
for (let at = 0; at < bytes.length; ) {
|
|
738
|
+
let end = bytes.indexOf(0x0a, at);
|
|
739
|
+
if (end === -1) end = bytes.length;
|
|
740
|
+
lines.push(bytes.subarray(at, end));
|
|
741
|
+
at = end + 1;
|
|
742
|
+
}
|
|
743
|
+
if (!whole) lines.shift();
|
|
744
|
+
for (const raw of lines) {
|
|
745
|
+
let row;
|
|
746
|
+
try {
|
|
747
|
+
row = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
|
|
748
|
+
} catch {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (isObject(row) && row.kind === "received" && typeof row.w === "string" && typeof row.instance === "string" && Number.isInteger(row.seq) && typeof row.sha256 === "string") {
|
|
752
|
+
rows.add([row.w, row.instance, row.seq, row.sha256].join("\u0000"));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
} catch {
|
|
756
|
+
// One that cannot be read: there is no row to use again.
|
|
757
|
+
rows.clear();
|
|
758
|
+
} finally {
|
|
759
|
+
await handle?.close();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
this.archiveBefore.set(name, { rows, version, size });
|
|
763
|
+
return rows;
|
|
764
|
+
}
|
|
765
|
+
|
|
538
766
|
/** The same for a record of something that is done already: what went wrong is given back, said in attention, and never thrown. */
|
|
539
767
|
async archiveSafely(label, record, what) {
|
|
540
768
|
try {
|
|
@@ -568,6 +796,8 @@ export class NodeRuntime {
|
|
|
568
796
|
/** Removes what the policy no longer keeps, or with everything the whole archive, and says how much went. */
|
|
569
797
|
async archivePrune({ everything = false } = {}) {
|
|
570
798
|
const gone = await pruneArchive(this.home, this.archivePolicy, { now: this.now(), everything });
|
|
799
|
+
this.archiveBefore.clear();
|
|
800
|
+
this.writtenDown.clear();
|
|
571
801
|
return { ...(await this.archiveStatus()), removed: gone.removed };
|
|
572
802
|
}
|
|
573
803
|
|
|
@@ -2210,6 +2440,11 @@ export class NodeRuntime {
|
|
|
2210
2440
|
* board inbox is waited on, and the others are asked once.
|
|
2211
2441
|
*/
|
|
2212
2442
|
async boardPoll(wait = 0) {
|
|
2443
|
+
return this.outerRead(() => this.boardPollHeld(wait));
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/** boardPoll, as one read for the archive. */
|
|
2447
|
+
async boardPollHeld(wait) {
|
|
2213
2448
|
this.dropExpired();
|
|
2214
2449
|
const now = this.now();
|
|
2215
2450
|
const boards = this.state.channels.filter((channel) => isObject(channel) && (channel.label === "board" || String(channel.label).startsWith("board-")) && !channel.muted && Number(channel.expire_at) > now)
|
|
@@ -2280,6 +2515,9 @@ export class NodeRuntime {
|
|
|
2280
2515
|
// and the copies held here are later readings of it.
|
|
2281
2516
|
const stored = count(rows.map(one));
|
|
2282
2517
|
const written = count(held.filter((entry) => entry.archived !== false).map(one));
|
|
2518
|
+
// A copy held here whose row an earlier process wrote wrote none: it is a
|
|
2519
|
+
// later reading of that row (the release check of 14 November 2026).
|
|
2520
|
+
for (const [delivery, times] of this.rowsReused) if (written.has(delivery)) written.set(delivery, Math.max(0, written.get(delivery) - times));
|
|
2283
2521
|
const earlier = new Set([...stored].filter(([delivery, rowsOfIt]) => rowsOfIt > (written.get(delivery) ?? 0)).map(([delivery]) => delivery));
|
|
2284
2522
|
const out = [];
|
|
2285
2523
|
const skipped = [];
|
|
@@ -2365,12 +2603,9 @@ export class NodeRuntime {
|
|
|
2365
2603
|
const raw = message.body;
|
|
2366
2604
|
if (!message.verified || !message.from) return [{ text: raw }, { signed: false, encrypted: false, format: "unsigned" }];
|
|
2367
2605
|
if (!isEnvelope(raw)) {
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
} catch {
|
|
2372
|
-
return [{ text: raw }, { signed: true, encrypted: false, format: "text" }];
|
|
2373
|
-
}
|
|
2606
|
+
const read = bodyJson(raw);
|
|
2607
|
+
if (read === null) return [{ text: raw }, { signed: true, encrypted: false, format: "text" }];
|
|
2608
|
+
const parsed = read.value;
|
|
2374
2609
|
return isObject(parsed) ? [parsed, { signed: true, encrypted: false, format: "json" }] : [{ text: raw }, { signed: true, encrypted: false, format: "json" }];
|
|
2375
2610
|
}
|
|
2376
2611
|
let text;
|
|
@@ -2382,12 +2617,9 @@ export class NodeRuntime {
|
|
|
2382
2617
|
} catch (error) {
|
|
2383
2618
|
return [{ text: null }, { signed: true, encrypted: true, format: "unreadable", error: error.name || "Error" }];
|
|
2384
2619
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
} catch {
|
|
2389
|
-
return [{ text }, { signed: true, encrypted: true, format: "text" }];
|
|
2390
|
-
}
|
|
2620
|
+
const read = bodyJson(text);
|
|
2621
|
+
if (read === null) return [{ text }, { signed: true, encrypted: true, format: "text" }];
|
|
2622
|
+
const parsed = read.value;
|
|
2391
2623
|
return isObject(parsed) ? [parsed, { signed: true, encrypted: true, format: "json" }] : [{ text: parsed }, { signed: true, encrypted: true, format: "json" }];
|
|
2392
2624
|
}
|
|
2393
2625
|
|
|
@@ -2445,21 +2677,46 @@ export class NodeRuntime {
|
|
|
2445
2677
|
* one before it redelivers what the stored hashes then mark as replays.
|
|
2446
2678
|
*/
|
|
2447
2679
|
async poll(channel, wait = 0, limit = null, maxBytes = null) {
|
|
2680
|
+
return this.outerRead(() => this.pollHeld(channel, wait, limit, maxBytes));
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/** One poll, inside the read it belongs to. */
|
|
2684
|
+
async pollHeld(channel, wait, limit, maxBytes) {
|
|
2448
2685
|
const before = { after: channel.after, created_at: channel.created_at, instance: channel.instance ?? null, expire_at: channel.expire_at, gone: channel.gone, seen: [...channel.seen], seen_from: [...(channel.seen_from ?? [])], peers: { ...this.state.peers } };
|
|
2449
2686
|
try {
|
|
2450
2687
|
return await this.pollOnce(channel, wait, limit, true, maxBytes);
|
|
2451
2688
|
} catch (error) {
|
|
2452
|
-
if (!error?.notSaved) throw error;
|
|
2453
2689
|
// What this read changed is undone in memory too: the cursor, the hashes
|
|
2454
2690
|
// and the bindings stay as the file has them, and the messages wait at
|
|
2455
|
-
// the service for the next read, which hands them over.
|
|
2691
|
+
// the service for the next read, which hands them over. Whatever stopped
|
|
2692
|
+
// the read: one that threw halfway through its messages kept the hashes of
|
|
2693
|
+
// the ones it had taken, the next channel's save wrote them down, and the
|
|
2694
|
+
// next read handed those messages over marked replay, never having handed
|
|
2695
|
+
// them over at all (the release check of 13 November 2026).
|
|
2456
2696
|
Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen, seen_from: before.seen_from });
|
|
2457
2697
|
this.state.peers = before.peers;
|
|
2698
|
+
if (!error?.notSaved) throw error;
|
|
2458
2699
|
this.note(channel.label, "not_saved", `what this read found here could not be written down (${error.cause?.code || error.cause?.message}), so nothing from this channel was handed over. It is still there for the next read, and nothing was skipped.`, channel.w);
|
|
2459
2700
|
return [];
|
|
2460
2701
|
}
|
|
2461
2702
|
}
|
|
2462
2703
|
|
|
2704
|
+
/**
|
|
2705
|
+
* One channel of a read across channels, and what went wrong there said in
|
|
2706
|
+
* attention rather than thrown, as aamio-python and aamio-php do. One channel
|
|
2707
|
+
* that threw rejected the whole read, and what the channels before it had
|
|
2708
|
+
* handed over, their cursors already saved, was lost (a security review of
|
|
2709
|
+
* 13 November 2026).
|
|
2710
|
+
*/
|
|
2711
|
+
async pollSafely(channel, wait, limit, maxBytes) {
|
|
2712
|
+
try {
|
|
2713
|
+
return await this.poll(channel, wait, limit, maxBytes);
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
this.note(channel.label, "unread", `this channel could not be read: ${error?.name || "Error"}. Messages from the other channels are still returned.`);
|
|
2716
|
+
return [];
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2463
2720
|
async saveRead() {
|
|
2464
2721
|
try {
|
|
2465
2722
|
await this.saveState();
|
|
@@ -2606,6 +2863,13 @@ export class NodeRuntime {
|
|
|
2606
2863
|
// cursor cannot be written, so the same rows are not written down again
|
|
2607
2864
|
// when this process reads them again.
|
|
2608
2865
|
const written = [];
|
|
2866
|
+
// The outer read tidied before it began, and from the first message weighed
|
|
2867
|
+
// for the archive nothing tidies until it returns, in this read or another:
|
|
2868
|
+
// what it says of the archive is so as it returns. Tidied here, channel by
|
|
2869
|
+
// channel, a ceiling under one row took the first channel's row when the
|
|
2870
|
+
// hour passed before the second (the fifth release check of 14 November
|
|
2871
|
+
// 2026).
|
|
2872
|
+
if (Array.isArray(data.messages) && data.messages.length > 0 && this.archiveEnabled && !this.closed) await this.givingReceipts();
|
|
2609
2873
|
for (const raw of data.messages) {
|
|
2610
2874
|
if (limit !== null && entries.length >= limit) break;
|
|
2611
2875
|
const entry = this.readEntry(channel, raw);
|
|
@@ -2680,13 +2944,33 @@ export class NodeRuntime {
|
|
|
2680
2944
|
if (typeof channel.instance === "string") row.instance = channel.instance;
|
|
2681
2945
|
const delivery = this.deliveryOf(channel, entry);
|
|
2682
2946
|
if (this.archiveEnabled) {
|
|
2947
|
+
// The file first: one that is another now, or shorter, makes this
|
|
2948
|
+
// process forget what it wrote to it, and that is looked for in it
|
|
2949
|
+
// again (the third release check of 14 November 2026).
|
|
2950
|
+
const before = delivery !== null && typeof channel.instance === "string" ? await this.archivedBefore(channel.label) : new Set();
|
|
2951
|
+
if (delivery !== null && !this.writtenDown.has(delivery) && before.has(delivery)) {
|
|
2952
|
+
// Written down by an earlier process that stopped before its cursor:
|
|
2953
|
+
// the row holds the same message and is used again, where it was
|
|
2954
|
+
// written a second time (the release check of 14 November 2026). A
|
|
2955
|
+
// row without the thread's instance, from a version before it was
|
|
2956
|
+
// kept, could be another delivery, so both are kept.
|
|
2957
|
+
this.writtenDown.set(delivery, archiveName(channel.label));
|
|
2958
|
+
const shown = [channel.w, channel.instance, entry.seq].join("\u0000");
|
|
2959
|
+
this.rowsReused.set(shown, (this.rowsReused.get(shown) ?? 0) + 1);
|
|
2960
|
+
}
|
|
2683
2961
|
if (delivery !== null && this.writtenDown.has(delivery)) {
|
|
2684
|
-
// Written down at a read
|
|
2962
|
+
// Written down at a read that did not get as far as its cursor, by this process or an earlier one.
|
|
2685
2963
|
entry.archived = true;
|
|
2686
2964
|
} else {
|
|
2687
2965
|
try {
|
|
2688
2966
|
entry.archived = (await this.archive(channel.label, row)) !== false;
|
|
2689
|
-
if (entry.archived && delivery !== null)
|
|
2967
|
+
if (entry.archived && delivery !== null) {
|
|
2968
|
+
written.push(delivery);
|
|
2969
|
+
// Known at once: a read that throws before its cursor is written
|
|
2970
|
+
// reads it again, and wrote it down a second time (the release
|
|
2971
|
+
// check of 14 November 2026).
|
|
2972
|
+
this.writtenDown.set(delivery, archiveName(channel.label));
|
|
2973
|
+
}
|
|
2690
2974
|
} catch (error) {
|
|
2691
2975
|
entry.archived = false;
|
|
2692
2976
|
said.push(["archive_failed", `message ${entry.seq} was handed over and could not be written to the archive: ${error.code || error.name}: ${error.message}. The archive does not have it.`]);
|
|
@@ -2739,7 +3023,7 @@ export class NodeRuntime {
|
|
|
2739
3023
|
try {
|
|
2740
3024
|
await this.saveRead();
|
|
2741
3025
|
} catch (error) {
|
|
2742
|
-
for (const delivery of written) this.writtenDown.
|
|
3026
|
+
for (const delivery of written) this.writtenDown.set(delivery, archiveName(channel.label));
|
|
2743
3027
|
throw error;
|
|
2744
3028
|
}
|
|
2745
3029
|
for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
|
|
@@ -2866,6 +3150,13 @@ export class NodeRuntime {
|
|
|
2866
3150
|
|
|
2867
3151
|
/** Messages on every channel still read, the inbox first. The inbox is opened and presence published as the read starts. */
|
|
2868
3152
|
async read({ wait = 0, limit = 50, maxBytes = null } = {}) {
|
|
3153
|
+
// The channels it reads one by one are one read for the archive: it is
|
|
3154
|
+
// tidied before the first, and not again until this returns.
|
|
3155
|
+
return this.outerRead(() => this.readHeld({ wait, limit, maxBytes }));
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/** read, as one read for the archive. */
|
|
3159
|
+
async readHeld({ wait, limit, maxBytes }) {
|
|
2869
3160
|
const noted = this.attention.length;
|
|
2870
3161
|
await this.ensureInbox();
|
|
2871
3162
|
await this.publishPresence();
|
|
@@ -2896,11 +3187,11 @@ export class NodeRuntime {
|
|
|
2896
3187
|
notAsked.push(channel);
|
|
2897
3188
|
continue;
|
|
2898
3189
|
}
|
|
2899
|
-
messages.push(...(await this.
|
|
3190
|
+
messages.push(...(await this.pollSafely(channel, 0, limit - messages.length, maxBytes)));
|
|
2900
3191
|
}
|
|
2901
3192
|
const inbox = this.channel("inbox");
|
|
2902
3193
|
if (!messages.length && wait > 0 && inbox && !inbox.muted) {
|
|
2903
|
-
messages.push(...(await this.
|
|
3194
|
+
messages.push(...(await this.pollSafely(inbox, wait, limit, maxBytes)));
|
|
2904
3195
|
// What reached the other channels during the wait comes with this
|
|
2905
3196
|
// answer, as in aamio-python: only the inbox can end the wait early. It
|
|
2906
3197
|
// used to wait for the next read (a review of 9 November 2026).
|
|
@@ -2910,7 +3201,7 @@ export class NodeRuntime {
|
|
|
2910
3201
|
notAskedAfter.push(channel);
|
|
2911
3202
|
continue;
|
|
2912
3203
|
}
|
|
2913
|
-
messages.push(...(await this.
|
|
3204
|
+
messages.push(...(await this.pollSafely(channel, 0, limit - messages.length, maxBytes)));
|
|
2914
3205
|
}
|
|
2915
3206
|
}
|
|
2916
3207
|
for (const channel of notAsked) {
|