@uuaid/pillar 0.2.0-alpha.7 → 0.2.0-alpha.9
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 +14 -1
- package/bin/pillar.mjs +80 -13
- package/package.json +1 -1
- package/src/bridges/zestro.mjs +89 -17
- package/src/mailbox/mailbox.mjs +10 -0
- package/src/pillar.mjs +26 -0
package/README.md
CHANGED
|
@@ -31,6 +31,19 @@ pillar id
|
|
|
31
31
|
pillar ask <their-uuaid> "<your question>"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
Check your mail any time — `pillar-ask` never drains your inbox, so replies
|
|
35
|
+
and messages queue at the carrier until you pull them:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pillar inbox # one-shot: fetch pending envelopes, print, exit
|
|
39
|
+
pillar inbox --unread # also print older stored-but-never-shown envelopes
|
|
40
|
+
pillar inbox --notify 'osascript -e "display notification ..."' # hook when mail waits
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For automated checking, put `pillar inbox --notify <cmd>` on a cron/launchd
|
|
44
|
+
interval — the hook gets `PILLAR_NEW` / `PILLAR_UNREAD` / `PILLAR_UUAID` env
|
|
45
|
+
vars and a JSON summary on stdin, and only fires when there is mail.
|
|
46
|
+
|
|
34
47
|
`--directory` fetches the directory and configures every carrier + seed listed
|
|
35
48
|
there, and writes every `agents[]` entry (uuaid + publicKey) into
|
|
36
49
|
`<home>/fleet.json` as your address book — so step 3 works immediately, no key
|
|
@@ -44,7 +57,7 @@ to build a private mesh.
|
|
|
44
57
|
|---|---|---|
|
|
45
58
|
| `pillar-responder` | Always-on auto-attendant: acks requests instantly, replies from fixed text or a command hook. Run it as a service; agents anywhere get ack+reply without you being live. | one process, outbound HTTPS |
|
|
46
59
|
| `pillar-seed` | A carrier seed — makes you part of the p2p mesh. Any agent that lists your URL replicates every envelope to you and polls you in parallel with other seeds. | one process, one SQLite file, TLS-terminating proxy in front |
|
|
47
|
-
| `pillar` | The full CLI (`init`, `id`, `send`, `send-group`, `recv`, `peers`) — optional libp2p DHT, or carrier-only. | only when used interactively |
|
|
60
|
+
| `pillar` | The full CLI (`init`, `id`, `send`, `send-group`, `recv`, `inbox`, `peers`) — optional libp2p DHT, or carrier-only. | only when used interactively |
|
|
48
61
|
|
|
49
62
|
```sh
|
|
50
63
|
pillar-responder --home ~/.pillar-responder --reply-text "ack" &
|
package/bin/pillar.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* pillar id [--home ~/.pillar] print our UUAID + PeerId + addrs
|
|
8
8
|
* pillar send <recipient> <kind> <json> send a signed envelope
|
|
9
9
|
* pillar recv [--home ~/.pillar] [--follow] listen for envelopes and print them
|
|
10
|
+
* pillar inbox [--home ~/.pillar] [--unread] [--notify CMD] [--mark-read]
|
|
11
|
+
* one-shot: pull pending mail, print, exit
|
|
10
12
|
* pillar peers list currently connected peers
|
|
11
13
|
*
|
|
12
14
|
* All commands respect --home to point at an alternate pillar directory,
|
|
@@ -323,6 +325,10 @@ async function cmdRecv(args) {
|
|
|
323
325
|
url: process.env.PILLAR_ZESTRO_URL ?? args["zestro-url"],
|
|
324
326
|
token: process.env.PILLAR_ZESTRO_TOKEN ?? args["zestro-token"],
|
|
325
327
|
sessionId: process.env.PILLAR_ZESTRO_AGENT ?? args["zestro-agent"],
|
|
328
|
+
tabId: process.env.PILLAR_ZESTRO_TAB ?? args["zestro-tab"],
|
|
329
|
+
// Opt-in: force on a busy tab spawns a SECOND agent process resuming the
|
|
330
|
+
// same transcript (handle-spawn.js has no live-process guard). Default off.
|
|
331
|
+
force: /^1|true|yes$/i.test(process.env.PILLAR_ZESTRO_FORCE ?? "") || !!args["zestro-force"],
|
|
326
332
|
});
|
|
327
333
|
await bridge.connect();
|
|
328
334
|
// Never log the full URL — it embeds the auth token.
|
|
@@ -336,24 +342,36 @@ async function cmdRecv(args) {
|
|
|
336
342
|
if (bridge && env.kind === "request") {
|
|
337
343
|
// Interaction-convention return leg: ack FIRST — pillar-ask allows 120s
|
|
338
344
|
// per phase but ZestroBridge times out at 30s, so the ack goes out before
|
|
339
|
-
// the bridged agent turn starts.
|
|
340
|
-
//
|
|
341
|
-
//
|
|
345
|
+
// the bridged agent turn starts. Reply statuses (verified against app code):
|
|
346
|
+
// "ok" = command_result received = renderer consumed / spawn initiated
|
|
347
|
+
// (NOT turn-complete — the turn's answer returns via a separate
|
|
348
|
+
// pillar send from the agent, if the prompt instructs one)
|
|
349
|
+
// "busy" = target tab busy, server rejected (fast, no 30s hang)
|
|
350
|
+
// "pending" = bridge timeout — the request was never confirmed
|
|
351
|
+
// "error" = bridge failure
|
|
342
352
|
const thread = env.payload?.thread ?? env.id;
|
|
343
353
|
const senderKey = env.transportSignature.publicKey; // open() F1: sender derives from this key
|
|
344
354
|
try {
|
|
345
355
|
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
|
|
346
356
|
const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
|
|
347
|
-
const
|
|
357
|
+
const hasText = result && typeof result === "object" &&
|
|
358
|
+
["text", "result", "output", "response", "message"].some(k => k in result);
|
|
359
|
+
const text = hasText
|
|
360
|
+
? replyTextFromResult(result)
|
|
361
|
+
: "zestro command_result received (server accepted) — agent turn answer, if any, returns via separate pillar send";
|
|
348
362
|
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "ok", text } });
|
|
349
363
|
console.log(` → zestro reply sent (${text.length} bytes)`);
|
|
350
364
|
} catch (e) {
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
365
|
+
// ZestroServerError SESSION_BUSY = server rejected: target tab busy and
|
|
366
|
+
// force off. Immediate distinct status — the sender can retry or drop.
|
|
367
|
+
if (e.code === "SESSION_BUSY") {
|
|
368
|
+
console.log(` → zestro: busy (${e.message})`);
|
|
369
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "busy", reason: e.message.slice(0, 300), text: `zestro agent busy: ${e.message.slice(0, 300)}` } });
|
|
370
|
+
} else if (/timed out/.test(e.message)) {
|
|
371
|
+
// Bridge timeout = the server never confirmed delivery (renderer drop,
|
|
372
|
+
// cli-server gone, etc.) — accepted-unconfirmed, not turn-running.
|
|
373
|
+
console.log(` → zestro: unconfirmed (bridge timeout)`);
|
|
374
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "pending", text: "request forwarded to zestro agent — delivery unconfirmed" } });
|
|
357
375
|
} else {
|
|
358
376
|
console.error(` → zestro bridge error: ${e.message}`);
|
|
359
377
|
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "error", reason: e.message.slice(0, 300), text: `zestro bridge error: ${e.message.slice(0, 300)}` } });
|
|
@@ -364,8 +382,10 @@ async function cmdRecv(args) {
|
|
|
364
382
|
const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
|
|
365
383
|
console.log(` → zestro result: ${JSON.stringify(result).slice(0, 200)}`);
|
|
366
384
|
} catch (e) {
|
|
367
|
-
if (
|
|
368
|
-
console.log(` → zestro:
|
|
385
|
+
if (e.code === "SESSION_BUSY") {
|
|
386
|
+
console.log(` → zestro: busy (${e.message})`);
|
|
387
|
+
} else if (/timed out/.test(e.message)) {
|
|
388
|
+
console.log(` → zestro: unconfirmed (bridge timeout)`);
|
|
369
389
|
} else {
|
|
370
390
|
console.error(` → zestro bridge error: ${e.message}`);
|
|
371
391
|
}
|
|
@@ -379,6 +399,52 @@ async function cmdRecv(args) {
|
|
|
379
399
|
await new Promise(() => {});
|
|
380
400
|
}
|
|
381
401
|
|
|
402
|
+
async function cmdInbox(args) {
|
|
403
|
+
// One-shot mail check for ANY identity: point --home at the pillar dir that
|
|
404
|
+
// minted the uuaid. Pulls pending envelopes from the carrier (no long-poll),
|
|
405
|
+
// prints them decrypted, reports unread backlog, exits. --notify CMD runs a
|
|
406
|
+
// shell hook when there is mail (cron/launchd-friendly); --mark-read stamps
|
|
407
|
+
// read_at on everything printed.
|
|
408
|
+
args["carrier-only"] = true; // inbox check needs the carrier only — no libp2p listener
|
|
409
|
+
const p = loadPillar(args);
|
|
410
|
+
const fresh = [];
|
|
411
|
+
p.onEnvelope((env) => { fresh.push(env); });
|
|
412
|
+
await p.start();
|
|
413
|
+
const pulled = await p.drainCarrierInbox();
|
|
414
|
+
for (const env of fresh) {
|
|
415
|
+
console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload: env.payload }));
|
|
416
|
+
}
|
|
417
|
+
// Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
|
|
418
|
+
const { decrypt } = await import("../src/net/envelope.mjs");
|
|
419
|
+
const freshIds = new Set(fresh.map(e => e.id));
|
|
420
|
+
const backlog = (p.mailbox.inboxUnread?.(500) ?? []).filter(r => !freshIds.has(r.envelope_id));
|
|
421
|
+
if (args.unread) {
|
|
422
|
+
for (const row of backlog) {
|
|
423
|
+
const env = JSON.parse(row.envelope_json);
|
|
424
|
+
let payload = null;
|
|
425
|
+
try { payload = decrypt(p.keychain, env); } catch (_e) { payload = { error: "decrypt failed" }; }
|
|
426
|
+
console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload }));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
console.log(`inbox: ${pulled} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}`);
|
|
430
|
+
if (args["mark-read"]) {
|
|
431
|
+
for (const env of fresh) p.mailbox.markInboxRead?.(env.id);
|
|
432
|
+
if (args.unread) for (const row of backlog) p.mailbox.markInboxRead?.(row.envelope_id);
|
|
433
|
+
}
|
|
434
|
+
if (args.notify && typeof args.notify === "string" && (pulled > 0 || backlog.length > 0)) {
|
|
435
|
+
const { spawnSync } = await import("node:child_process");
|
|
436
|
+
const r = spawnSync(args.notify, {
|
|
437
|
+
shell: true,
|
|
438
|
+
input: JSON.stringify({ uuaid: p.uuaid, new: pulled, unread: backlog.length }) + "\n",
|
|
439
|
+
env: { ...process.env, PILLAR_NEW: String(pulled), PILLAR_UNREAD: String(backlog.length), PILLAR_UUAID: p.uuaid },
|
|
440
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
441
|
+
});
|
|
442
|
+
if (r.status !== 0) console.error(`notify hook exited ${r.status}`);
|
|
443
|
+
}
|
|
444
|
+
await p.stop();
|
|
445
|
+
exit(0);
|
|
446
|
+
}
|
|
447
|
+
|
|
382
448
|
async function cmdPeers(args) {
|
|
383
449
|
const p = loadPillar(args);
|
|
384
450
|
await p.start();
|
|
@@ -398,9 +464,10 @@ async function main() {
|
|
|
398
464
|
case "send-group": return cmdSendGroup(args);
|
|
399
465
|
case "groups": return cmdGroups(args);
|
|
400
466
|
case "recv": return cmdRecv(args);
|
|
467
|
+
case "inbox": return cmdInbox(args);
|
|
401
468
|
case "peers": return cmdPeers(args);
|
|
402
469
|
default:
|
|
403
|
-
console.error(`usage: pillar <init|id|send|send-group|groups|recv|peers> [--home DIR]`);
|
|
470
|
+
console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|peers> [--home DIR]`);
|
|
404
471
|
exit(2);
|
|
405
472
|
}
|
|
406
473
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uuaid/pillar",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.9",
|
|
4
4
|
"description": "IAASO-3050 reference implementation. Every agent runs its own PDP pillar, discovers peers via a Kademlia DHT, and exchanges cryptographically signed + Noise-encrypted messages over libp2p. One command to install and join the network.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/bridges/zestro.mjs
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zestro WebSocket bridge.
|
|
3
3
|
*
|
|
4
|
-
* Wire protocol
|
|
4
|
+
* Wire protocol — verified against the OFFICIAL client contract
|
|
5
|
+
* (Zestro app.asar: dist/cli/maestro-client.ts, sourcesContent from
|
|
6
|
+
* maestro-cli.js.map) and the app's own server/renderer handlers:
|
|
5
7
|
* ws://127.0.0.1:53522/<token>/ws
|
|
6
|
-
* -> { type: "send_command",
|
|
7
|
-
*
|
|
8
|
+
* -> { type: "send_command", requestId: "req_...", sessionId: <agentId>,
|
|
9
|
+
* command: <str>, inputMode: "ai", tabId?: <uuid>, force?: true }
|
|
10
|
+
* <- { type: "command_result", success, sessionId, tabId?, requestId } // echoes requestId
|
|
11
|
+
* <- { type: "error", message, sessionId } // NO requestId echo
|
|
12
|
+
*
|
|
13
|
+
* Correlation is on `requestId`, NOT `id` — the server echoes only
|
|
14
|
+
* `requestId`, so a client correlating on `id` can never match a reply.
|
|
15
|
+
*
|
|
16
|
+
* Semantics (verified in app code, all four layers):
|
|
17
|
+
* - command_result arrives IMMEDIATELY after the renderer receives the
|
|
18
|
+
* command — it means "consumed by the renderer / spawn initiated", NOT
|
|
19
|
+
* "the agent's turn completed". The turn answer returns out-of-band.
|
|
20
|
+
* - A busy target session WITHOUT force is rejected by the server
|
|
21
|
+
* ({type:"error"}), and the renderer has TWO more busy drops of its own.
|
|
22
|
+
* Errors carry no requestId, so they must be handled single-flight.
|
|
23
|
+
* - force=true on a BUSY tab spawns a SECOND agent process resuming the
|
|
24
|
+
* same session transcript (handle-spawn.js: no live-process guard,
|
|
25
|
+
* --resume <agentSessionId>). Concurrent, not queued. Opt-in only.
|
|
8
26
|
*
|
|
9
27
|
* Token lives in ~/Library/Application Support/zestro/cli-server.json.
|
|
10
28
|
* Agent id for ZZ was verified to be 7fbce535-3a2f-4e6b-96e6-64926403efa5.
|
|
11
|
-
*
|
|
12
|
-
* Correlation on `id` so multiple concurrent sends don't race.
|
|
13
29
|
*/
|
|
14
30
|
import { WebSocket } from "ws";
|
|
15
|
-
|
|
31
|
+
|
|
32
|
+
/** Server rejected the command (busy session, bad payload, ...). */
|
|
33
|
+
export class ZestroServerError extends Error {
|
|
34
|
+
constructor(message, code = "ZESTRO_SERVER_ERROR") {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "ZestroServerError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
16
40
|
|
|
17
41
|
export class ZestroBridge {
|
|
18
|
-
constructor({ url, token, sessionId, timeoutMs = 30000 }) {
|
|
42
|
+
constructor({ url, token, sessionId, tabId, timeoutMs = 30000, force = false }) {
|
|
19
43
|
if (!url || !token || !sessionId) {
|
|
20
44
|
throw new Error("ZestroBridge requires { url, token, sessionId }");
|
|
21
45
|
}
|
|
@@ -26,7 +50,9 @@ export class ZestroBridge {
|
|
|
26
50
|
}
|
|
27
51
|
this.url = url;
|
|
28
52
|
this.sessionId = sessionId;
|
|
53
|
+
this.tabId = tabId ?? null;
|
|
29
54
|
this.timeoutMs = timeoutMs;
|
|
55
|
+
this.force = force;
|
|
30
56
|
this._ws = null;
|
|
31
57
|
this._pending = new Map();
|
|
32
58
|
}
|
|
@@ -44,35 +70,81 @@ export class ZestroBridge {
|
|
|
44
70
|
ws.on("message", (buf) => {
|
|
45
71
|
let msg;
|
|
46
72
|
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
|
|
74
|
+
// `echo` = the app did not recognize the message type (older build).
|
|
75
|
+
if (msg.type === "echo") {
|
|
76
|
+
const original = msg.data ?? {};
|
|
77
|
+
const reqId = original.requestId ?? msg.originalRequestId;
|
|
78
|
+
if (reqId && this._pending.has(reqId)) {
|
|
79
|
+
this._settle(reqId, p => p.reject(new ZestroServerError(
|
|
80
|
+
`Zestro app does not support '${original.type ?? "unknown"}' (older build?)`,
|
|
81
|
+
"ZESTRO_UNSUPPORTED"
|
|
82
|
+
)));
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Exact correlation: server echoes requestId on command_result.
|
|
88
|
+
if (msg.requestId && this._pending.has(msg.requestId)) {
|
|
89
|
+
this._settle(msg.requestId, p => p.resolve(msg));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Single-flight: server `error` replies carry NO requestId (verified:
|
|
94
|
+
// sendError sends {type:'error', message, ...extra}). The bridge keeps
|
|
95
|
+
// at most one request in flight, so route orphan replies to it.
|
|
96
|
+
if (msg.type === "error") {
|
|
97
|
+
const message = msg.message ?? "zestro server error";
|
|
98
|
+
const code = /busy/i.test(message) ? "SESSION_BUSY" : "ZESTRO_SERVER_ERROR";
|
|
99
|
+
this._settleOldest(p => p.reject(new ZestroServerError(message, code)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (msg.type === "command_result") {
|
|
103
|
+
this._settleOldest(p => p.resolve(msg));
|
|
51
104
|
}
|
|
52
105
|
});
|
|
53
106
|
});
|
|
54
107
|
}
|
|
55
108
|
|
|
56
|
-
|
|
109
|
+
_settle(requestId, fn) {
|
|
110
|
+
const p = this._pending.get(requestId);
|
|
111
|
+
if (p) {
|
|
112
|
+
this._pending.delete(requestId);
|
|
113
|
+
fn(p);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_settleOldest(fn) {
|
|
118
|
+
const first = this._pending.entries().next();
|
|
119
|
+
if (!first.done) {
|
|
120
|
+
const [requestId, p] = first.value;
|
|
121
|
+
this._pending.delete(requestId);
|
|
122
|
+
fn(p);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sendCommand({ command, inputMode = "ai", force } = {}) {
|
|
57
127
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
|
58
128
|
return Promise.reject(new Error("zestro bridge not connected"));
|
|
59
129
|
}
|
|
60
|
-
const
|
|
130
|
+
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
61
131
|
return new Promise((resolve, reject) => {
|
|
62
132
|
const timer = setTimeout(() => {
|
|
63
|
-
this._pending.delete(
|
|
64
|
-
reject(new Error(`zestro send_command ${
|
|
133
|
+
this._pending.delete(requestId);
|
|
134
|
+
reject(new Error(`zestro send_command ${requestId} timed out after ${this.timeoutMs}ms`));
|
|
65
135
|
}, this.timeoutMs);
|
|
66
|
-
this._pending.set(
|
|
136
|
+
this._pending.set(requestId, {
|
|
67
137
|
resolve: (msg) => { clearTimeout(timer); resolve(msg); },
|
|
68
138
|
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
69
139
|
});
|
|
70
140
|
this._ws.send(JSON.stringify({
|
|
71
141
|
type: "send_command",
|
|
72
|
-
|
|
142
|
+
requestId,
|
|
73
143
|
sessionId: this.sessionId,
|
|
74
144
|
command,
|
|
75
145
|
inputMode,
|
|
146
|
+
...(this.tabId ? { tabId: this.tabId } : {}),
|
|
147
|
+
...((force ?? this.force) ? { force: true } : {}),
|
|
76
148
|
}));
|
|
77
149
|
});
|
|
78
150
|
}
|
package/src/mailbox/mailbox.mjs
CHANGED
|
@@ -155,6 +155,16 @@ export class Mailbox {
|
|
|
155
155
|
return this.db.prepare("select * from inbox where seq > ? order by seq asc limit ?").all(seq, limit);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** Envelopes never shown to the operator (read_at null). Oldest first. */
|
|
159
|
+
inboxUnread(limit = 100) {
|
|
160
|
+
return this.db.prepare("select * from inbox where read_at is null order by seq asc limit ?").all(limit);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
markInboxRead(envelopeId) {
|
|
164
|
+
this.db.prepare("update inbox set read_at = ? where envelope_id = ? and read_at is null")
|
|
165
|
+
.run(new Date().toISOString(), envelopeId);
|
|
166
|
+
}
|
|
167
|
+
|
|
158
168
|
/**
|
|
159
169
|
* Recompute the outbox chain from scratch and compare to stored chain_hash column.
|
|
160
170
|
* Returns { ok, badSeq? }. Used by adversarial tests + operator-driven audits.
|
package/src/pillar.mjs
CHANGED
|
@@ -338,6 +338,32 @@ export class Pillar {
|
|
|
338
338
|
if (this._carrierPoller) { this._carrierPoller.stop(); this._carrierPoller = null; }
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* One-shot inbox pull: fetch every pending envelope from every carrier
|
|
343
|
+
* (no long-poll) through the same verification + mailbox + handler
|
|
344
|
+
* pipeline, then return. Complements startCarrierPolling for CLI /
|
|
345
|
+
* cron-style "check my mail now" callers. Returns envelopes fetched.
|
|
346
|
+
*/
|
|
347
|
+
async drainCarrierInbox() {
|
|
348
|
+
if (!this.carrierClient) return 0;
|
|
349
|
+
let total = 0;
|
|
350
|
+
const cursors = this.mailbox.getCarrierCursors?.() ?? {};
|
|
351
|
+
for (const base of this.carriers) {
|
|
352
|
+
let since = cursors[base] ?? 0;
|
|
353
|
+
for (;;) { // carrier pages at 200/fetch — loop until an empty page
|
|
354
|
+
const r = await this.carrierClient.fetchInbox(base, { since, waitS: 0 });
|
|
355
|
+
if (!r.envelopes.length) break;
|
|
356
|
+
for (const { seq, envelope } of r.envelopes) {
|
|
357
|
+
await this._receive(JSON.stringify(envelope), null);
|
|
358
|
+
this.mailbox.setCarrierCursor?.(base, seq);
|
|
359
|
+
since = Math.max(since, seq);
|
|
360
|
+
total++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return total;
|
|
365
|
+
}
|
|
366
|
+
|
|
341
367
|
async _deliverToPeer(envelope, peerIdStr, multiaddrStr) {
|
|
342
368
|
const { peerIdFromString } = await import("@libp2p/peer-id");
|
|
343
369
|
const { multiaddr } = await import("@multiformats/multiaddr");
|