@mcsherrylabs/pi-meerkat 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -10
- package/dist/installer/committee-init.sh +17 -33
- package/dist/installer/committee.conf +12 -34
- package/dist/installer/genesis.devnet.conf +92 -0
- package/dist/installer/node-follower.conf +232 -0
- package/dist/installer/node-join.sh +15 -1
- package/dist/installer/node.conf +4 -231
- package/dist/installer/node.local.conf +8 -0
- package/dist/installer/runtime.json +1 -1
- package/dist/installer-runtime/dist/scripts/recover-owner.js +7 -9
- package/dist/installer-runtime/dist/scripts/subscriptions-init.js +3 -4
- package/dist/installer-runtime/dist/scripts/versions.mjs +5 -10
- package/dist/installer-runtime/dist/src/announcements.js +1 -289
- package/dist/installer-runtime/dist/src/app.js +12 -535
- package/dist/installer-runtime/dist/src/chainfetch.js +0 -64
- package/dist/installer-runtime/dist/src/env.js +47 -32
- package/dist/installer-runtime/dist/src/registry/index.js +11 -2
- package/dist/installer-runtime/dist/src/registry/provision.js +46 -1
- package/dist/installer-runtime/dist/src/service.js +151 -0
- package/dist/installer.d.ts +185 -18
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +161 -52
- package/dist/installer.js.map +1 -1
- package/dist/mcp-announce.d.ts +18 -0
- package/dist/mcp-announce.d.ts.map +1 -1
- package/dist/mcp-announce.js +18 -4
- package/dist/mcp-announce.js.map +1 -1
- package/dist/mcp-cli.d.ts.map +1 -1
- package/dist/mcp-cli.js +28 -18
- package/dist/mcp-cli.js.map +1 -1
- package/dist/mcp-config.d.ts +1 -0
- package/dist/mcp-config.d.ts.map +1 -1
- package/dist/mcp-config.js +2 -1
- package/dist/mcp-config.js.map +1 -1
- package/dist/mcp-upstream.d.ts +1 -1
- package/dist/mcp-upstream.d.ts.map +1 -1
- package/dist/mcp-upstream.js +7 -2
- package/dist/mcp-upstream.js.map +1 -1
- package/dist/tools.js +4 -4
- package/dist/tools.js.map +1 -1
- package/installer/Node.Dockerfile +5 -0
- package/installer/Registry.Dockerfile +34 -9
- package/installer/check-single-sdk.mjs +151 -0
- package/installer/runtime-control.mjs +179 -16
- package/package.json +5 -5
- package/skills/channel-subscription/SKILL.md +3 -3
- package/dist/installer-runtime/dist/src/runtime.js +0 -164
package/dist/installer/node.conf
CHANGED
|
@@ -1,235 +1,8 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
# node-local knobs below.
|
|
4
|
-
include file("/public-genesis/genesis.conf")
|
|
1
|
+
# DevNet wrapper: selects the devnet genesis, then the follower body.
|
|
2
|
+
include file("/config/genesis.devnet.conf")
|
|
5
3
|
|
|
6
4
|
# 089 chain-spec pin: the canonical hash of the genesis file IS the genesis block id.
|
|
7
5
|
# Any drift refuses to boot rather than forking quietly.
|
|
8
|
-
ce.evolver.genesis-file = "/
|
|
9
|
-
|
|
10
|
-
# This node's OWN identity, from the first-boot faucet claim (node-join.sh). REQUIRED
|
|
11
|
-
# substitutions (no `?`): a node booting with missing keys must fail fast, not later and
|
|
12
|
-
# obscurely at the first TLS handshake.
|
|
13
|
-
ce.evolver.node {
|
|
14
|
-
private-key = ${EVOLVER_PRIVATE_KEY} # Ed25519 PKCS#8 base64
|
|
15
|
-
public-key = ${EVOLVER_NODE_PUBLIC_KEY} # Ed25519 X.509 base64
|
|
16
|
-
bls-secret = ${EVOLVER_BLS_SEED} # BLS IKM base64
|
|
17
|
-
port = ${EVOLVER_NODE_PORT} # p2p transport (raw TCP, mTLS)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
evolver.peer-discovery {
|
|
21
|
-
# THIS NODE ADVERTISES NOTHING, AND MUST NOT. `preferred-url` is the address this node
|
|
22
|
-
# gossips as its OWN (`""` = feature off), and a joined app node behind Docker HAS no
|
|
23
|
-
# routable p2p address: its 7000 is container-local and deliberately unpublished. So
|
|
24
|
-
# advertising one means every node on the chain records `<us> -> https://127.0.0.1:7000`
|
|
25
|
-
# and then dials its own loopback.
|
|
26
|
-
#
|
|
27
|
-
# Observed on devnet, and it is worse than noise: this node CONNECTED TO ITSELF, logged
|
|
28
|
-
# `Peer connected: <self>`, sent its own SyncManager `BlockRangeRequest(4841,4845)`, and
|
|
29
|
-
# TipGossip then reported `rotating to <self> (failed=devnet1-node0)` followed by
|
|
30
|
-
# `stalled (upstream=none)`. After that its submits were never forwarded and every write
|
|
31
|
-
# timed out. The table also keeps the dead: `evolvertemplateappnode965813 at
|
|
32
|
-
# 127.0.0.1:7000` from an earlier deployment was still being dialled hours later.
|
|
33
|
-
# ADVERTISED AGAIN, and ⚠️ ONLY BECAUSE IT IS NOW REAL. This was `""` because the node
|
|
34
|
-
# had been advertising `https://127.0.0.1:7000`: every peer that read the discovery
|
|
35
|
-
# table dialled its OWN loopback, so this node connected to itself, sent its own
|
|
36
|
-
# SyncManager block requests, reported `candidates=<self>` / `stalled (upstream=none)`
|
|
37
|
-
# and stopped forwarding submits. An unroutable value reinstates that exactly — which
|
|
38
|
-
# is why `EVOLVER_TRANSPORT_ADDRESS` is REQUIRED here rather than defaulted, and why the
|
|
39
|
-
# command suite refuses a loopback value.
|
|
40
|
-
# ⚠️ EMPTY, DELIBERATELY. This node is a READER behind NAT: it has no routable p2p address.
|
|
41
|
-
# Advertising one is not merely useless, it is harmful — devnet recorded a node that advertised
|
|
42
|
-
# an unroutable address CONNECTING TO ITSELF, sending its own sync requests, reporting
|
|
43
|
-
# `stalled (upstream=none)`, and leaving dead entries other nodes dialled for hours.
|
|
44
|
-
#
|
|
45
|
-
# It costs this node nothing: advertising is how others DIAL YOU, and nothing needs to. Writing
|
|
46
|
-
# still works — this node dials OUT, discovers the network, learns its distance to the committee
|
|
47
|
-
# and forwards transactions along that path. Being undialable only stops bodies being flooded TO
|
|
48
|
-
# it, which is a DA member's concern and not a reader's.
|
|
49
|
-
transport-address = ""
|
|
50
|
-
peer-url = ""
|
|
51
|
-
gossip-interval-seconds = 60
|
|
52
|
-
|
|
53
|
-
# 16 INBOUND, not 0. The 141 leaf recipe (`in=0`) is right for an app node that only
|
|
54
|
-
# talks upstream — and wrong here: the registry's channel requires announcers' nodes to
|
|
55
|
-
# reach THIS node so their flooded bodies can be attested (research D2). Each announcer
|
|
56
|
-
# holds one inbound slot, so 16 is the number of concurrent announcer nodes supported.
|
|
57
|
-
# Generous on both sides so DISCOVERY actually happens. The seed below is pinned so the node
|
|
58
|
-
# always has a way back to the chain, but `allowed-outbound` is deliberately LARGER than the
|
|
59
|
-
# pinned list — the pin is strict only while the two are equal, and a strict pin here would mean
|
|
60
|
-
# this node never dials anything it discovers.
|
|
61
|
-
allowed-inbound = 16
|
|
62
|
-
# `allowed-outbound` and `preferred-peers` are DELIBERATELY ABSENT HERE — node-join.sh renders
|
|
63
|
-
# both into peers.conf so the two cannot drift apart in two files.
|
|
64
|
-
#
|
|
65
|
-
# ⚠️ THIS COMMENT USED TO CLAIM THE PIN IS STRICT. It is NOT, and the difference matters to
|
|
66
|
-
# anyone reasoning about what this node will dial. It said `|preferred| == allowed-outbound`
|
|
67
|
-
# holds "by construction"; node-join.sh renders ONE pinned seed and an outbound budget of 16,
|
|
68
|
-
# so 1 != 16 and the pin is deliberately LOOSE. That is the intended design — a follower has to
|
|
69
|
-
# dial what it discovers or it is a leaf — but the old wording described the opposite property
|
|
70
|
-
# and named the incidents (a dead address from a torn-down deployment still being dialled, this
|
|
71
|
-
# node dialling its own loopback) that a strict pin was supposed to prevent. Those are the COST
|
|
72
|
-
# of the loose pin, not evidence against it: the seed guarantees a way back to the chain, and
|
|
73
|
-
# discovery does the rest. Which is what keeps a shared chain's discovery table out of the
|
|
74
|
-
# dial queue — it keeps the dead (an address from a torn-down deployment was still
|
|
75
|
-
# being dialled hours later) and once made this node dial its own loopback. Raising
|
|
76
|
-
# the budget without naming a peer would silently unpin it; naming a peer without
|
|
77
|
-
# raising the budget could never reach it. One list, both keys, no drift.
|
|
78
|
-
#
|
|
79
|
-
# ⚠️ THIS USED TO BE `out=1`, copied verbatim from the 141 LEAF recipe, and it decided
|
|
80
|
-
# WHICH CHANNELS THIS NODE COULD READ — silently. `ChannelOverlay.peers(ledgerId)` is
|
|
81
|
-
# the channel's DA group ∩ connected, so with the one slot spent on the upstream every
|
|
82
|
-
# channel whose DA member is somebody else resolved to an EMPTY pull set: the node
|
|
83
|
-
# records the miss, sends no request, and says so at DEBUG only (evolver#89). A node
|
|
84
|
-
# reading a channel it does not itself serve needs the upstream AND one reachable DA
|
|
85
|
-
# member per such channel, so a fixed 1 is only ever right for a node that reads
|
|
86
|
-
# nothing but its own.
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
ce.evolver.consensus {
|
|
90
|
-
validate-chain-on-startup = false
|
|
91
|
-
wake-on-work { forward-watermark = 16 }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
# PROCESSING MODE — off by default, and THE REASON THIS DEPLOYMENT EXISTS once your app
|
|
95
|
-
# reads a channel.
|
|
96
|
-
#
|
|
97
|
-
# `/api/channel/next` is a SINGLE-CONSUMER, FORWARD-ONLY cursor: two readers do not
|
|
98
|
-
# error, they silently split the stream and the message is unrecoverable. So an app that
|
|
99
|
-
# consumes an ordered channel must drain a node NOBODY ELSE drains — which means a node
|
|
100
|
-
# of its own. It cannot share a public one, and a single-node chain (devnet today) IS the
|
|
101
|
-
# committee, so that node is not a processing node and serves no stream at all.
|
|
102
|
-
#
|
|
103
|
-
# And the committee's node must never be put in this mode: processing mode DEFERS apply
|
|
104
|
-
# and paces it from the app's polling, so a committee node in processing mode would
|
|
105
|
-
# couple consensus progress to an HTTP poller. Ordering is its job; give the app its own.
|
|
106
|
-
#
|
|
107
|
-
# Turning it on changes what committed reads MEAN here — they answer "as of MY cursor".
|
|
108
|
-
# Measured in this family (evolver-scheduler): right after a successful faucet claim a
|
|
109
|
-
# quorum node reported `{"balance":5000}` for an identity while a processing node
|
|
110
|
-
# reported `{"error":"not_found"}` for the same one. An app in that mode needs to know
|
|
111
|
-
# which of its questions are "has the chain agreed?" (ask a quorum node) and which are
|
|
112
|
-
# "what have I processed?" (ask this one).
|
|
113
|
-
#
|
|
114
|
-
# ON, because this app owns a channel (`announcements`) and reads its ordered stream —
|
|
115
|
-
# steps 5-7 of src/app.ts. BOTH lines are needed and neither works alone: a node in
|
|
116
|
-
# processing mode with no channels declared has nothing to offer, and a node that
|
|
117
|
-
# declares channels without execution stores bodies it never streams.
|
|
118
|
-
#
|
|
119
|
-
# The channel must be named HERE as well as claimed on chain. A processing node stores
|
|
120
|
-
# bodies only for the channels it lists, so an undeclared channel's message is applied
|
|
121
|
-
# in the slice and never offered — the write looks fine and the stream stays silent,
|
|
122
|
-
# which is the failure the app's step 7 names explicitly.
|
|
123
|
-
# Processing is enabled only by the paired managed composition with its persistent zero-module
|
|
124
|
-
# shared SDK consumer. MCP sessions never drain. Independent remote nodes remain unmanaged.
|
|
125
|
-
ce.evolver.execution.enabled = ${?EVOLVER_EXECUTION_ENABLED}
|
|
126
|
-
# Shared SDK controller is the SOLE writer; node sees the whole config-only directory read-only.
|
|
127
|
-
# Ordinary boot/upgrade cannot merge defaults, resurrect removals or seed a lost declaration.
|
|
128
|
-
include required(file("/subscriptions/channels.conf"))
|
|
129
|
-
# `required` for the same reason: an absent optional include is IGNORED, and a node
|
|
130
|
-
# that silently fell back to the 64/64 defaults would dial the discovery table.
|
|
131
|
-
include required(file("/data/chain/peers.conf"))
|
|
132
|
-
|
|
133
|
-
# ── THE LOCAL ROUTES THIS DEPLOYMENT OPENS TO ITS OWN CONTAINERS ────────────
|
|
134
|
-
#
|
|
135
|
-
# The agent and the registry run BESIDE this node, each in its own container on this compose
|
|
136
|
-
# project's network. These routes carry no token and no client certificate — reaching the address
|
|
137
|
-
# IS the authorization — so the node binds them to loopback unless told otherwise, and loopback
|
|
138
|
-
# inside this container is reachable by nothing else in the stack.
|
|
139
|
-
#
|
|
140
|
-
# `bind-host = 0.0.0.0` here means: THIS COMPOSE NETWORK IS THE BOUNDARY. That claim holds only
|
|
141
|
-
# because none of these ports appear under `ports:` in docker-compose.yml and the stack does not
|
|
142
|
-
# use host networking. Publish one, or run with `--network host`, and the claim is simply false.
|
|
143
|
-
#
|
|
144
|
-
# ⚠️ `ce.evolver.login` is deliberately NOT here and cannot be: that route signs as any identity
|
|
145
|
-
# this host holds a signer for, which on this node is the node's own. Setting `bind-host` under
|
|
146
|
-
# that namespace fails the boot rather than being ignored.
|
|
147
|
-
|
|
148
|
-
# 145 — the agent sends and drains application messages. `/api/p2p/send` relays a message the
|
|
149
|
-
# agent signed ITSELF (its own identity, verified at the far end); `/api/p2p/sign-and-send` makes
|
|
150
|
-
# this node sign as itself. One listener, so opening it grants both.
|
|
151
|
-
ce.evolver.p2p {
|
|
152
|
-
http-port = 8460
|
|
153
|
-
bind-host = "0.0.0.0"
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
# 158 — the agent may ASK an enrolled phone to authorise a transaction. It cannot sign: the device
|
|
157
|
-
# holder approves every one, and that gate does not move when the bind does. Inert until a
|
|
158
|
-
# `mobile-signer` block exists, and the node says so at boot rather than failing.
|
|
159
|
-
ce.evolver.mobile {
|
|
160
|
-
http-port = 8470
|
|
161
|
-
bind-host = "0.0.0.0"
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
# 139 — CONTENT, so the agent can publish a document and the registry can read one back.
|
|
165
|
-
#
|
|
166
|
-
# `POST /content` writes to this node's own store, keyed by the bytes' own digest, and talks to
|
|
167
|
-
# no peer at all (FR-006). `bind-host` below is what lets the agent and the registry reach that
|
|
168
|
-
# route from their own containers. Fetching a document another node holds is the part that uses
|
|
169
|
-
# the overlay, and that is what the TLS listener underneath is for.
|
|
170
|
-
ce.evolver.content {
|
|
171
|
-
enabled = true
|
|
172
|
-
http-port = 8450
|
|
173
|
-
bind-host = "0.0.0.0"
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
data.base = "/data/chain"
|
|
177
|
-
state.snapshots = "snapshots"
|
|
178
|
-
# BOTH LISTENERS, and the pair is the point.
|
|
179
|
-
#
|
|
180
|
-
# 8440 PLAIN is what the agent and the registry talk to. They are ordinary HTTP clients in their
|
|
181
|
-
# own containers, and the alternative is not "use TLS" — this node's HTTPS listener presents its
|
|
182
|
-
# own IDENTITY certificate, self-signed, CN=<nodeId>, ~13 minutes old. It rotates, so it cannot
|
|
183
|
-
# be pinned as a CA, so a client that verifies it must turn verification off process-wide. For
|
|
184
|
-
# the agent that would also stop it verifying GoToSocial, which is not a trade worth making for
|
|
185
|
-
# a hop between two containers on one network.
|
|
186
|
-
#
|
|
187
|
-
# 8441 TLS is what PEERS use, and it exists so `ce.evolver.content` above can: the content peer
|
|
188
|
-
# route identifies its requester by client certificate, and refuses to start without somewhere
|
|
189
|
-
# mTLS to live. NOT PUBLISHED in docker-compose.yml — this node is a reader behind NAT that
|
|
190
|
-
# fetches outbound and serves nobody, so nothing needs to dial it.
|
|
191
|
-
#
|
|
192
|
-
# ⚠️ IF THIS NODE EVER SERVES CONTENT, `evolver.peer-discovery.api-url` must name 8441 and that
|
|
193
|
-
# port must be publicly reachable. It is empty here (see above): a node with no routable address
|
|
194
|
-
# advertises nothing and can only fetch.
|
|
195
|
-
http.port = 8440
|
|
196
|
-
# Only this private Compose network may reach the unsigned processing API.
|
|
197
|
-
http.bind-host = "0.0.0.0"
|
|
198
|
-
# ⚠️ REQUIRED SINCE 131 (#131), and without it this stack breaks. `http.bind-host` now defaults
|
|
199
|
-
# to 127.0.0.1 — the plain listener joined the bind-is-authorization set the four local listeners
|
|
200
|
-
# above already belong to, because it carries the whole API with no token and no client
|
|
201
|
-
# certificate, including (this node runs `ce.evolver.execution.enabled`) the SINGLE-CONSUMER
|
|
202
|
-
# cursor `GET /api/channel/next`, which a stranger can step past the registry's own deliveries.
|
|
203
|
-
#
|
|
204
|
-
# The claim being made here is the same one `ce.evolver.content.bind-host` and the p2p/login ones
|
|
205
|
-
# above make: THIS COMPOSE NETWORK IS THE BOUNDARY. It holds because 8440 is NOT published in
|
|
206
|
-
# docker-compose.yml and nothing uses host networking — the agent and the registry reach this
|
|
207
|
-
# node as `node:8440`, a service name on that network. Publish the port, or run with host
|
|
208
|
-
# networking, and the claim stops being true. The node cannot tell, so it logs a WARN naming it.
|
|
209
|
-
http.bind-host = "0.0.0.0"
|
|
210
|
-
https.port = 8441
|
|
211
|
-
evolver.grpc.port = 9000
|
|
212
|
-
|
|
213
|
-
# REQUIRED, and there is no default: `NodeBootstrap.initChainDb` reads this block
|
|
214
|
-
# directly, so a node without it dies with `No configuration setting found for key
|
|
215
|
-
# 'chainDb'` after the migrations have already run — which is exactly how this omission
|
|
216
|
-
# was found. Same values as the bundled deployment's node.
|
|
217
|
-
chainDb {
|
|
218
|
-
datasource {
|
|
219
|
-
driver = "org.hsqldb.jdbc.JDBCDriver"
|
|
220
|
-
user = "sa"
|
|
221
|
-
pass = ""
|
|
222
|
-
maxPoolSize = 5
|
|
223
|
-
connectionProperties = ""
|
|
224
|
-
cachePrepStmts = true
|
|
225
|
-
prepStmtCacheSize = 250
|
|
226
|
-
prepStmtCacheSqlLimit = 2048
|
|
227
|
-
connectionTimeout = 5000
|
|
228
|
-
useServerPrepStmts = false
|
|
229
|
-
transactionIsolationLevel = "TRANSACTION_READ_COMMITTED"
|
|
230
|
-
}
|
|
231
|
-
viewCachesSize = 50
|
|
232
|
-
useShutdownHook = false
|
|
233
|
-
freeBlobsEarly = false
|
|
234
|
-
}
|
|
6
|
+
ce.evolver.genesis-file = "/config/genesis.devnet.conf"
|
|
235
7
|
|
|
8
|
+
include required(file("/config/node-follower.conf"))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Local-chain wrapper: selects the committee-exported public genesis, then the follower body.
|
|
2
|
+
include file("/public-genesis/genesis.conf")
|
|
3
|
+
|
|
4
|
+
# 089 chain-spec pin: the canonical hash of the genesis file IS the genesis block id.
|
|
5
|
+
# Any drift refuses to boot rather than forking quietly.
|
|
6
|
+
ce.evolver.genesis-file = "/public-genesis/genesis.conf"
|
|
7
|
+
|
|
8
|
+
include required(file("/config/node-follower.conf"))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"operatorIdentity":"evolvermeerkatA","sdk":"0.
|
|
1
|
+
{"operatorIdentity":"evolvermeerkatA","devnet":{"origin":"https://devnet.mcsherrylabs.com","chainId":"c8925985ae7948d2e608efcf863928d15476c4b561ad773fdca6f0d53d4fea05","channel":"ce.meerkat.announcements","operator":"evolvermeerkatA483344","upstreamNodeId":"evolvermeerkatnode920080","daPeers":"schednode2","allowedOutbound":"2","genesisSha256":"7f6a95cd0fcbe2306d89b2426a9473b1c6537427a82feaffff4783d9cc5dc6b9"},"sdk":"0.24.0","bootstrap":"0.17.0"}
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// Explicit offline operator action. Never imported/called by ordinary startup.
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
|
-
import {
|
|
4
|
-
import { currentTarget } from "../src/env.js";
|
|
5
|
-
|
|
3
|
+
import { recoverManagedOwner } from "@mcsherrylabs/evolver-client/processor";
|
|
4
|
+
import { currentTarget, registryStatePath } from "../src/env.js";
|
|
5
|
+
/** Offline forced owner-lock recovery for the registry. Delegates to the SDK's
|
|
6
|
+
* `recoverManagedOwner`, which owns the usage error, the `[registry] ` prefix and the
|
|
7
|
+
* explicit-acknowledgement warning; only the state path is Meerkat's. The exported name is
|
|
8
|
+
* kept for evolver-mcp's fault fixture. */
|
|
6
9
|
export function recoverMeerkatOwner(args, target, say = console.error) {
|
|
7
|
-
|
|
8
|
-
throw new Error("usage: recover-owner --force (only after stopping ALL collectors and disabling automatic restarts)");
|
|
9
|
-
say("WARNING: --force acknowledges ALL collectors are stopped and automatic restarts disabled; this command cannot verify that.");
|
|
10
|
-
const result = recoverProcessorOwnerLock({ path: processorStatePath(target), force: true });
|
|
11
|
-
say(`${result.removed ? "Removed owner lock" : "No owner lock present"}: ${result.marker}. Database/WAL, progress, subscriptions and keys unchanged. Start ONE collector normally with the SAME target/state environment to acquire a new lock.`);
|
|
12
|
-
return result;
|
|
10
|
+
return recoverManagedOwner(args, { statePath: registryStatePath(target), service: "registry", say });
|
|
13
11
|
}
|
|
14
12
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
15
13
|
recoverMeerkatOwner(process.argv.slice(2), currentTarget());
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
// Explicit fresh-resource operation; NEVER imported/called by ordinary startup or upgrade.
|
|
2
2
|
import { chown, chmod, mkdir, readdir } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { initializeSubscriptions } from "@mcsherrylabs/evolver-client/processor";
|
|
5
|
-
import {
|
|
6
|
-
import { processorConfigPath } from "../src/runtime.js";
|
|
4
|
+
import { initializeSubscriptions, MANAGED_PROCESSOR_DEFAULTS } from "@mcsherrylabs/evolver-client/processor";
|
|
5
|
+
import { ROOT } from "../src/env.js";
|
|
7
6
|
const raw = process.env.EVOLVER_INITIAL_CHANNELS;
|
|
8
7
|
if (raw === undefined)
|
|
9
8
|
throw new Error("set EVOLVER_INITIAL_CHANNELS to an explicit JSON array (including []); initialization is fresh-only");
|
|
10
9
|
const channels = JSON.parse(raw);
|
|
11
10
|
if (!Array.isArray(channels) || channels.some(id => typeof id !== "string"))
|
|
12
11
|
throw new Error("EVOLVER_INITIAL_CHANNELS must be a JSON string array");
|
|
13
|
-
const path =
|
|
12
|
+
const path = MANAGED_PROCESSOR_DEFAULTS.paths.subscriptions;
|
|
14
13
|
const directory = dirname(path);
|
|
15
14
|
if (process.getuid?.() === 0) {
|
|
16
15
|
const uid = Number(process.env.EVOLVER_OPERATOR_UID), gid = Number(process.env.EVOLVER_OPERATOR_GID);
|
|
@@ -32,7 +32,7 @@ export function readEnv() {
|
|
|
32
32
|
continue; // empty ⇒ treat as missing, so the required-keys check reports it
|
|
33
33
|
out[t.slice(0, i).trim()] = v;
|
|
34
34
|
}
|
|
35
|
-
for (const k of ["EVOLVER_REGISTRY", "REGISTRY", "EVOLVER_VERSION", "APP_NAME", "APP_VERSION"]) {
|
|
35
|
+
for (const k of ["EVOLVER_REGISTRY", "REGISTRY", "EVOLVER_VERSION", "APP_NAME", "APP_VERSION", "EVOLVER_MCP_VERSION"]) {
|
|
36
36
|
if (!out[k])
|
|
37
37
|
throw new Error(`${ENV_PATH}: ${k} is missing — it is required, see the comments in that file`);
|
|
38
38
|
}
|
|
@@ -63,15 +63,10 @@ export function updateEnv(updates) {
|
|
|
63
63
|
}
|
|
64
64
|
export const appImage = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}:${e.APP_VERSION}`;
|
|
65
65
|
export const deployRef = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}-deploy:${e.APP_VERSION}`;
|
|
66
|
-
/** The
|
|
67
|
-
*
|
|
68
|
-
export const
|
|
69
|
-
/** The app PLUS ITS OWN NODE, that node joining devnet — from `docker-compose.devnet.yml`.
|
|
70
|
-
*
|
|
71
|
-
* Three refs because they are three different things to hand someone, and the difference is
|
|
72
|
-
* not cosmetic: one carries a throwaway chain, one borrows somebody else's node on a real
|
|
73
|
-
* chain, and one brings a node of its own onto that real chain (which is the only shape that
|
|
74
|
-
* can read an ordered channel stream or join a DA overlay). */
|
|
66
|
+
/** The evolver-mcp-node image the managed follower runs — a separate variable because it is the
|
|
67
|
+
* evolver-mcp project's registry/version, not this app's node image. */
|
|
68
|
+
export const mcpNodeImage = (e = readEnv()) => `${e.EVOLVER_REGISTRY}/evolver-mcp-node:${e.EVOLVER_MCP_VERSION}`;
|
|
69
|
+
/** The app PLUS ITS OWN NODE, that node joining devnet — from `docker-compose.devnet.yml`. */
|
|
75
70
|
export const devnetDeployRef = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}-deploy-devnet:${e.APP_VERSION}`;
|
|
76
71
|
/** Identity names are `^[A-Za-z0-9]{1,50}$` — no hyphens, dots or underscores — so an app
|
|
77
72
|
* name cannot be used verbatim. Strip it to what a node id may contain and bound it.
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// stores and floods the body it will later serve. This is why the group-chat deployment
|
|
31
31
|
// has a "DA group = the app nodes" step, and why an app cannot simply write to a channel
|
|
32
32
|
// it claimed with defaults.
|
|
33
|
-
import {
|
|
33
|
+
import { fString, fVarint, fMessage, concat } from "@mcsherrylabs/evolver-client";
|
|
34
34
|
/**
|
|
35
35
|
* The channel this app owns — ONE variable, read by the app AND by the node's
|
|
36
36
|
* `subscribed-channels` declaration in docker-compose.devnet.yml, because the two must agree
|
|
@@ -47,23 +47,6 @@ import { buildSignedTx, claimBody, channelBody, createGroupBody, addMemberBody,
|
|
|
47
47
|
* rather than a literal.
|
|
48
48
|
*/
|
|
49
49
|
export const CHANNEL = process.env.EVOLVER_CHANNEL?.trim() || "announcements1";
|
|
50
|
-
/**
|
|
51
|
-
* The DA threshold. ZERO — best-effort — because this app's DA group has ONE member.
|
|
52
|
-
*
|
|
53
|
-
* The scheduler's own validation states the rule and I had it wrong: at one DA node a
|
|
54
|
-
* threshold of 1 can never be satisfied, because a node is never its own flood target and
|
|
55
|
-
* does not self-attest, so a write needing one attestation waits for one that will not come
|
|
56
|
-
* and ends in `503 da_unavailable`.
|
|
57
|
-
*
|
|
58
|
-
* ⚠ And best-effort means what it says: one member is ONE VOLUME. Lose it and every message
|
|
59
|
-
* that member alone held is unrecoverable — and any future processing node declaring this
|
|
60
|
-
* channel WEDGES on the first such message, permanently, because apply is forward-only. That
|
|
61
|
-
* is not hypothetical: it is what happened to `announcements`, and it is why this app is on
|
|
62
|
-
* `announcements1` (evolver#83). Two members on separate volumes is the answer when the
|
|
63
|
-
* messages matter.
|
|
64
|
-
*/
|
|
65
|
-
export const DA_THRESHOLD_M = Number(process.env.EVOLVER_DA_THRESHOLD_M?.trim() || 0);
|
|
66
|
-
const utf8 = (s) => new TextEncoder().encode(s);
|
|
67
50
|
/** The channel's terms as a NAMED node sees them — pass the devnet url to ask the chain, or
|
|
68
51
|
* this app's node to ask its own cursor. The difference is the point (fact 2). */
|
|
69
52
|
export async function channelTerms(baseUrl, ledgerId = CHANNEL) {
|
|
@@ -111,27 +94,6 @@ export async function groupMembers(baseUrl, group) {
|
|
|
111
94
|
return { known: false };
|
|
112
95
|
}
|
|
113
96
|
}
|
|
114
|
-
/**
|
|
115
|
-
* This node's OWN on-chain identity, which the app needs because the DA group's member is the
|
|
116
|
-
* NODE, not the app.
|
|
117
|
-
*
|
|
118
|
-
* Read from `/status`, which is an HTML page — the honest state of things: a node reports its
|
|
119
|
-
* own identity to a human and has no machine-readable equivalent. A JSON status route would
|
|
120
|
-
* make this a question instead of a parse (see evolver#82, which asks for the same thing about
|
|
121
|
-
* readiness).
|
|
122
|
-
*/
|
|
123
|
-
export async function nodeSelfId(baseUrl) {
|
|
124
|
-
try {
|
|
125
|
-
const res = await fetch(`${baseUrl}/status`, { signal: AbortSignal.timeout(10_000) });
|
|
126
|
-
if (!res.ok)
|
|
127
|
-
return null;
|
|
128
|
-
const m = /<h1>\s*Node:\s*([A-Za-z0-9]+)\s*<\/h1>/.exec(await res.text());
|
|
129
|
-
return m ? m[1] : null;
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
97
|
/**
|
|
136
98
|
* Submit a write and then CONFIRM IT BY READING — the only reliable shape on a node whose
|
|
137
99
|
* apply this app paces (fact 3).
|
|
@@ -176,61 +138,6 @@ export async function submitAndConfirm(node, stx, label, confirm, say, opts) {
|
|
|
176
138
|
}
|
|
177
139
|
throw new Error(`${label}: not visible on chain within the deadline — outcome UNKNOWN, nothing resent`);
|
|
178
140
|
}
|
|
179
|
-
/**
|
|
180
|
-
* Create the DA group and put this app's NODE in it.
|
|
181
|
-
*
|
|
182
|
-
* Both steps are best-effort and SAY what happened, because neither is the gate: from run two
|
|
183
|
-
* onward "already exists" is the normal answer, and on a paced node a submit routinely returns
|
|
184
|
-
* `timeout` and commits anyway. The real gate is the CLAIM below — it cannot succeed unless the
|
|
185
|
-
* group exists with this node in it, so a silent failure here surfaces there rather than
|
|
186
|
-
* passing unnoticed.
|
|
187
|
-
*/
|
|
188
|
-
export async function ensureDaGroup(node, baseUrl, owner, signer, group, memberNodeId, say, pace) {
|
|
189
|
-
say(` DA group '${group}', member '${memberNodeId}' — the NODE's identity, not the app's`);
|
|
190
|
-
// READ FIRST, AND SUBMIT ONLY WHAT IS MISSING.
|
|
191
|
-
//
|
|
192
|
-
// This used to submit CreateGroup and AddMember unconditionally on every run. A CreateGroup
|
|
193
|
-
// for a name that already exists is not simply refused: the node ACCEPTS it, then rejects it
|
|
194
|
-
// PRE-COMMIT every round ("name 'x' taken by group") and RETAINS it, re-proposing until its
|
|
195
|
-
// TTL. On the live devnet that did two things — three rejection warnings per round in the
|
|
196
|
-
// leader's log, and, far worse, it kept the tx queue permanently non-empty, which SUPPRESSES
|
|
197
|
-
// wake-on-work for the whole chain (evolver#85: the wake fires only on the queue's empty →
|
|
198
|
-
// non-empty edge). Every submitter, ours and everyone else's, then waited up to the
|
|
199
|
-
// five-minute idle heartbeat — which is also what burned an identity name through
|
|
200
|
-
// evolver#86. A pointless resubmission is not free, and on a shared chain it is not local.
|
|
201
|
-
const before = await groupMembers(baseUrl, group);
|
|
202
|
-
if (before.known && "members" in before && before.members.includes(memberNodeId)) {
|
|
203
|
-
say(` group '${group}' exists with '${memberNodeId}' as a member — nothing to submit`);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (!before.known) {
|
|
207
|
-
throw new Error(`could not read group '${group}' from ${baseUrl} — refusing to guess: ` +
|
|
208
|
-
`submitting a CreateGroup for a name that already exists poisons the ` +
|
|
209
|
-
`chain's tx queue (evolver#85), so this needs an answer, not an attempt.`);
|
|
210
|
-
}
|
|
211
|
-
const steps = [];
|
|
212
|
-
if ("absent" in before) {
|
|
213
|
-
steps.push([`create group '${group}'`, createGroupBody(group),
|
|
214
|
-
async () => { const g = await groupMembers(baseUrl, group); return g.known && !("absent" in g); }]);
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
say(` group '${group}' already exists — adding the member only`);
|
|
218
|
-
}
|
|
219
|
-
steps.push([`add '${memberNodeId}' to '${group}'`, addMemberBody(group, memberNodeId),
|
|
220
|
-
async () => {
|
|
221
|
-
const g = await groupMembers(baseUrl, group);
|
|
222
|
-
return g.known && "members" in g && g.members.includes(memberNodeId);
|
|
223
|
-
}]);
|
|
224
|
-
for (const [label, body, confirm] of steps) {
|
|
225
|
-
const stx = buildSignedTx({
|
|
226
|
-
ledgerId: LEDGER.groups, senderNodeId: owner.nodeId, body,
|
|
227
|
-
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
228
|
-
});
|
|
229
|
-
// Confirmed by READING, like every other write here: a submit that times out has an
|
|
230
|
-
// unknown outcome, and on a quiescent chain the block is minutes away.
|
|
231
|
-
await submitAndConfirm(node, stx, label, confirm, say, { pace });
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
141
|
/**
|
|
235
142
|
* `UpdateChannelDaTxBody` (097) — OwnersTxBody variant 4.
|
|
236
143
|
*
|
|
@@ -251,198 +158,3 @@ export function updateChannelDaBody(args) {
|
|
|
251
158
|
parts.push(...fVarint(3, args.daThresholdM));
|
|
252
159
|
return concat(fMessage(4, concat(parts)));
|
|
253
160
|
}
|
|
254
|
-
/**
|
|
255
|
-
* Make the channel exist, owned by us, WITH a DA group — the only shape that accepts a
|
|
256
|
-
* body-form write (fact 4).
|
|
257
|
-
*
|
|
258
|
-
* Idempotent, and the three cases are genuinely different: nothing claimed (claim it, carrying
|
|
259
|
-
* the DA policy), claimed by us with the WRONG DA policy (converge it in place with
|
|
260
|
-
* `UpdateChannelDa`), claimed by somebody else (stop: that is a real conflict on a shared
|
|
261
|
-
* chain).
|
|
262
|
-
*/
|
|
263
|
-
export async function ensureChannelWithDa(node, chainUrl, owner, signer, group, say, pace) {
|
|
264
|
-
const ask = () => channelTerms(chainUrl);
|
|
265
|
-
const read = await ask();
|
|
266
|
-
if (!read.ok)
|
|
267
|
-
throw new Error(`could not read the terms of '${CHANNEL}' from ${chainUrl}: ${read.why}\n` +
|
|
268
|
-
` Refusing to guess: an unreadable answer used to mean "unclaimed", and acting on ` +
|
|
269
|
-
`that submits a Claim for a channel someone else may own — accepted, then rejected ` +
|
|
270
|
-
`every round until its TTL (evolver#85).`);
|
|
271
|
-
const terms = read.terms;
|
|
272
|
-
const ownedByUs = terms.owners.some((o) => o.name === owner.nodeId);
|
|
273
|
-
const someoneElse = terms.owners.length > 0 && !ownedByUs;
|
|
274
|
-
// A CHANNEL NAME IS GLOBAL ON A SHARED CHAIN, and whoever claims it owns it. A second
|
|
275
|
-
// deployment of this app — new identities, same channel name — is therefore a READER, not a
|
|
276
|
-
// broken installation: it can declare the channel, subscribe, and fold the ordered stream,
|
|
277
|
-
// which is most of what an announcements channel is for. Only the owner may write (the
|
|
278
|
-
// default write policy is owner-or-group; 156 lets an owner open it to any writer).
|
|
279
|
-
//
|
|
280
|
-
// Dying here instead would be wrong in the ordinary case and would make the name a
|
|
281
|
-
// first-come landmine.
|
|
282
|
-
if (someoneElse) {
|
|
283
|
-
say(` '${CHANNEL}' is owned by ${terms.owners.map((o) => o.name).join(", ")} — this app will READ it, not write`);
|
|
284
|
-
return "reader";
|
|
285
|
-
}
|
|
286
|
-
// THE POLICY MUST MATCH, not merely exist — and "wrong" includes a threshold this app now
|
|
287
|
-
// knows is unsatisfiable. A mismatch is converged with `UpdateChannelDa`, in place; a second
|
|
288
|
-
// Claim would not do it (on an already-claimed ledger a Claim is a committed NO-OP).
|
|
289
|
-
//
|
|
290
|
-
// Measured on the deployment: `announcements1` was claimed at m=1 with a single-member
|
|
291
|
-
// group, this function said "already claimed by us with a DA group — reusing", and the very
|
|
292
|
-
// next write died with `fetch failed` — the attestation that can never arrive, waited out
|
|
293
|
-
// until the connection dropped. Checking only for PRESENCE is what let that through twice.
|
|
294
|
-
const daOk = !!terms.da && terms.da.group === group && terms.da.thresholdM === DA_THRESHOLD_M;
|
|
295
|
-
if (ownedByUs && daOk) {
|
|
296
|
-
say(` '${CHANNEL}' already claimed by us with DA group '${group}' (m=${DA_THRESHOLD_M}) — reusing`);
|
|
297
|
-
return "owner";
|
|
298
|
-
}
|
|
299
|
-
if (ownedByUs && !daOk) {
|
|
300
|
-
const had = terms.da ? `DA group '${terms.da.group}' (m=${terms.da.thresholdM})` : "NO DA group";
|
|
301
|
-
say(` '${CHANNEL}' is claimed by us with ${had}, but this app needs '${group}' (m=${DA_THRESHOLD_M})`);
|
|
302
|
-
// ⚠️ A TARGETED UPDATE, NOT UNCLAIM-THEN-RECLAIM.
|
|
303
|
-
//
|
|
304
|
-
// This used to drop the claim and re-claim, on the belief that a DA policy is set at claim
|
|
305
|
-
// time and cannot be changed. That belief is wrong — `UpdateChannelDa` (097) exists and is
|
|
306
|
-
// owner-gated (OwnersLedgerOrchestrator validates it and sets the policy in place) — and
|
|
307
|
-
// the belief made the app do something irreversible to fix something reversible:
|
|
308
|
-
// `clearChannelPolicies` drops all three policy rows, and BETWEEN the unclaim and the
|
|
309
|
-
// re-claim the channel name is UNOWNED, so on a shared chain anyone can claim it and the
|
|
310
|
-
// name is lost for good. A second Claim would not even have converged it: on an
|
|
311
|
-
// already-claimed ledger a Claim is a committed NO-OP, which is why the unclaim was there
|
|
312
|
-
// at all. evolver-scheduler's provisioner had this right first.
|
|
313
|
-
say(` converging in place with UpdateChannelDa — an unclaim would expose the name to being taken`);
|
|
314
|
-
const upd = buildSignedTx({
|
|
315
|
-
ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
|
|
316
|
-
body: updateChannelDaBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
|
|
317
|
-
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
318
|
-
});
|
|
319
|
-
await submitAndConfirm(node, upd, `update '${CHANNEL}' DA policy to '${group}' (m=${DA_THRESHOLD_M})`, async () => {
|
|
320
|
-
const r = await ask();
|
|
321
|
-
return r.ok && !!r.terms.da && r.terms.da.group === group &&
|
|
322
|
-
r.terms.da.thresholdM === DA_THRESHOLD_M;
|
|
323
|
-
}, say, { pace });
|
|
324
|
-
return "owner";
|
|
325
|
-
}
|
|
326
|
-
// Nothing owns it: claim it, carrying the DA policy in the claim itself.
|
|
327
|
-
const claim = buildSignedTx({
|
|
328
|
-
ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
|
|
329
|
-
body: claimBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
|
|
330
|
-
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
331
|
-
});
|
|
332
|
-
await submitAndConfirm(node, claim, `claim '${CHANNEL}' with DA group '${group}' (m=${DA_THRESHOLD_M})`, async () => {
|
|
333
|
-
const r = await ask();
|
|
334
|
-
return r.ok && !!r.terms.da && r.terms.da.group === group &&
|
|
335
|
-
r.terms.da.thresholdM === DA_THRESHOLD_M &&
|
|
336
|
-
r.terms.owners.some((o) => o.name === owner.nodeId);
|
|
337
|
-
}, say, { pace });
|
|
338
|
-
return "owner";
|
|
339
|
-
}
|
|
340
|
-
/** Write one announcement. Plain UTF-8: this channel is public by intent. */
|
|
341
|
-
export async function announce(node, from, signer, text, say) {
|
|
342
|
-
const stx = buildSignedTx({
|
|
343
|
-
ledgerId: CHANNEL, senderNodeId: from.nodeId,
|
|
344
|
-
body: channelBody(utf8(text)), ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
345
|
-
});
|
|
346
|
-
// submitTxWithDa: the node collects the channel's DA attestations over the overlay and
|
|
347
|
-
// assembles the transaction. A client cannot do that for itself — it has no overlay.
|
|
348
|
-
const r = await node.submitTxWithDa(stx);
|
|
349
|
-
if (r.kind === "committed") {
|
|
350
|
-
say(` announced: "${text}"`);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
if (r.kind === "timeout") {
|
|
354
|
-
say(` announce: submit timed out — the stream below is the confirmation`);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
// A duplicate is not a failure of this app: the txId is a function of the body, the sender
|
|
358
|
-
// and the TTL, so an identical announcement that already committed answers `duplicate` —
|
|
359
|
-
// which means the message IS on the channel, which is exactly what the stream below checks.
|
|
360
|
-
if (r.kind === "failed" && r.reason === "duplicate") {
|
|
361
|
-
say(` announce: this exact message is already on the chain — the stream below is the confirmation`);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
throw new Error(`the announcement was refused: ${JSON.stringify(r)}\n` +
|
|
365
|
-
` insufficient_da means the DA group could not be mustered — is this app's node a member?\n` +
|
|
366
|
-
` out-of-band means the channel has no DA group at all (see ensureChannelWithDa).`);
|
|
367
|
-
}
|
|
368
|
-
// The demo reads the shared collector's durable inbox. There is no local handler map,
|
|
369
|
-
// volatile receive buffer, cursor probe or second consumer of the channel route.
|
|
370
|
-
export async function retainedMessages(store, channel) {
|
|
371
|
-
const messages = [];
|
|
372
|
-
let after;
|
|
373
|
-
do {
|
|
374
|
-
const page = await store.inboxPage(channel, { limit: 100, after });
|
|
375
|
-
for (const entry of page.entries)
|
|
376
|
-
if (entry.body !== null)
|
|
377
|
-
messages.push(new TextDecoder().decode(entry.body));
|
|
378
|
-
if (!page.more)
|
|
379
|
-
return messages;
|
|
380
|
-
if (!page.next || (after && (BigInt(page.next.round) < BigInt(after.round) ||
|
|
381
|
-
(page.next.round === after.round && BigInt(page.next.index) <= BigInt(after.index)))))
|
|
382
|
-
throw new Error("inbox readback did not advance its continuation");
|
|
383
|
-
after = page.next;
|
|
384
|
-
} while (true);
|
|
385
|
-
}
|
|
386
|
-
/** Pace the processor forward until this node stops making progress — which is how a processing
|
|
387
|
-
* node applies blocks at all (fact 2). Anything of ours it passes on the way is KEPT. */
|
|
388
|
-
export async function paceApply(pace, say) {
|
|
389
|
-
const MAX = 200;
|
|
390
|
-
// A walk stops at its budget whether or not the cursor reached the tip, so the report
|
|
391
|
-
// distinguishes the two. Claiming "to its tip" after a truncated walk is a statement the code
|
|
392
|
-
// cannot make — a backlog longer than MAX leaves apply behind, and the reads that follow would
|
|
393
|
-
// silently answer as of an earlier round.
|
|
394
|
-
let walked = 0;
|
|
395
|
-
let caughtUp = false;
|
|
396
|
-
for (let attempts = 0; attempts < 10 && walked < MAX; attempts++) {
|
|
397
|
-
const r = await pace();
|
|
398
|
-
walked += r.inboxed + r.missed + r.duplicates;
|
|
399
|
-
if (r.stoppedBecause === "body-pending")
|
|
400
|
-
throw new Error("node is waiting for a channel body; nothing skipped");
|
|
401
|
-
if (r.stoppedBecause === "caught-up") {
|
|
402
|
-
caughtUp = true;
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (!caughtUp)
|
|
407
|
-
say(` paced this node's apply, stopping at ${walked} delivery(ies) — the backlog is longer, so apply may still be behind`);
|
|
408
|
-
else
|
|
409
|
-
say(` paced this node's apply to its tip; original deliveries are retained in the inbox`);
|
|
410
|
-
}
|
|
411
|
-
/** Pacing with no narration, for use inside a wait loop. Keeps what it walks past, as above. */
|
|
412
|
-
export async function paceQuietly(pace) {
|
|
413
|
-
await pace();
|
|
414
|
-
}
|
|
415
|
-
/**
|
|
416
|
-
* Follow the stream until our own message arrives.
|
|
417
|
-
*
|
|
418
|
-
* The delivery is the app's write coming back THROUGH CONSENSUS, which is the only evidence
|
|
419
|
-
* that matters: a submit says the transaction was ordered; a delivery says the app can read
|
|
420
|
-
* what was ordered, in order, from its own node.
|
|
421
|
-
*
|
|
422
|
-
* A submit that timed out has an UNKNOWN outcome and the block carrying it can be minutes away
|
|
423
|
-
* on a quiescent chain, so this waits to a DEADLINE rather than for a fixed number of idle polls
|
|
424
|
-
* (20s of idle was the old bound, and it expired before the block existed). Nothing is ever
|
|
425
|
-
* resent — the transaction id is fixed, so waiting is the only correct move.
|
|
426
|
-
*/
|
|
427
|
-
export async function readBack(pace, want, say, opts) {
|
|
428
|
-
// ⚠️ A CALLER THAT CANNOT MATCH MUST PASS ITS OWN DEADLINE. This waits for a specific
|
|
429
|
-
// message, so with a sentinel that never matches it waits out the whole budget — five
|
|
430
|
-
// minutes of "following the stream" for the reader-mode caller, which only wants to watch
|
|
431
|
-
// briefly. That regression arrived with the deadline itself, from changing this function's
|
|
432
|
-
// contract without revisiting its second call site.
|
|
433
|
-
const deadline = Date.now() + (opts.deadlineMs ?? 300_000);
|
|
434
|
-
let received = [...await opts.messages()];
|
|
435
|
-
while (!received.some((t) => t.includes(want)) && Date.now() < deadline) {
|
|
436
|
-
// Pending/quiet is an outcome; storage, module and transport failures are errors. Never
|
|
437
|
-
// disguise one as an empty read or a successful wait. The app's finally releases its owner.
|
|
438
|
-
const r = await pace();
|
|
439
|
-
received = [...await opts.messages()];
|
|
440
|
-
if ((r.stoppedBecause === "caught-up" || r.stoppedBecause === "body-pending") && !received.some(t => t.includes(want)))
|
|
441
|
-
await new Promise(res => setTimeout(res, 500));
|
|
442
|
-
}
|
|
443
|
-
const found = received.some((t) => t.includes(want));
|
|
444
|
-
say(` retained ${received.length} message(s) on '${CHANNEL}'`);
|
|
445
|
-
return { seen: [...received], found };
|
|
446
|
-
}
|
|
447
|
-
// Only the node's body-unavailable outcome permits advancement. No application timer
|
|
448
|
-
// converts pending/unreadable outcomes into absence; the shared collector owns that rule.
|