@mcsherrylabs/pi-meerkat 0.7.0 → 0.10.1
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 +109 -33
- package/dist/installer/committee-init.sh +117 -0
- package/dist/installer/committee.conf +102 -0
- package/dist/installer/genesis.devnet.conf +92 -0
- package/dist/installer/node-join.sh +235 -0
- package/dist/installer/node.conf +238 -0
- package/dist/installer/node.devnet.conf +238 -0
- package/dist/installer/runtime.json +1 -0
- package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
- package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
- package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
- package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
- package/dist/installer-runtime/dist/src/announcements.js +448 -0
- package/dist/installer-runtime/dist/src/app.js +552 -0
- package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
- package/dist/installer-runtime/dist/src/env.js +437 -0
- package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
- package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
- package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
- package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
- package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
- package/dist/installer-runtime/dist/src/registry/http.js +194 -0
- package/dist/installer-runtime/dist/src/registry/index.js +61 -0
- package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
- package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
- package/dist/installer-runtime/dist/src/registry/provision.js +225 -0
- package/dist/installer-runtime/dist/src/registry/store.js +242 -0
- package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
- package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
- package/dist/installer-runtime/dist/src/runtime.js +192 -0
- package/dist/installer-runtime/dist/src/store.js +138 -0
- package/dist/installer-runtime/package.json +1 -0
- package/dist/installer.d.ts +401 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +446 -0
- package/dist/installer.js.map +1 -0
- 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 +41 -5
- 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/registry.d.ts.map +1 -1
- package/dist/registry.js +8 -4
- package/dist/registry.js.map +1 -1
- package/dist/tools.js +4 -4
- package/dist/tools.js.map +1 -1
- package/installer/Committee.Dockerfile +6 -0
- package/installer/Node.Dockerfile +10 -0
- package/installer/Registry.Dockerfile +13 -0
- package/installer/runtime-control.mjs +335 -0
- package/installer/session-files.mjs +16 -0
- package/package.json +8 -7
- package/skills/channel-subscription/SKILL.md +3 -3
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// ── the announcements channel: claim it (with DA), write to it, read it back ─
|
|
2
|
+
//
|
|
3
|
+
// This is the part the template leaves out, and the reason this app runs a node of its own.
|
|
4
|
+
//
|
|
5
|
+
// A CHANNEL is an owned ledger id that the quorum orders OPAQUELY: the committee agrees the
|
|
6
|
+
// order of the messages and never sees inside them. The app defines what a message means and
|
|
7
|
+
// derives its own state by folding the ordered stream, read from a node with
|
|
8
|
+
// `GET /api/channel/next`. Four facts, each of them established by running this against a live
|
|
9
|
+
// devnet rather than by reading:
|
|
10
|
+
//
|
|
11
|
+
// 1. THE CURSOR IS SINGLE-CONSUMER AND SERVED FROM THE NODE'S OWN POSITION. The (round,index)
|
|
12
|
+
// a client asks for is a FLOOR, not an address: the node serves the next declared-channel
|
|
13
|
+
// tx at or after its own cursor, applies everything in between, and advances atomically.
|
|
14
|
+
// So `{0,0}` means "whatever is next" — and HISTORY CANNOT BE REPLAYED. A caught-up node
|
|
15
|
+
// answers `not-ready` to `?round=0&index=0`, having passed it.
|
|
16
|
+
// 2. POLLING IS WHAT MAKES A PROCESSING NODE APPLY BLOCKS AT ALL. Apply is deferred and
|
|
17
|
+
// paced by these polls, so committed reads answer "as of MY cursor" — an unpolled node is
|
|
18
|
+
// a stopped node. Measured twice: a node whose store was level with devnet answered
|
|
19
|
+
// `Unknown identity` about ITSELF, and this channel read `owners: []` for a channel devnet
|
|
20
|
+
// already showed as claimed.
|
|
21
|
+
// 3. EVERY WRITE HERE TIMES OUT AND THEN COMMITS. The submit's commit-wait fires on apply,
|
|
22
|
+
// which this app paces — so `?wait=true` routinely expires while the transaction is
|
|
23
|
+
// ordered anyway. A write is therefore SUBMITTED and then CONFIRMED BY READING, never
|
|
24
|
+
// resent (identical bytes are refused as a duplicate; a rebuilt transaction is a second
|
|
25
|
+
// transaction). `submitAndConfirm` is that pattern, once, for every write below.
|
|
26
|
+
// 4. A CHANNEL WITH NO DA GROUP CANNOT TAKE A BODY-FORM WRITE. The node refuses it —
|
|
27
|
+
// "channel 'x' is out-of-band — submit the id-only form (the node does not disseminate
|
|
28
|
+
// its body)" — because without a DA group nothing will carry the body to anyone. So the
|
|
29
|
+
// channel is claimed WITH a DA group whose member is this app's own node, and that node
|
|
30
|
+
// stores and floods the body it will later serve. This is why the group-chat deployment
|
|
31
|
+
// has a "DA group = the app nodes" step, and why an app cannot simply write to a channel
|
|
32
|
+
// it claimed with defaults.
|
|
33
|
+
import { buildSignedTx, claimBody, channelBody, createGroupBody, addMemberBody, fString, fVarint, fMessage, concat, LEDGER, } from "@mcsherrylabs/evolver-client";
|
|
34
|
+
/**
|
|
35
|
+
* The channel this app owns — ONE variable, read by the app AND by the node's
|
|
36
|
+
* `subscribed-channels` declaration in docker-compose.devnet.yml, because the two must agree
|
|
37
|
+
* or the write succeeds and the stream stays silent.
|
|
38
|
+
*
|
|
39
|
+
* Overridable, and that is not decoration: a channel can become UNUSABLE through no fault of
|
|
40
|
+
* this app. A message whose body no live node holds stalls a processing node's apply at that
|
|
41
|
+
* position for as long as its consumer keeps asking for that position (evolver#83, as
|
|
42
|
+
* corrected: the NODE is not wedged — asking for index+1 walks past it and the skipped tx is
|
|
43
|
+
* still applied in the slice; what is permanent is the LOSS of that message). The ways past it
|
|
44
|
+
* are to walk past it and lose it, or to stop declaring that channel — an undeclared channel's
|
|
45
|
+
* tx is applied in the slice and never offered, so the node walks straight past it. `announcements` was poisoned exactly that way: its DA group had one
|
|
46
|
+
* member, and that member's volume was destroyed. Hence `announcements1`, and hence a knob
|
|
47
|
+
* rather than a literal.
|
|
48
|
+
*/
|
|
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
|
+
/** The channel's terms as a NAMED node sees them — pass the devnet url to ask the chain, or
|
|
68
|
+
* this app's node to ask its own cursor. The difference is the point (fact 2). */
|
|
69
|
+
export async function channelTerms(baseUrl, ledgerId = CHANNEL) {
|
|
70
|
+
try {
|
|
71
|
+
// A DEADLINE, because a read with no deadline is not a read. Without this the confirm
|
|
72
|
+
// loop below ran three times past its own 300-second budget on a node that had stopped
|
|
73
|
+
// answering this route — a hang, reported as nothing at all.
|
|
74
|
+
const res = await fetch(`${baseUrl}/api/explorer/channels/${encodeURIComponent(ledgerId)}`, { signal: AbortSignal.timeout(10_000) });
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
return { ok: false, why: `HTTP ${res.status}` };
|
|
77
|
+
return { ok: true, terms: (await res.json()) };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return { ok: false, why: e instanceof Error ? e.message : String(e) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The members of a group, or the fact that there is no such group — and those are DIFFERENT
|
|
85
|
+
* from "could not tell", which is why this returns three things and not two.
|
|
86
|
+
*
|
|
87
|
+
* It matters because the caller's next move on a wrong answer is a CreateGroup for a name that
|
|
88
|
+
* already exists, and that transaction is not merely refused: it is rejected pre-commit and
|
|
89
|
+
* RETAINED, re-proposed every round until its TTL. See ensureDaGroup.
|
|
90
|
+
*
|
|
91
|
+
* `/api/explorer/groups/<g>/members` answers 200 with the member list, or 404 `Unknown group`.
|
|
92
|
+
*/
|
|
93
|
+
export async function groupMembers(baseUrl, group) {
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetch(`${baseUrl}/api/explorer/groups/${encodeURIComponent(group)}/members`, { signal: AbortSignal.timeout(10_000) });
|
|
96
|
+
// A 404 is evidence of absence only if it came from THIS route. A node too old to serve
|
|
97
|
+
// it answers 404 for the PATH, and reading that as "no such group" is how a CreateGroup
|
|
98
|
+
// for an existing name gets sent — the very thing this reader exists to prevent.
|
|
99
|
+
if (res.status === 404) {
|
|
100
|
+
const body = await res.text().catch(() => "");
|
|
101
|
+
if (/unknown group/i.test(body))
|
|
102
|
+
return { known: true, absent: true };
|
|
103
|
+
return { known: false };
|
|
104
|
+
}
|
|
105
|
+
if (!res.ok)
|
|
106
|
+
return { known: false };
|
|
107
|
+
const body = (await res.json());
|
|
108
|
+
return { known: true, members: body.members ?? [] };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { known: false };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
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
|
+
/**
|
|
136
|
+
* Submit a write and then CONFIRM IT BY READING — the only reliable shape on a node whose
|
|
137
|
+
* apply this app paces (fact 3).
|
|
138
|
+
*
|
|
139
|
+
* `confirm` is a question about committed state, asked of this node, and it is asked only
|
|
140
|
+
* after pacing the cursor forward: the answer cannot change until the node has applied the
|
|
141
|
+
* block. Nothing is ever resent.
|
|
142
|
+
*/
|
|
143
|
+
export async function submitAndConfirm(node, stx, label, confirm, say, opts) {
|
|
144
|
+
const r = opts.da ? await node.submitTxWithDa(stx) : await node.submitTx(stx);
|
|
145
|
+
if (r.kind === "committed") {
|
|
146
|
+
say(` ${label}: committed`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (r.kind === "failed") {
|
|
150
|
+
// A duplicate is not a failure of this app: the write is already on the chain, which is
|
|
151
|
+
// exactly what `confirm` is about to establish.
|
|
152
|
+
if (r.reason !== "duplicate") {
|
|
153
|
+
throw new Error(`${label} refused: ${JSON.stringify(r)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
say(` ${label}: submit returned '${r.kind === "failed" ? r.reason : r.kind}' — confirming by reading (nothing resent)`);
|
|
157
|
+
// Default five minutes: on a cold node the first writes wait for the ROUTER to drain its
|
|
158
|
+
// sync backlog before anything is forwarded, and no API reports when that is (evolver#82).
|
|
159
|
+
const deadline = Date.now() + (opts.deadlineMs ?? 300_000);
|
|
160
|
+
while (Date.now() < deadline) {
|
|
161
|
+
// Both legs are wrapped: a failed pace or a failed read is "not yet", never "failed".
|
|
162
|
+
// The loop's contract is that only the DEADLINE ends it, and a raw throw from either
|
|
163
|
+
// would have escaped as a fetch error while the transaction was still perfectly fine.
|
|
164
|
+
try {
|
|
165
|
+
await opts.pace();
|
|
166
|
+
}
|
|
167
|
+
catch { /* transient — try again below */ }
|
|
168
|
+
try {
|
|
169
|
+
if (await confirm()) {
|
|
170
|
+
say(` ${label}: confirmed on chain`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch { /* an unreadable answer is not a confirmation */ }
|
|
175
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`${label}: not visible on chain within the deadline — outcome UNKNOWN, nothing resent`);
|
|
178
|
+
}
|
|
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
|
+
/**
|
|
235
|
+
* `UpdateChannelDaTxBody` (097) — OwnersTxBody variant 4.
|
|
236
|
+
*
|
|
237
|
+
* ⚠️ HAND-ROLLED BECAUSE THE SDK DOES NOT HAVE IT. `@mcsherrylabs/evolver-client` exports
|
|
238
|
+
* `claimBody`, `updateChannelTollBody` and `updateChannelWritePolicyBody` but no DA
|
|
239
|
+
* equivalent. Taken VERBATIM from evolver-scheduler's provisioner, which needed it first —
|
|
240
|
+
* copied rather than re-derived, because a second hand-written encoding of the same fields is
|
|
241
|
+
* a second thing to get wrong. Built from the SDK's own field writers, so only the field
|
|
242
|
+
* layout is restated: owners.proto (1 ledger_id, 2 da_group, 3 da_threshold_m) and the oneof
|
|
243
|
+
* tag update_channel_da = 4.
|
|
244
|
+
*
|
|
245
|
+
* A BODY, not a signing digest, so Constitution X does not apply — the digest folds
|
|
246
|
+
* SHA-256(tx_body) as a bytes read. Worth an SDK addition all the same.
|
|
247
|
+
*/
|
|
248
|
+
export function updateChannelDaBody(args) {
|
|
249
|
+
const parts = [...fString(1, args.ledgerId), ...fString(2, args.daGroup)];
|
|
250
|
+
if (args.daThresholdM !== 0)
|
|
251
|
+
parts.push(...fVarint(3, args.daThresholdM));
|
|
252
|
+
return concat(fMessage(4, concat(parts)));
|
|
253
|
+
}
|
|
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.
|