@mcsherrylabs/pi-meerkat 0.6.0 → 0.8.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.
Files changed (67) hide show
  1. package/README.md +234 -54
  2. package/dist/installer/committee-init.sh +117 -0
  3. package/dist/installer/committee.conf +102 -0
  4. package/dist/installer/node-join.sh +228 -0
  5. package/dist/installer/node.conf +235 -0
  6. package/dist/installer/runtime.json +1 -0
  7. package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
  8. package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
  9. package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
  10. package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
  11. package/dist/installer-runtime/dist/src/announcements.js +448 -0
  12. package/dist/installer-runtime/dist/src/app.js +539 -0
  13. package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
  14. package/dist/installer-runtime/dist/src/env.js +425 -0
  15. package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
  16. package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
  17. package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
  18. package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
  19. package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
  20. package/dist/installer-runtime/dist/src/registry/http.js +194 -0
  21. package/dist/installer-runtime/dist/src/registry/index.js +52 -0
  22. package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
  23. package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
  24. package/dist/installer-runtime/dist/src/registry/provision.js +180 -0
  25. package/dist/installer-runtime/dist/src/registry/store.js +242 -0
  26. package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
  27. package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
  28. package/dist/installer-runtime/dist/src/runtime.js +164 -0
  29. package/dist/installer-runtime/dist/src/store.js +138 -0
  30. package/dist/installer-runtime/package.json +1 -0
  31. package/dist/installer.d.ts +224 -0
  32. package/dist/installer.d.ts.map +1 -0
  33. package/dist/installer.js +336 -0
  34. package/dist/installer.js.map +1 -0
  35. package/dist/mcp-announce.d.ts +42 -0
  36. package/dist/mcp-announce.d.ts.map +1 -0
  37. package/dist/mcp-announce.js +231 -0
  38. package/dist/mcp-announce.js.map +1 -0
  39. package/dist/mcp-cli.d.ts +3 -0
  40. package/dist/mcp-cli.d.ts.map +1 -0
  41. package/dist/mcp-cli.js +137 -0
  42. package/dist/mcp-cli.js.map +1 -0
  43. package/dist/mcp-config.d.ts +28 -0
  44. package/dist/mcp-config.d.ts.map +1 -0
  45. package/dist/mcp-config.js +104 -0
  46. package/dist/mcp-config.js.map +1 -0
  47. package/dist/mcp-facade.d.ts +20 -0
  48. package/dist/mcp-facade.d.ts.map +1 -0
  49. package/dist/mcp-facade.js +200 -0
  50. package/dist/mcp-facade.js.map +1 -0
  51. package/dist/mcp-upstream.d.ts +42 -0
  52. package/dist/mcp-upstream.d.ts.map +1 -0
  53. package/dist/mcp-upstream.js +151 -0
  54. package/dist/mcp-upstream.js.map +1 -0
  55. package/dist/registry.d.ts.map +1 -1
  56. package/dist/registry.js +11 -5
  57. package/dist/registry.js.map +1 -1
  58. package/dist/tools.d.ts +6 -2
  59. package/dist/tools.d.ts.map +1 -1
  60. package/dist/tools.js +123 -119
  61. package/dist/tools.js.map +1 -1
  62. package/installer/Committee.Dockerfile +6 -0
  63. package/installer/Node.Dockerfile +6 -0
  64. package/installer/Registry.Dockerfile +13 -0
  65. package/installer/runtime-control.mjs +172 -0
  66. package/installer/session-files.mjs +16 -0
  67. package/package.json +14 -5
@@ -0,0 +1,90 @@
1
+ // Wait until the target's node is up AND its chain is actually committing.
2
+ //
3
+ // "The container is running" is not the same as "the chain is usable": the node generates
4
+ // keys, writes genesis, migrates two databases and starts consensus before it will accept a
5
+ // transaction. Polling for a COMMITTED round is the only honest readiness signal — a node
6
+ // that answers /status can still refuse every write with `not-ready`.
7
+ //
8
+ // It resolves the node URL through src/env.ts, the SAME place the app does. It used to read
9
+ // EVOLVER_NODE directly and default to localhost, which made it target-BLIND: the published
10
+ // image runs this before the app, so `docker run -e EVOLVER_TARGET=devnet <image>` polled
11
+ // its own localhost for 180 s and exited before the app ever started.
12
+ //
13
+ // An OPTIONAL argument PINS the chain, and the local-chain scripts pass `local`. They have
14
+ // to: once the default chain can be a remote one, a bare currentTarget() here makes
15
+ // `npm run chain:up` boot the local node and then report the REMOTE chain's tip as its
16
+ // readiness — green while nothing local is up at all. The no-argument form is still what the
17
+ // container runs, where the environment names the chain and the answer must follow it.
18
+ import { currentTarget, isTargetName, targetByName, knownTargets, optionalEnv, TargetError } from "../src/env.js";
19
+ const want = process.argv[2];
20
+ let target;
21
+ try {
22
+ if (want && !isTargetName(want)) {
23
+ console.error(`unknown chain '${want}' — available by name: ${knownTargets().map((t) => t.name).join(", ")}`);
24
+ process.exit(1);
25
+ }
26
+ target = want ? targetByName(want) : currentTarget();
27
+ }
28
+ catch (e) {
29
+ if (e instanceof TargetError) {
30
+ console.error(`\n✗ ${e.message}\n`);
31
+ process.exit(1);
32
+ }
33
+ throw e;
34
+ }
35
+ // Through optionalEnv for the reason documented there: compose passes CHAIN_WAIT_MS through
36
+ // as present-and-empty, `Number("")` is 0, and a 0 ms deadline means the loop below never
37
+ // runs even once — the app then reports "chain not ready after 0s" about a chain it never
38
+ // asked. Junk is treated the same way as absent, because "unparseable" must not silently
39
+ // become "do not wait at all".
40
+ const DEADLINE_MS = (() => {
41
+ const raw = optionalEnv("CHAIN_WAIT_MS");
42
+ const n = raw === undefined ? NaN : Number(raw);
43
+ if (raw !== undefined && !(Number.isFinite(n) && n > 0))
44
+ console.error(`(ignoring CHAIN_WAIT_MS='${raw}' — not a positive number of milliseconds)`);
45
+ return Number.isFinite(n) && n > 0 ? n : 180_000;
46
+ })();
47
+ if (process.env.EVOLVER_INSECURE_TLS === "1")
48
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
49
+ const started = Date.now();
50
+ let lastErr = "never reached";
51
+ while (Date.now() - started < DEADLINE_MS) {
52
+ try {
53
+ const res = await fetch(`${target.nodeUrl}/api/explorer/blocks?limit=1`, {
54
+ headers: { Accept: "application/json" },
55
+ });
56
+ if (res.ok) {
57
+ const b = (await res.json());
58
+ const round = b?.blocks?.[0]?.roundNum ?? 0;
59
+ if (round > 0) {
60
+ console.log(`chain ready — ${target.nodeUrl}, committed round ${round}`);
61
+ process.exit(0);
62
+ }
63
+ lastErr = "tip still round 0 (consensus started, nothing committed yet)";
64
+ }
65
+ else {
66
+ lastErr = `HTTP ${res.status}`;
67
+ }
68
+ }
69
+ catch (e) {
70
+ lastErr = e instanceof Error ? e.message.split("\n")[0] : String(e);
71
+ }
72
+ await new Promise((r) => setTimeout(r, 2000));
73
+ }
74
+ // Advice depends on WHERE this is running: inside the published image there is no npm
75
+ // project and no docker compose, so the host-side commands would be a dead end.
76
+ const inContainer = process.env.EVOLVER_IN_CONTAINER === "1";
77
+ const advice = inContainer
78
+ ? [
79
+ ` · this container waits for target '${target.name}' at ${target.nodeUrl}`,
80
+ ` · pointing at a remote chain? docker run -e EVOLVER_TARGET=devnet ...`,
81
+ ` · running the bundled chain? the 'node' service must be healthy first`,
82
+ ]
83
+ : [
84
+ ` · docker compose ps is the node running?`,
85
+ ` · docker compose logs node what did it say?`,
86
+ ` · npm run reset wipe chain + local state and start over`,
87
+ ];
88
+ console.error(`\n✗ chain not ready after ${Math.round(DEADLINE_MS / 1000)}s at ${target.nodeUrl} ` +
89
+ `(target '${target.name}') — last: ${lastErr}\n` + advice.join("\n") + "\n");
90
+ process.exit(1);
@@ -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.