@mcsherrylabs/pi-meerkat 0.7.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.
- package/README.md +85 -33
- package/dist/installer/committee-init.sh +117 -0
- package/dist/installer/committee.conf +102 -0
- package/dist/installer/node-join.sh +228 -0
- package/dist/installer/node.conf +235 -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 +539 -0
- package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
- package/dist/installer-runtime/dist/src/env.js +425 -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 +52 -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 +180 -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 +164 -0
- package/dist/installer-runtime/dist/src/store.js +138 -0
- package/dist/installer-runtime/package.json +1 -0
- package/dist/installer.d.ts +224 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +336 -0
- package/dist/installer.js.map +1 -0
- package/dist/mcp-cli.d.ts.map +1 -1
- package/dist/mcp-cli.js +31 -5
- package/dist/mcp-cli.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/installer/Committee.Dockerfile +6 -0
- package/installer/Node.Dockerfile +6 -0
- package/installer/Registry.Dockerfile +13 -0
- package/installer/runtime-control.mjs +172 -0
- package/installer/session-files.mjs +16 -0
- package/package.json +5 -4
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Make the registry's channel exist with the terms this feature needs, and prove it by reading.
|
|
2
|
+
//
|
|
3
|
+
// ONE term now, and it is load-bearing for everything:
|
|
4
|
+
//
|
|
5
|
+
// writePolicy = open so third parties may announce, AND so any holder will serve a body
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ THERE USED TO BE THREE, and the other two were a DA group of { this node } at m=1. The
|
|
8
|
+
// argument was FR-007 satisfied structurally: `InsufficientDA` is enforced at admit and at
|
|
9
|
+
// pre-commit, so an announcement that committed could not have committed without this node
|
|
10
|
+
// holding the body. That much was true. What it left out is who paid for it — a third party's
|
|
11
|
+
// write needed an attestation from this one node, so announcing required a live p2p path to
|
|
12
|
+
// the registry and failed `503 da_unavailable` with nothing ordered when it was down. An index
|
|
13
|
+
// being unavailable stopped people writing the thing being indexed. And the guarantee bought
|
|
14
|
+
// with that was one volume, not replication: lose it and the message was gone anyway.
|
|
15
|
+
//
|
|
16
|
+
// So the DA policy is REMOVED — permanently, and this path converges an existing channel to
|
|
17
|
+
// that shape rather than leaving two designs in the estate. Bodies are best-effort, `misses`
|
|
18
|
+
// is the honest metric, and the drainer already treated an unfetchable body as a bounded miss
|
|
19
|
+
// rather than a stall, so nothing downstream was relying on the promise.
|
|
20
|
+
//
|
|
21
|
+
// ⚠️ WHAT SURVIVES UNCHANGED: THE OPERATOR ANNOUNCES THROUGH ITS OWN NODE. The first version of
|
|
22
|
+
// this file routed the operator's own announcements through a foreign gateway, which left its
|
|
23
|
+
// entries — including the base instruction document the agent skill trusts most — reachable
|
|
24
|
+
// only by a single best-effort flood. Submitted through THIS node, `channelOriginate` stores
|
|
25
|
+
// the body LOCALLY before the id-only form is ordered (NodeBootstrap.scala:1339-1341 →
|
|
26
|
+
// ChannelOverlay `onOriginate`). With no DA group that local store is now the ONLY thing
|
|
27
|
+
// putting the operator's own bodies anywhere, so it matters more than it did, not less. See
|
|
28
|
+
// announce.ts and research D3.
|
|
29
|
+
import { buildSignedTx, claimBody, updateChannelWritePolicyBody, LEDGER } from "@mcsherrylabs/evolver-client";
|
|
30
|
+
import { channelTerms, submitAndConfirm, updateChannelDaBody, } from "../announcements.js";
|
|
31
|
+
export function decideRegistryRole(args) {
|
|
32
|
+
const { terms, self, operator } = args;
|
|
33
|
+
// ALREADY OURS — see "keeping is not acquiring" above. Note this is the IDENTITY name,
|
|
34
|
+
// matching what the claim/converge path below can actually sign for: a channel owned by a
|
|
35
|
+
// group we happen to belong to is deliberately NOT treated as ours, because this app holds
|
|
36
|
+
// one identity and submits as it.
|
|
37
|
+
if (terms.owners.some((o) => o.name === self))
|
|
38
|
+
return { kind: "owner" };
|
|
39
|
+
// UNCLAIMED. The route answers 200 with `owners: []` for a channel that has never existed
|
|
40
|
+
// (research A2), so this is also what a typo'd REGISTRY_CHANNEL looks like — one more reason
|
|
41
|
+
// not to answer it by claiming.
|
|
42
|
+
if (terms.owners.length === 0) {
|
|
43
|
+
if (operator && operator === self)
|
|
44
|
+
return { kind: "owner" };
|
|
45
|
+
return { kind: "waiting", why: operator
|
|
46
|
+
? `'${terms.ledgerId}' is UNCLAIMED, and this deployment ('${self}') is not its operator — ` +
|
|
47
|
+
`REGISTRY_CHANNEL_OPERATOR names '${operator}'. Draining the cursor and indexing nothing ` +
|
|
48
|
+
`until '${operator}' provisions it. Nothing will be claimed here.`
|
|
49
|
+
: `'${terms.ledgerId}' is UNCLAIMED and no REGISTRY_CHANNEL_OPERATOR is set, so this ` +
|
|
50
|
+
`deployment will not claim it — a channel name is permanent and cannot be reused, and ` +
|
|
51
|
+
`boot order is not a mandate. Draining the cursor and indexing nothing. To make THIS ` +
|
|
52
|
+
`deployment the operator, set REGISTRY_CHANNEL_OPERATOR=${self} and restart.` };
|
|
53
|
+
}
|
|
54
|
+
const holder = terms.owners.map((o) => o.name).join(", ");
|
|
55
|
+
// OWNED, BUT NOT BY THE NAMED OPERATOR. Unusable rather than reader: the terms are set by
|
|
56
|
+
// somebody this deployment was not told to trust, and quietly indexing them is how a squatted
|
|
57
|
+
// channel becomes an index nobody audits.
|
|
58
|
+
if (operator && !terms.owners.some((o) => o.name === operator))
|
|
59
|
+
return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder}, but REGISTRY_CHANNEL_OPERATOR names ` +
|
|
60
|
+
`'${operator}'. Either the operator changed — update the key — or this name was taken by ` +
|
|
61
|
+
`somebody else, in which case choose another REGISTRY_CHANNEL. Refusing to index a ` +
|
|
62
|
+
`channel whose terms are set by a party this deployment was not told to trust.` };
|
|
63
|
+
// ⚠️ NO DA POLICY IS THE EXPECTED SHAPE, and this used to be the refusal that made reader
|
|
64
|
+
// mode pointless: "nothing is OBLIGED to hold a body, so a reader cannot index what nobody
|
|
65
|
+
// must keep." Bodies are best-effort by design now (see [[ProvisionResult]]), a miss is a
|
|
66
|
+
// counted outcome rather than a broken premise, and refusing here would refuse the registry's
|
|
67
|
+
// own channel. A channel that still HAS a DA policy is read just the same — it is a legacy
|
|
68
|
+
// shape the owner has not converged yet, not an error.
|
|
69
|
+
if (terms.writePolicy !== "open")
|
|
70
|
+
return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder} and its write policy is ` +
|
|
71
|
+
`'${terms.writePolicy}', not 'open'. With no DA group there is no membership that would ` +
|
|
72
|
+
`let a holder serve us anyway, so an open write policy is the ONLY thing that makes a ` +
|
|
73
|
+
`body obtainable — every announcement would be a permanent miss. Ask ${holder} to open ` +
|
|
74
|
+
`the channel, or choose another REGISTRY_CHANNEL.` };
|
|
75
|
+
return { kind: "reader", holder };
|
|
76
|
+
}
|
|
77
|
+
export async function provisionRegistryChannel(args) {
|
|
78
|
+
const { node, chainUrl, chainAgreedUrl, channel, owner, signer, nodeIdentity, operator, say, pace } = args;
|
|
79
|
+
let writes = 0;
|
|
80
|
+
// ⚠️ READ AND DECIDE BEFORE WRITING ANYTHING.
|
|
81
|
+
//
|
|
82
|
+
// A DA group used to be created here, ahead of the role decision, so a deployment that turned
|
|
83
|
+
// out to be a mere READER had already created and paid for a group it could never be asked to
|
|
84
|
+
// attest with. No group is created at all now, which retires that hazard rather than ordering
|
|
85
|
+
// around it — but the rule it taught still holds: decide first, so `writes === 0` is a fact
|
|
86
|
+
// about a reader rather than a hope.
|
|
87
|
+
const ask = () => channelTerms(chainUrl, channel); // "has MY node applied it?"
|
|
88
|
+
const read = await channelTerms(chainAgreedUrl, channel); // "what has the CHAIN agreed?"
|
|
89
|
+
// Refusing to guess, for the reason the group read refuses: acting on an unreadable answer
|
|
90
|
+
// means submitting a Claim for a channel somebody else may own, which the node accepts and
|
|
91
|
+
// then rejects every round until its TTL, poisoning the queue for the whole chain
|
|
92
|
+
// (evolver#85).
|
|
93
|
+
if (!read.ok)
|
|
94
|
+
throw new Error(`could not read the terms of '${channel}' from ${chainAgreedUrl}: ${read.why}\n` +
|
|
95
|
+
` Refusing to guess — a Claim for a channel someone else owns is retained ` +
|
|
96
|
+
`and re-proposed until its TTL, which suppresses wake-on-work chain-wide.`);
|
|
97
|
+
const terms = read.terms;
|
|
98
|
+
const role = decideRegistryRole({ terms, self: owner.nodeId, operator });
|
|
99
|
+
if (role.kind === "unusable")
|
|
100
|
+
throw new Error(role.why);
|
|
101
|
+
if (role.kind === "waiting") {
|
|
102
|
+
// NOT a throw, and the distinction is the node's liveness. This process is what reads
|
|
103
|
+
// `/api/channel/next`; kill it and the node stops applying, so a deployment that merely
|
|
104
|
+
// has nothing to index would take its own node's core state down with it.
|
|
105
|
+
say(` ⚠ ${role.why}`);
|
|
106
|
+
return { role: "waiting", channel, member: nodeIdentity, writes: 0 };
|
|
107
|
+
}
|
|
108
|
+
if (role.kind === "reader") {
|
|
109
|
+
// A channel's terms are its owner's alone to set — so we set none. Indexing never
|
|
110
|
+
// needed ownership: the cursor, the walk, the store and the read surface are all
|
|
111
|
+
// node-local, and the only thing the chain has to grant us is the ABILITY TO FETCH A
|
|
112
|
+
// BODY, which `writePolicy = open` is exactly what grants (see [[decideRegistryRole]]).
|
|
113
|
+
say(` '${channel}' is owned by ${role.holder}, not by '${owner.nodeId}' — running as a READER`);
|
|
114
|
+
say(` writes are open, so any node holding a body will serve it to us on request.`);
|
|
115
|
+
say(` Nothing will be claimed or provisioned here.`);
|
|
116
|
+
say(` ⚠ availability is BEST EFFORT — for the owner too, since this channel carries no DA`);
|
|
117
|
+
say(` policy. A body nobody kept is reported as a miss; see /summary.`);
|
|
118
|
+
return { role: "reader", channel, member: nodeIdentity, writes: 0 };
|
|
119
|
+
}
|
|
120
|
+
// ── from here down this deployment OWNS the channel (or it is unclaimed and named for us) ──
|
|
121
|
+
say(` channel '${channel}' — no DA policy by design; writes open so any holder will serve`);
|
|
122
|
+
const ownedByUs = terms.owners.some((o) => o.name === owner.nodeId);
|
|
123
|
+
// The ONLY acceptable DA state is none at all. `terms.da` present means a channel claimed by
|
|
124
|
+
// an older build, and converging it is how the estate ends up with one design rather than two.
|
|
125
|
+
const daOk = !terms.da;
|
|
126
|
+
const openOk = terms.writePolicy === "open";
|
|
127
|
+
if (!ownedByUs) {
|
|
128
|
+
// One transaction sets every term, so there is no window in which the channel is claimed
|
|
129
|
+
// but not yet open. `claimBody` with no daGroup/daThresholdM is not DA-managed.
|
|
130
|
+
const claim = buildSignedTx({
|
|
131
|
+
ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
|
|
132
|
+
body: claimBody({ ledgerId: channel, writePolicy: "open" }),
|
|
133
|
+
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
134
|
+
});
|
|
135
|
+
await submitAndConfirm(node, claim, `claim '${channel}' (no DA, open)`, async () => {
|
|
136
|
+
const r = await ask();
|
|
137
|
+
return r.ok && r.terms.owners.some((o) => o.name === owner.nodeId) &&
|
|
138
|
+
!r.terms.da && r.terms.writePolicy === "open";
|
|
139
|
+
}, say, { pace });
|
|
140
|
+
return { role: "owner", channel, member: nodeIdentity, writes: writes + 1 };
|
|
141
|
+
}
|
|
142
|
+
// OURS ALREADY, but a term has drifted. Converge IN PLACE — never by unclaiming: a full
|
|
143
|
+
// unclaim drops all three policy rows and leaves the name UNOWNED between the two
|
|
144
|
+
// transactions, so on a shared chain anyone can take it. A second Claim is a committed no-op
|
|
145
|
+
// on an already-claimed ledger, so the targeted updates are the only thing that converges.
|
|
146
|
+
//
|
|
147
|
+
// ⚠️ THIS IS THE MIGRATION, and it runs itself. A channel claimed by an older build still
|
|
148
|
+
// carries a DA group; an empty `daGroup` in UpdateChannelDa turns the policy OFF
|
|
149
|
+
// (owners.proto: "da_group empty = turn DA off"), so the first boot of this build as owner
|
|
150
|
+
// removes it. No hand-signed transaction, and nothing to remember to do.
|
|
151
|
+
if (!daOk) {
|
|
152
|
+
say(` DA policy is '${terms.da.group}' (m=${terms.da.thresholdM}); this registry wants NONE — removing in place`);
|
|
153
|
+
const upd = buildSignedTx({
|
|
154
|
+
ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
|
|
155
|
+
body: updateChannelDaBody({ ledgerId: channel, daGroup: "", daThresholdM: 0 }),
|
|
156
|
+
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
157
|
+
});
|
|
158
|
+
await submitAndConfirm(node, upd, `remove '${channel}' DA policy`, async () => {
|
|
159
|
+
const r = await ask();
|
|
160
|
+
return r.ok && !r.terms.da;
|
|
161
|
+
}, say, { pace });
|
|
162
|
+
writes += 1;
|
|
163
|
+
}
|
|
164
|
+
if (!openOk) {
|
|
165
|
+
say(` write policy is '${terms.writePolicy}', this registry needs 'open' — updating in place`);
|
|
166
|
+
const upd = buildSignedTx({
|
|
167
|
+
ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
|
|
168
|
+
body: updateChannelWritePolicyBody({ ledgerId: channel, writePolicy: "open" }),
|
|
169
|
+
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
170
|
+
});
|
|
171
|
+
await submitAndConfirm(node, upd, `open '${channel}' to any writer`, async () => {
|
|
172
|
+
const r = await ask();
|
|
173
|
+
return r.ok && r.terms.writePolicy === "open";
|
|
174
|
+
}, say, { pace });
|
|
175
|
+
writes += 1;
|
|
176
|
+
}
|
|
177
|
+
if (writes === 0)
|
|
178
|
+
say(` '${channel}' already has the terms this registry needs — nothing to submit`);
|
|
179
|
+
return { role: "owner", channel, member: nodeIdentity, writes };
|
|
180
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { BindingError } from "@mcsherrylabs/evolver-client/processor";
|
|
2
|
+
const SCHEMA = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
4
|
+
ledger_id TEXT PRIMARY KEY, announcer TEXT NOT NULL, provenance TEXT NOT NULL,
|
|
5
|
+
seq INTEGER NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL,
|
|
6
|
+
keywords TEXT NOT NULL,
|
|
7
|
+
detail_key TEXT, detail_sha256 TEXT, detail_bytes INTEGER,
|
|
8
|
+
detail_state TEXT NOT NULL DEFAULT 'ok', detail_reason TEXT,
|
|
9
|
+
announced_at_round INTEGER NOT NULL, verified_at_round INTEGER NOT NULL,
|
|
10
|
+
requested_expiry_round INTEGER NOT NULL, granted_expiry_round INTEGER NOT NULL,
|
|
11
|
+
state TEXT NOT NULL, tx_id TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS entries_state ON entries(state);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS entries_kind ON entries(kind);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS entries_announced ON entries(announced_at_round);
|
|
16
|
+
CREATE TABLE IF NOT EXISTS rejections (
|
|
17
|
+
tx_id TEXT PRIMARY KEY, round INTEGER NOT NULL, sender TEXT NOT NULL,
|
|
18
|
+
ledger_id TEXT NOT NULL, reason TEXT NOT NULL, detail TEXT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
|
|
21
|
+
-- The VERIFIED detail document, kept here because the registry is the only thing an agent can
|
|
22
|
+
-- reach. Bounded by MAX_DETAIL_BYTES before it is ever stored, and keyed by the content key so
|
|
23
|
+
-- two channels announcing the same document hold one copy.
|
|
24
|
+
CREATE TABLE IF NOT EXISTS detail_docs (
|
|
25
|
+
detail_key TEXT PRIMARY KEY, sha256 TEXT NOT NULL, bytes BLOB NOT NULL, fetched_at INTEGER NOT NULL
|
|
26
|
+
);
|
|
27
|
+
-- A delivery whose control check could not be DECIDED, held with its body so the answer can be
|
|
28
|
+
-- sought again on a later walk. See defer() below for why this table has to exist.
|
|
29
|
+
CREATE TABLE IF NOT EXISTS deferred (
|
|
30
|
+
tx_id TEXT PRIMARY KEY, round INTEGER NOT NULL, idx INTEGER NOT NULL,
|
|
31
|
+
sender TEXT NOT NULL, ledger_id TEXT NOT NULL, body BLOB NOT NULL,
|
|
32
|
+
first_seen INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, last_why TEXT NOT NULL DEFAULT ''
|
|
33
|
+
);
|
|
34
|
+
`;
|
|
35
|
+
export class RegistryStore {
|
|
36
|
+
#db;
|
|
37
|
+
#channel;
|
|
38
|
+
#readMisses;
|
|
39
|
+
/** All collaborators run on the supplied database owner's serialized execution domain.
|
|
40
|
+
* `readMisses` concerns THIS index's channel and must propagate failures, never return a
|
|
41
|
+
* fabricated zero. Keeping it explicit prevents dependence on generic storage table names. */
|
|
42
|
+
constructor(db, channel, readMisses) {
|
|
43
|
+
this.#db = db;
|
|
44
|
+
this.#channel = channel;
|
|
45
|
+
this.#readMisses = readMisses;
|
|
46
|
+
this.#db.exec(SCHEMA);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Bind this index to its channel, or REFUSE. The shared owner validates the chain/node binding
|
|
50
|
+
* before installing the module; this store owns only the registry-channel half.
|
|
51
|
+
*
|
|
52
|
+
* Every row here describes an announcement on ONE channel, verified against that channel's
|
|
53
|
+
* control. Reading them as the index of a different channel would serve assertions nobody
|
|
54
|
+
* made there. A first bind records; a mismatch throws.
|
|
55
|
+
*/
|
|
56
|
+
bindChannel() {
|
|
57
|
+
const chan = this.#meta("request_channel");
|
|
58
|
+
if (chan === null) {
|
|
59
|
+
this.#setMeta("request_channel", this.#channel);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (chan !== this.#channel)
|
|
63
|
+
throw new BindingError(`this store's index was built while draining '${chan}', but the configuration says ` +
|
|
64
|
+
`'${this.#channel}'. Its entries describe the other channel; serving them as this one's ` +
|
|
65
|
+
`would be wrong. Point PROCESSOR_DB at the right store, or set REGISTRY_CHANNEL back.`);
|
|
66
|
+
}
|
|
67
|
+
#meta(k) {
|
|
68
|
+
const r = this.#db.prepare("SELECT v FROM meta WHERE k = ?").get(k);
|
|
69
|
+
return r?.v ?? null;
|
|
70
|
+
}
|
|
71
|
+
#setMeta(k, v) {
|
|
72
|
+
this.#db.prepare("INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v").run(k, v);
|
|
73
|
+
}
|
|
74
|
+
/** Replace any live entry for this channel (FR-008, FR-013). One statement, so a refresh
|
|
75
|
+
* cannot briefly leave two rows or none. */
|
|
76
|
+
put(e) {
|
|
77
|
+
this.#db.prepare(`
|
|
78
|
+
INSERT INTO entries (ledger_id, announcer, provenance, seq, kind, title, summary, keywords,
|
|
79
|
+
detail_key, detail_sha256, detail_bytes, detail_state, detail_reason,
|
|
80
|
+
announced_at_round, verified_at_round, requested_expiry_round, granted_expiry_round, state, tx_id)
|
|
81
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
82
|
+
ON CONFLICT(ledger_id) DO UPDATE SET
|
|
83
|
+
announcer=excluded.announcer, provenance=excluded.provenance, seq=excluded.seq,
|
|
84
|
+
kind=excluded.kind, title=excluded.title, summary=excluded.summary, keywords=excluded.keywords,
|
|
85
|
+
detail_key=excluded.detail_key, detail_sha256=excluded.detail_sha256, detail_bytes=excluded.detail_bytes,
|
|
86
|
+
detail_state=excluded.detail_state, detail_reason=excluded.detail_reason,
|
|
87
|
+
announced_at_round=excluded.announced_at_round, verified_at_round=excluded.verified_at_round,
|
|
88
|
+
requested_expiry_round=excluded.requested_expiry_round, granted_expiry_round=excluded.granted_expiry_round,
|
|
89
|
+
state=excluded.state, tx_id=excluded.tx_id`).run(e.ledgerId, e.announcer, e.provenance, e.seq, e.kind, e.title, e.summary, JSON.stringify(e.keywords), e.detail?.key ?? null, e.detail?.sha256 ?? null, e.detail?.bytes ?? null, e.detail?.state ?? "ok", e.detail?.reason ?? null, Number(e.announcedAtRound), Number(e.verifiedAtRound), Number(e.requestedExpiryRound), Number(e.grantedExpiryRound), e.state, e.txId);
|
|
90
|
+
}
|
|
91
|
+
get(ledgerId, deny = new Set()) {
|
|
92
|
+
const r = this.#db.prepare("SELECT * FROM entries WHERE ledger_id = ?").get(ledgerId);
|
|
93
|
+
if (!r)
|
|
94
|
+
return null;
|
|
95
|
+
const e = row(r);
|
|
96
|
+
return deny.has(e.announcer) ? null : e;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Search. Ordering is `announced_at_round` ASC — deterministic, and not influenced by
|
|
100
|
+
* anything an announcer supplies (FR-022): the only way to rank higher is to have been there
|
|
101
|
+
* longer, which cannot be bought with keywords.
|
|
102
|
+
*/
|
|
103
|
+
search(q, deny = new Set()) {
|
|
104
|
+
const all = this.#db.prepare("SELECT * FROM entries ORDER BY announced_at_round ASC, ledger_id ASC")
|
|
105
|
+
.all().map(row).filter((e) => !deny.has(e.announcer));
|
|
106
|
+
const terms = (q.terms ?? []).map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
|
|
107
|
+
const match = (e) => (q.kind === undefined || e.kind === q.kind) &&
|
|
108
|
+
(terms.length === 0 || terms.some((t) => e.keywords.includes(t) || e.title.toLowerCase().includes(t) ||
|
|
109
|
+
e.summary.toLowerCase().includes(t) || e.ledgerId.toLowerCase().includes(t)));
|
|
110
|
+
const hits = all.filter(match);
|
|
111
|
+
return { total: hits.length, entries: hits.slice(q.offset, q.offset + q.limit) };
|
|
112
|
+
}
|
|
113
|
+
summary(deny = new Set()) {
|
|
114
|
+
const all = this.#db.prepare("SELECT * FROM entries").all()
|
|
115
|
+
.map(row).filter((e) => !deny.has(e.announcer));
|
|
116
|
+
const entries = { live: 0, expired: 0 };
|
|
117
|
+
const kinds = {};
|
|
118
|
+
const kw = new Map();
|
|
119
|
+
for (const e of all) {
|
|
120
|
+
entries[e.state] += 1;
|
|
121
|
+
if (e.state === "live") {
|
|
122
|
+
kinds[e.kind] = (kinds[e.kind] ?? 0) + 1;
|
|
123
|
+
for (const k of e.keywords)
|
|
124
|
+
kw.set(k, (kw.get(k) ?? 0) + 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const keywords = [...kw.entries()].map(([keyword, live]) => ({ keyword, live }))
|
|
128
|
+
.sort((a, b) => b.live - a.live || a.keyword.localeCompare(b.keyword));
|
|
129
|
+
return { entries, kinds, keywords, misses: this.misses(), deferred: this.deferredCount() };
|
|
130
|
+
}
|
|
131
|
+
/** Mark leases that the tip has passed (FR-012). Kept, never deleted: an agent that reads
|
|
132
|
+
* "expired" learns more than one that reads nothing. */
|
|
133
|
+
sweep(tipRound) {
|
|
134
|
+
const r = this.#db.prepare("UPDATE entries SET state = 'expired' WHERE state = 'live' AND granted_expiry_round < ?")
|
|
135
|
+
.run(Number(tipRound));
|
|
136
|
+
return Number(r.changes);
|
|
137
|
+
}
|
|
138
|
+
/** Keep a VERIFIED detail document. The caller has already checked its digest and its size —
|
|
139
|
+
* this stores bytes, it does not vouch for them. */
|
|
140
|
+
putDetailDoc(key, sha256, bytes) {
|
|
141
|
+
this.#db.prepare(`INSERT INTO detail_docs (detail_key, sha256, bytes, fetched_at) VALUES (?,?,?,?)
|
|
142
|
+
ON CONFLICT(detail_key) DO UPDATE SET sha256=excluded.sha256, bytes=excluded.bytes,
|
|
143
|
+
fetched_at=excluded.fetched_at`).run(key, sha256, bytes, Date.now());
|
|
144
|
+
}
|
|
145
|
+
detailDoc(key) {
|
|
146
|
+
const r = this.#db.prepare("SELECT sha256, bytes FROM detail_docs WHERE detail_key = ?").get(key);
|
|
147
|
+
if (!r || r.bytes === undefined || r.sha256 === undefined)
|
|
148
|
+
return null;
|
|
149
|
+
return { sha256: String(r.sha256), bytes: new Uint8Array(r.bytes) };
|
|
150
|
+
}
|
|
151
|
+
reject(r) {
|
|
152
|
+
this.#db.prepare(`INSERT INTO rejections (tx_id, round, sender, ledger_id, reason, detail)
|
|
153
|
+
VALUES (?,?,?,?,?,?) ON CONFLICT(tx_id) DO NOTHING`)
|
|
154
|
+
.run(r.txId, Number(r.round), r.sender, r.ledgerId, r.reason, r.detail.slice(0, 500));
|
|
155
|
+
}
|
|
156
|
+
rejections(limit = 50) {
|
|
157
|
+
return this.#db.prepare("SELECT * FROM rejections ORDER BY round DESC LIMIT ?").all(limit)
|
|
158
|
+
.map((r) => ({
|
|
159
|
+
txId: String(r.tx_id), round: BigInt(Number(r.round)), sender: String(r.sender),
|
|
160
|
+
ledgerId: String(r.ledger_id), reason: String(r.reason), detail: String(r.detail),
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* HOLD a delivery whose control check could not be decided, WITH ITS BODY.
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ THE ONLY UNRECOVERABLE THING IN THIS SYSTEM IS THE BODY, and the undetermined path used
|
|
167
|
+
* to throw one away while holding it. `/api/channel/next` is forward-only and no route fetches
|
|
168
|
+
* a body by txId (evolver#87), so once the walk has passed a position the body exists in
|
|
169
|
+
* exactly one place: this process's memory. Recording a miss and dropping it meant a chain
|
|
170
|
+
* read that was briefly unavailable cost an announcement its entry until its owner refreshed
|
|
171
|
+
* — for a reason that had nothing to do with the announcement.
|
|
172
|
+
*
|
|
173
|
+
* So it is written down instead, and retried on every later walk. The walk still never
|
|
174
|
+
* blocks: the single consumer moves on immediately, which is the property that mattered.
|
|
175
|
+
*/
|
|
176
|
+
defer(d) {
|
|
177
|
+
this.#db.prepare(`INSERT INTO deferred (tx_id, round, idx, sender, ledger_id, body, first_seen, attempts, last_why)
|
|
178
|
+
VALUES (?,?,?,?,?,?,?,1,?)
|
|
179
|
+
ON CONFLICT(tx_id) DO UPDATE SET attempts = attempts + 1, last_why = excluded.last_why`)
|
|
180
|
+
.run(d.txId, Number(d.round), d.index, d.sender, d.ledgerId, d.body, Date.now(), d.why.slice(0, 300));
|
|
181
|
+
}
|
|
182
|
+
/** Oldest first: a delivery held longer has waited longer, and order of arrival is the only
|
|
183
|
+
* ranking this system recognises. */
|
|
184
|
+
deferredAll(limit = 100) {
|
|
185
|
+
return this.#db.prepare("SELECT * FROM deferred ORDER BY round ASC, idx ASC LIMIT ?")
|
|
186
|
+
.all(limit).map((r) => ({
|
|
187
|
+
txId: String(r.tx_id), round: BigInt(Number(r.round)), index: Number(r.idx),
|
|
188
|
+
sender: String(r.sender), ledgerId: String(r.ledger_id),
|
|
189
|
+
body: new Uint8Array(r.body), attempts: Number(r.attempts),
|
|
190
|
+
why: String(r.last_why),
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
undefer(txId) {
|
|
194
|
+
this.#db.prepare("DELETE FROM deferred WHERE tx_id = ?").run(txId);
|
|
195
|
+
}
|
|
196
|
+
deferredCount() {
|
|
197
|
+
const r = this.#db.prepare("SELECT COUNT(*) AS n FROM deferred").get();
|
|
198
|
+
return Number(r.n);
|
|
199
|
+
}
|
|
200
|
+
/** Announcements whose bodies never arrived, not authorization rejections, failed reads,
|
|
201
|
+
* module errors, or eviction of a body that was already indexed. The shared owner's outcome
|
|
202
|
+
* projection defines these facts; this store does not know its generic inbox schema. */
|
|
203
|
+
misses() { return this.#readMisses(); }
|
|
204
|
+
}
|
|
205
|
+
function row(r) {
|
|
206
|
+
const detail = r.detail_key
|
|
207
|
+
? {
|
|
208
|
+
key: String(r.detail_key), sha256: String(r.detail_sha256), bytes: Number(r.detail_bytes),
|
|
209
|
+
state: String(r.detail_state),
|
|
210
|
+
...(r.detail_reason ? { reason: String(r.detail_reason) } : {}),
|
|
211
|
+
}
|
|
212
|
+
: undefined;
|
|
213
|
+
return {
|
|
214
|
+
ledgerId: String(r.ledger_id), announcer: String(r.announcer),
|
|
215
|
+
provenance: String(r.provenance), seq: Number(r.seq),
|
|
216
|
+
kind: String(r.kind), title: String(r.title), summary: String(r.summary),
|
|
217
|
+
keywords: JSON.parse(String(r.keywords)),
|
|
218
|
+
...(detail ? { detail } : {}),
|
|
219
|
+
announcedAtRound: BigInt(Number(r.announced_at_round)),
|
|
220
|
+
verifiedAtRound: BigInt(Number(r.verified_at_round)),
|
|
221
|
+
requestedExpiryRound: BigInt(Number(r.requested_expiry_round)),
|
|
222
|
+
grantedExpiryRound: BigInt(Number(r.granted_expiry_round)),
|
|
223
|
+
state: String(r.state), txId: String(r.tx_id),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Build an entry from an accepted announcement — the one place the two shapes meet, so a new
|
|
227
|
+
* announcement field cannot reach the index without passing through here. */
|
|
228
|
+
export function entryFrom(args) {
|
|
229
|
+
const { a } = args;
|
|
230
|
+
return {
|
|
231
|
+
ledgerId: a.ledgerId, announcer: args.announcer, provenance: args.provenance, seq: a.seq,
|
|
232
|
+
kind: a.kind, title: a.title, summary: a.summary, keywords: a.keywords,
|
|
233
|
+
...(a.detail ? { detail: {
|
|
234
|
+
...a.detail, state: args.detailState ?? "ok",
|
|
235
|
+
...(args.detailReason ? { reason: args.detailReason } : {}),
|
|
236
|
+
} } : {}),
|
|
237
|
+
announcedAtRound: args.announcedAtRound, verifiedAtRound: args.verifiedAtRound,
|
|
238
|
+
requestedExpiryRound: args.lease.requestedExpiryRound,
|
|
239
|
+
grantedExpiryRound: args.lease.grantedExpiryRound,
|
|
240
|
+
state: "live", txId: args.txId,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** One pass. Separated from the timer so it can be driven directly in a test and once at boot,
|
|
2
|
+
* where waiting a whole interval to retire an already-stale entry would be strange. */
|
|
3
|
+
export function sweepOnce(store, tipRound, say) {
|
|
4
|
+
const n = store.sweep(tipRound);
|
|
5
|
+
if (n > 0)
|
|
6
|
+
say(` ${n} lease(s) expired as of round ${tipRound}`);
|
|
7
|
+
return n;
|
|
8
|
+
}
|
|
9
|
+
// Scheduled by the shared worker's serialized afterWalk hook, including quiet walks.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Registry adapter for the shared SDK's ONE storage worker. No listener, polling, setup,
|
|
2
|
+
// subscription mutation, signing material, second database or independent DB timer lives here.
|
|
3
|
+
// The parent provisions using RuntimeModule.setup({pace,readModule}), after recovery listeners.
|
|
4
|
+
import { position, validateChannelId, } from "@mcsherrylabs/evolver-client/processor";
|
|
5
|
+
import { registryDependencies } from "./dependencies.js";
|
|
6
|
+
import { createRegistryHandler } from "./drainer.js";
|
|
7
|
+
import { registryRoutes } from "./http.js";
|
|
8
|
+
import { RegistryStore } from "./store.js";
|
|
9
|
+
import { sweepOnce } from "./sweep.js";
|
|
10
|
+
export const REGISTRY_MODULE_NAME = "registry";
|
|
11
|
+
function readRequest(request) {
|
|
12
|
+
if (!request || typeof request !== "object")
|
|
13
|
+
throw new Error("invalid registry read projection");
|
|
14
|
+
const r = request;
|
|
15
|
+
if (typeof r.path !== "string" || r.path.length > 4096 || !r.path.startsWith("/") || r.path.startsWith("//") ||
|
|
16
|
+
typeof r.tip !== "string" || typeof r.walked !== "string" ||
|
|
17
|
+
(r.deny !== undefined && (!Array.isArray(r.deny) || r.deny.length > 1024 || r.deny.some(id => typeof id !== "string"))))
|
|
18
|
+
throw new Error("invalid registry read projection");
|
|
19
|
+
position(r.tip, "0");
|
|
20
|
+
position(r.walked, "0");
|
|
21
|
+
return r;
|
|
22
|
+
}
|
|
23
|
+
/** Factory with explicit collaborators for owned tests and trusted application composition.
|
|
24
|
+
* Only the default factory below is selected by the production ModuleReference. */
|
|
25
|
+
export function createRegistryWorkerModule(options, deps, clock) {
|
|
26
|
+
validateChannelId(options.channel);
|
|
27
|
+
if (deps.channel !== options.channel)
|
|
28
|
+
throw new Error("registry handler channel differs from module claim");
|
|
29
|
+
return {
|
|
30
|
+
name: REGISTRY_MODULE_NAME,
|
|
31
|
+
install(context) {
|
|
32
|
+
const store = new RegistryStore(context.db, options.channel, () => {
|
|
33
|
+
const summary = context.outcomeSummary(options.channel);
|
|
34
|
+
const total = Number(summary.unavailable);
|
|
35
|
+
if (!/^(0|[1-9][0-9]*)$/.test(summary.unavailable) || !Number.isSafeInteger(total))
|
|
36
|
+
throw new Error("registry unavailable count is not an exact safe integer");
|
|
37
|
+
if (summary.lastRound !== null)
|
|
38
|
+
position(summary.lastRound, "0");
|
|
39
|
+
return { total, lastRound: summary.lastRound === null ? null : BigInt(summary.lastRound) };
|
|
40
|
+
});
|
|
41
|
+
store.bindChannel();
|
|
42
|
+
const handler = createRegistryHandler(store, deps, clock);
|
|
43
|
+
// Only serialized read projections set this view; never an independently scheduled task.
|
|
44
|
+
let view = {
|
|
45
|
+
tip: 0n, walked: 0n, deny: new Set(options.deny),
|
|
46
|
+
};
|
|
47
|
+
const routes = registryRoutes({
|
|
48
|
+
store, chain: options.chain, channel: options.channel,
|
|
49
|
+
tipRound: () => view.tip, walkedRound: () => view.walked, deny: () => view.deny,
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
handlers: [[options.channel, async (delivery) => {
|
|
53
|
+
// SDK positions are canonical strings in the node Int range. The registry's domain
|
|
54
|
+
// logic uses bigint rounds and safe numeric indices internally, never on the wire.
|
|
55
|
+
await handler.handle({ ...delivery, round: BigInt(delivery.round), index: Number(delivery.index) });
|
|
56
|
+
}]],
|
|
57
|
+
afterWalk({ tip }) {
|
|
58
|
+
handler.takeAccepted();
|
|
59
|
+
// The owner invokes this hook, including quiet walks. No DB handle escapes to a timer.
|
|
60
|
+
sweepOnce(store, BigInt(tip), deps.say);
|
|
61
|
+
},
|
|
62
|
+
read(request) {
|
|
63
|
+
const r = readRequest(request);
|
|
64
|
+
view = { tip: BigInt(r.tip), walked: BigInt(r.walked), deny: new Set(r.deny ?? options.deny) };
|
|
65
|
+
const url = new URL(r.path, "http://registry.local");
|
|
66
|
+
const route = routes.find(candidate => candidate.prefix
|
|
67
|
+
? url.pathname.startsWith(candidate.path) : url.pathname === candidate.path);
|
|
68
|
+
return route ? route.handle(url) : { status: 404, json: { error: "not_found" } };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Trusted local factory: its options are cloneable deployment settings, never remote plugin
|
|
75
|
+
* discovery or signing keys. Loading the module does not install it or touch the node. */
|
|
76
|
+
export default function registryWorkerModule(value) {
|
|
77
|
+
if (!value || typeof value !== "object")
|
|
78
|
+
throw new Error("registry worker options are required");
|
|
79
|
+
const o = value;
|
|
80
|
+
if (typeof o.chain !== "string" || typeof o.channel !== "string" || typeof o.operator !== "string" ||
|
|
81
|
+
typeof o.chainUrl !== "string" || (o.contentUrl !== null && typeof o.contentUrl !== "string") ||
|
|
82
|
+
!Number.isSafeInteger(o.maxLeaseRounds) || o.maxLeaseRounds < 1 ||
|
|
83
|
+
!Array.isArray(o.deny) || o.deny.length > 1024 || o.deny.some(id => typeof id !== "string"))
|
|
84
|
+
throw new Error("invalid registry worker options");
|
|
85
|
+
const options = o;
|
|
86
|
+
return createRegistryWorkerModule(options, registryDependencies(options, message => console.log(message)));
|
|
87
|
+
}
|