@mcsherrylabs/evolver-mcp 0.9.5 → 0.10.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/deploy/.dockerignore +8 -0
- package/deploy/Dockerfile +3 -3
- package/deploy/config/node-join.sh +32 -74
- package/deploy/config/node.conf +5 -47
- package/deploy/docker-compose.yml +97 -81
- package/deploy/processor/.dockerignore +8 -0
- package/deploy/processor/Dockerfile +14 -0
- package/deploy/processor/entrypoint.mjs +122 -0
- package/deploy/release.json +8 -0
- package/dist/bootstrap.d.ts +4 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +44 -5
- package/dist/bootstrap.js.map +1 -1
- package/dist/config.d.ts +5 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +44 -11
- package/dist/config.js.map +1 -1
- package/dist/inbox.d.ts +4 -26
- package/dist/inbox.d.ts.map +1 -1
- package/dist/inbox.js +43 -80
- package/dist/inbox.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +37 -2
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +261 -101
- package/dist/install.js.map +1 -1
- package/dist/processor.d.ts +14 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +46 -0
- package/dist/processor.js.map +1 -0
- package/dist/tools.d.ts +3 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +48 -13
- package/dist/tools.js.map +1 -1
- package/package.json +6 -3
package/deploy/Dockerfile
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
# idea of `/workspace` is a different directory than the container's, so `./config` is empty and
|
|
9
9
|
# the node boots with `/config/node-join.sh: No such file`. Baking removes the path entirely.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
ARG
|
|
13
|
-
FROM ${
|
|
11
|
+
# Explicit coordinated candidate: v0.24.7 lacks processor-state observation.
|
|
12
|
+
ARG EVOLVER_NODE_IMAGE
|
|
13
|
+
FROM ${EVOLVER_NODE_IMAGE}
|
|
14
14
|
|
|
15
15
|
# The static, chain-identity configs. node.conf references these by absolute path and
|
|
16
16
|
# node-join.sh execs `-Dconfig.file=/config/node.conf`, so /config is their fixed home.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Claim-then-boot (evolver feature 102)
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
2
|
+
# Claim-then-boot (evolver feature 102) for an explicitly managed follower.
|
|
3
|
+
# Static config is baked into the candidate image. The separately initialized subscription
|
|
4
|
+
# directory is mounted read-only; the installer provisions/persists APP_NODE_ID before boot.
|
|
5
|
+
# Ordinary entry never initializes subscription preferences or adopts existing valued state.
|
|
6
6
|
#
|
|
7
7
|
# FIRST boot: generate this node's keys LOCALLY (they never leave this volume),
|
|
8
8
|
# faucet-claim its identity on devnet (the committee node sponsors and funds it — no
|
|
@@ -10,6 +10,22 @@
|
|
|
10
10
|
# source it and start as a non-genesis follower, syncing the chain from block 0.
|
|
11
11
|
set -eu
|
|
12
12
|
|
|
13
|
+
# Same read-only guard on normal boot and the owned-fixture preflight. It NEVER initializes,
|
|
14
|
+
# rewrites or merges a declaration; canonical fragment validation belongs to the shared SDK.
|
|
15
|
+
require_subscriptions() {
|
|
16
|
+
if [ ! -r "$1/channels.conf" ] ||
|
|
17
|
+
[ "$(cat "$1/channels.conf.initialized" 2>/dev/null || true)" != "evolver-subscriptions-v1" ]; then
|
|
18
|
+
echo "[node-join] FATAL: initialized subscription declaration missing; refusing to reseed"
|
|
19
|
+
return 1
|
|
20
|
+
fi
|
|
21
|
+
}
|
|
22
|
+
if [ "${1:-}" = "--check-subscriptions" ]; then
|
|
23
|
+
: "${2:?owned subscription directory required}"
|
|
24
|
+
require_subscriptions "$2"
|
|
25
|
+
exit $?
|
|
26
|
+
fi
|
|
27
|
+
require_subscriptions /subscriptions
|
|
28
|
+
|
|
13
29
|
: "${DEVNET_API:?DEVNET_API required (the faucet base url, e.g. https://devnet.mcsherrylabs.com)}"
|
|
14
30
|
|
|
15
31
|
# ⚠️ CHECKED HERE, not left to HOCON. `preferred-url` is a REQUIRED substitution in the
|
|
@@ -51,6 +67,12 @@ else
|
|
|
51
67
|
echo "[node-join] node id '$NODE_ID' chosen and persisted"
|
|
52
68
|
fi
|
|
53
69
|
|
|
70
|
+
: "${APP_NODE_ID:?managed deployment requires its saved explicit node id}"
|
|
71
|
+
if [ "$NODE_ID" != "$APP_NODE_ID" ]; then
|
|
72
|
+
echo "[node-join] FATAL: saved node id differs from managed binding; refusing to claim or boot"
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
|
|
54
76
|
if [ ! -f "$CLAIM" ]; then
|
|
55
77
|
# A CLAIM WHOSE CONFIRMATION TIMED OUT MUST NOT BE RE-SENT BLIND.
|
|
56
78
|
#
|
|
@@ -159,76 +181,12 @@ set -a
|
|
|
159
181
|
set +a
|
|
160
182
|
|
|
161
183
|
mkdir -p /data/chain
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
#
|
|
169
|
-
# ONE decision, in ONE place, keyed on the SAME switch the app uses. The node config
|
|
170
|
-
# `include required`s this file, so if the write below ever fails the node refuses to
|
|
171
|
-
# start instead of quietly declaring nothing (HOCON ignores an absent optional include,
|
|
172
|
-
# and a node that declares no channels retains no bodies, streams nothing and delivers
|
|
173
|
-
# nothing — while logging nothing unusual).
|
|
174
|
-
#
|
|
175
|
-
# The registry's channel is declared IFF a registry is deployed here. Declaring it
|
|
176
|
-
# otherwise wedges this node's apply on the first transaction written to a channel it is
|
|
177
|
-
# not a DA member of: nothing stores the body, nothing can pull it, and apply is
|
|
178
|
-
# forward-only (evolver#83).
|
|
179
|
-
# ⚠️ A SUBSCRIPTION IS HOW THIS COMPONENT GAINS A CAPABILITY, not a configuration detail. Each
|
|
180
|
-
# ledger declared here is something the component can then process, index and act on — the set grows
|
|
181
|
-
# as its owner's interests do.
|
|
182
|
-
#
|
|
183
|
-
# The precondition is not optional: this node must be able to OBTAIN the bodies. A channel with a DA
|
|
184
|
-
# policy serves them from its DA group, so this node needs a live connection to a member — otherwise
|
|
185
|
-
# apply blocks on the first body it cannot fetch, PERMANENTLY, because apply is forward-only
|
|
186
|
-
# (evolver#83). For `ce.meerkat.announcements` that group is `evolvermeerkatA286174reg`, whose only
|
|
187
|
-
# member is `appnode046550` — which this node discovers and dials.
|
|
188
|
-
#
|
|
189
|
-
# ⚠️ THIS LIST IS MERGED, NEVER REPLACED. `meerkat_add_channel` writes the same file when the
|
|
190
|
-
# agent subscribes to something, so the file is the union of two writers: this deployment's
|
|
191
|
-
# channel, which is a property of the deployment, and whatever the agent has since subscribed
|
|
192
|
-
# to, which is a property of its work. A boot that rewrote the list from EVOLVER_CHANNEL alone
|
|
193
|
-
# would silently revoke every capability the agent had gained — including the reply channel it
|
|
194
|
-
# is waiting on — and the agent has no way to notice: the file it wrote is simply gone.
|
|
195
|
-
#
|
|
196
|
-
# ⚠️ AND IT IS WRITTEN tmp-THEN-RENAME, chmod 666. Two uids write this one file, and a plain
|
|
197
|
-
# `>` redirect must open the EXISTING file for writing: whichever of them creates it owns a
|
|
198
|
-
# 0644 the other then cannot truncate, for as long as the file lives. That is not theoretical —
|
|
199
|
-
# it wedged a node in a restart loop (`cannot create /data/chain/channels.conf: Permission
|
|
200
|
-
# denied`), and because `include required` makes the node refuse to start rather than declare
|
|
201
|
-
# nothing, the whole stack came down with it. `mv` needs write on the DIRECTORY, which the
|
|
202
|
-
# o+rwx above grants both, so the rename lands whoever owns the old file; the 666 then lets
|
|
203
|
-
# the agent's direct `writeFileSync` land too.
|
|
204
|
-
# ⚠️ THE MCP CONSUMES NO CHANNEL STREAM, so the node declares NO channel by default
|
|
205
|
-
# (`EVOLVER_CHANNEL` defaults empty). The `include required` of `channels.conf` in node.conf still
|
|
206
|
-
# needs the file to EXIST, so an empty list is written below rather than the file being omitted.
|
|
207
|
-
_ch="${EVOLVER_CHANNEL:-}"
|
|
208
|
-
_conf=/data/chain/channels.conf
|
|
209
|
-
_existing=""
|
|
210
|
-
if [ -f "$_conf" ]; then
|
|
211
|
-
# the HOCON this file ever contains is one list on one line, written by this script or by
|
|
212
|
-
# pi-meerkat's formatHocon — strip to the bare comma-separated ids.
|
|
213
|
-
_existing=$(sed -n 's/.*\[\(.*\)\].*/\1/p' "$_conf" | tr -d ' "')
|
|
214
|
-
fi
|
|
215
|
-
_merged="$_ch"
|
|
216
|
-
for _c in $(echo "$_existing" | tr ',' ' '); do
|
|
217
|
-
[ -n "$_c" ] || continue
|
|
218
|
-
[ "$_c" = "$_ch" ] && continue
|
|
219
|
-
_merged="$_merged,$_c"
|
|
220
|
-
done
|
|
221
|
-
# An empty channel set becomes `[]`, not `[""]` — the node declares no subscribed channel, which
|
|
222
|
-
# is correct for a reader that owns no channel stream.
|
|
223
|
-
if [ -z "$_merged" ] || [ -z "${_merged##*,}" ]; then
|
|
224
|
-
_body="[]"
|
|
225
|
-
else
|
|
226
|
-
_body="[ $(echo "$_merged" | sed 's/,/", "/g; s/^/"/; s/$/"/') ]"
|
|
227
|
-
fi
|
|
228
|
-
echo "[node-join] declaring channels: $_merged"
|
|
229
|
-
printf 'ce.evolver.subscribed-channels = %s\n' "$_body" > "$_conf.node.tmp"
|
|
230
|
-
chmod 666 "$_conf.node.tmp"
|
|
231
|
-
mv -f "$_conf.node.tmp" "$_conf"
|
|
184
|
+
# Config-only directory is mounted read-only into this node. Only the shared SDK controller
|
|
185
|
+
# writes it. Ordinary boot NEVER merges environment/module defaults or initializes a missing file.
|
|
186
|
+
# SDK initialization is an explicit fresh-resource command; its marker survives an empty set.
|
|
187
|
+
# Already required before any identity/claim effects at entry.
|
|
188
|
+
# Keep keys/chain private: no agent/container writes this node's database or fragment.
|
|
189
|
+
chmod 700 /data/chain
|
|
232
190
|
|
|
233
191
|
# ── WHICH PEERS THIS NODE DIALS ───────────────────────────────────────────
|
|
234
192
|
#
|
package/deploy/config/node.conf
CHANGED
|
@@ -120,54 +120,12 @@ ce.evolver.consensus {
|
|
|
120
120
|
# bodies only for the channels it lists, so an undeclared channel's message is applied
|
|
121
121
|
# in the slice and never offered — the write looks fine and the stream stays silent,
|
|
122
122
|
# which is the failure the app's step 7 names explicitly.
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
# In processing mode apply is DEFERRED and paced by the app polling /api/channel/next, so
|
|
126
|
-
# committed reads answer "as of MY cursor" rather than "as of the chain". With no channels
|
|
127
|
-
# declared and no consumer draining — which is exactly this node — the cursor never advances and
|
|
128
|
-
# NOTHING IS EVER APPLIED: the node syncs every block and still answers `not_found` for an identity
|
|
129
|
-
# the chain committed. Observed here: its own identity, committed at block 7225 and verified on
|
|
130
|
-
# chain, was unknown to it at height 7231.
|
|
131
|
-
#
|
|
132
|
-
# ⚠️ ON, NOW THAT SOMETHING DRAINS THIS NODE. The rule is not "readers off, apps on" — it is that
|
|
133
|
-
# processing mode paces apply from whatever drains /api/channel/next, so it is correct exactly when
|
|
134
|
-
# there IS a drainer and catastrophic when there is not (with none, this node synced every block and
|
|
135
|
-
# still answered `not_found` for an identity it had itself committed).
|
|
136
|
-
#
|
|
137
|
-
# The meerkat registry beside it is that drainer. Its polling is what advances apply, so the agent's
|
|
138
|
-
# reads stay correct — as of that cursor, which is what a component reading its own processed state
|
|
139
|
-
# means by correct.
|
|
123
|
+
# Processing is enabled only by the paired managed composition with its persistent zero-module
|
|
124
|
+
# shared SDK consumer. MCP sessions never drain. Independent remote nodes remain unmanaged.
|
|
140
125
|
ce.evolver.execution.enabled = ${?EVOLVER_EXECUTION_ENABLED}
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
# It cannot be written here, because WHICH channels this node declares depends on whether
|
|
145
|
-
# a registry is deployed beside it — and declaring one that is not wanted is not harmless:
|
|
146
|
-
#
|
|
147
|
-
# the registry's channel is declared by the node so it can STORE and ATTEST bodies
|
|
148
|
-
# (`onBody` drops a flooded body for an undeclared channel, and a body it never stored
|
|
149
|
-
# is one it cannot attest — at m=1 that stops the registry rather than degrading it);
|
|
150
|
-
#
|
|
151
|
-
# but a node that declares a channel it is NOT a DA member of STALLS at every transaction
|
|
152
|
-
# on that channel. `channelSubscribes` is false, so it stores no body, and nothing can be
|
|
153
|
-
# pulled either (the DA group's members are identities, not this node's peers), so the
|
|
154
|
-
# position answers `body-pending` and the cursor sits ON it — applied state, and every
|
|
155
|
-
# committed read, frozen at the block before it.
|
|
156
|
-
#
|
|
157
|
-
# ⚠️ NOT a permanent wedge of the NODE, and evolver#83 was corrected on exactly this
|
|
158
|
-
# point: the runner advances the cursor atomically with the apply and the body lookup
|
|
159
|
-
# happens afterwards, so a CONSUMER that asks for `index + 1` walks past and the skipped
|
|
160
|
-
# transaction is still applied in the slice. What is permanent is the LOSS of that
|
|
161
|
-
# message. So the stall lasts as long as the consumer retries the same position — which
|
|
162
|
-
# the SDK's `drainChannel` does, correctly, for at-most-once delivery — and it is
|
|
163
|
-
# reachable by anyone willing to spend 10 credits on an id-only write to an unclaimed
|
|
164
|
-
# name. Declaring only what this deployment participates in avoids the whole question.
|
|
165
|
-
#
|
|
166
|
-
# In demo mode this node is not in the registry channel's DA group — nothing here claimed
|
|
167
|
-
# it — so declaring it was exactly that hazard. The switch is PROCESSOR_HTTP_PORT (named
|
|
168
|
-
# REGISTRY_HTTP_PORT before meerkat v0.6.0), the same one the app uses to decide it is a
|
|
169
|
-
# service, so the two cannot disagree.
|
|
170
|
-
include required(file("/data/chain/channels.conf"))
|
|
126
|
+
# Shared SDK controller is the SOLE writer; node sees the whole config-only directory read-only.
|
|
127
|
+
# Ordinary boot/upgrade cannot merge defaults, resurrect removals or seed a lost declaration.
|
|
128
|
+
include required(file("/subscriptions/channels.conf"))
|
|
171
129
|
# `required` for the same reason: an absent optional include is IGNORED, and a node
|
|
172
130
|
# that silently fell back to the 64/64 defaults would dial the discovery table.
|
|
173
131
|
include required(file("/data/chain/peers.conf"))
|
|
@@ -1,120 +1,136 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
# JOINS and syncs the live devnet chain (it is a non-genesis follower, not a new chain), and the
|
|
5
|
-
# env contract an agent runtime supplies when it launches `npx -y @mcsherrylabs/evolver-mcp`
|
|
6
|
-
# against that node.
|
|
7
|
-
#
|
|
8
|
-
# CONTENT LIVES HERE, NOT ON DEVNET. The MCP posts to and reads from THIS node's own content
|
|
9
|
-
# cache (`ce.evolver.content.http-port = 8450`) via `EVOLVER_CONTENT_URL=http://node:8450`. The
|
|
10
|
-
# devnet URL is only the FAUCET origin for the node's first-boot identity claim; every other call
|
|
11
|
-
# is against the local node (`node:8440` chain API, `node:8450` content).
|
|
12
|
-
#
|
|
13
|
-
# GROUNDING — mirror /workspace/evolver/deployments/claude-agent/, the canonical follower pattern:
|
|
14
|
-
# - genesis.devnet.conf is a BYTE-IDENTICAL copy of the devnet genesis (committee + seed nodes).
|
|
15
|
-
# It is the chain's identity; never edit it.
|
|
16
|
-
# - node-join.sh is the claim-then-boot entrypoint: mint node keys locally, faucet-claim the
|
|
17
|
-
# identity on devnet (the committee sponsors it), persist the claim, then start as a non-genesis
|
|
18
|
-
# follower syncing from block 0.
|
|
19
|
-
# - node.conf includes genesis + peers.conf + channels.conf; content 8450, chain API 8440, TLS
|
|
20
|
-
# 8441 (peer content fetches), gRPC 9000.
|
|
21
|
-
#
|
|
22
|
-
# THE MCP IS STDIO, NOT A LONG-RUNNING SERVICE. There is no published evolver-mcp image; an agent
|
|
23
|
-
# runtime (Claude Code / Codex) launches it per session as a child process. The `mcp` service below
|
|
24
|
-
# only documents the exact env contract (see the comment on it). For dockerized use, build from
|
|
25
|
-
# docs/mcp.Dockerfile and exec `docker compose run --rm mcp npx -y @mcsherrylabs/evolver-mcp …`.
|
|
26
|
-
|
|
1
|
+
# Managed paired composition. Run fresh initialization explicitly through `evolver-mcp install
|
|
2
|
+
# --new`; ordinary up/restart NEVER runs processor-init or seeds subscriptions.
|
|
3
|
+
# Candidate node image, SDK archive/hash and known genesis/node binding are mandatory.
|
|
27
4
|
name: evolver-mcp
|
|
28
5
|
|
|
6
|
+
x-processor-image: &processor-image
|
|
7
|
+
image: ${EVOLVER_PROCESSOR_IMAGE:?explicit local candidate processor image tag required}
|
|
8
|
+
build:
|
|
9
|
+
context: ./processor
|
|
10
|
+
dockerfile: Dockerfile
|
|
11
|
+
additional_contexts:
|
|
12
|
+
sdk-candidate: ${EVOLVER_PROCESSOR_SDK_DIR:?directory containing exact sdk.tgz required}
|
|
13
|
+
args:
|
|
14
|
+
EVOLVER_PROCESSOR_SDK_SHA256: ${EVOLVER_PROCESSOR_SDK_SHA256:?exact SDK archive SHA256 required}
|
|
15
|
+
|
|
29
16
|
services:
|
|
30
|
-
#
|
|
17
|
+
# Explicit fresh-resource operation only; NEVER a dependency of node/processor/up.
|
|
18
|
+
processor-init:
|
|
19
|
+
<<: *processor-image
|
|
20
|
+
profiles: ["initialize-only"]
|
|
21
|
+
user: "0:0"
|
|
22
|
+
command: ["initialize"]
|
|
23
|
+
environment:
|
|
24
|
+
EVOLVER_OPERATOR_UID: ${EVOLVER_OPERATOR_UID:?operator uid required}
|
|
25
|
+
EVOLVER_OPERATOR_GID: ${EVOLVER_OPERATOR_GID:?operator gid required}
|
|
26
|
+
EVOLVER_INITIAL_CHANNELS: ${EVOLVER_INITIAL_CHANNELS:-[]}
|
|
27
|
+
volumes:
|
|
28
|
+
- subscriptions:/subscriptions
|
|
29
|
+
- processor-state:/processor-state
|
|
30
|
+
restart: "no"
|
|
31
|
+
network_mode: none
|
|
32
|
+
|
|
31
33
|
node:
|
|
32
|
-
|
|
33
|
-
# mount: the daemon resolves host paths, and `install` may run in a container whose /workspace
|
|
34
|
-
# differs from the host's. `--build` is passed by the install so a fresh machine builds it.
|
|
34
|
+
container_name: ${EVOLVER_MANAGED_NODE_CONTAINER:?saved fixed node container required}
|
|
35
35
|
build:
|
|
36
36
|
context: .
|
|
37
37
|
dockerfile: Dockerfile
|
|
38
38
|
args:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
image: evolver-mcp-node:latest
|
|
39
|
+
EVOLVER_NODE_IMAGE: ${EVOLVER_NODE_IMAGE:?candidate node image with processor-state observation required}
|
|
40
|
+
image: ${EVOLVER_MANAGED_NODE_IMAGE:?explicit local config-bearing candidate node image tag required}
|
|
42
41
|
restart: unless-stopped
|
|
42
|
+
group_add: ["${EVOLVER_OPERATOR_GID:?operator group required}"]
|
|
43
43
|
environment:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
APP_NAME: evolverscp
|
|
47
|
-
# FAUCET ONLY — the first-boot claim target. Not the chain URL the MCP talks to.
|
|
44
|
+
APP_NODE_ID: ${APP_NODE_ID:?saved known node id required}
|
|
45
|
+
APP_NAME: evolvermcp
|
|
48
46
|
DEVNET_API: ${DEVNET_API:-https://devnet.mcsherrylabs.com}
|
|
49
|
-
# THE MCP READS NO CHANNEL STREAM, so the node declares NO channel. Empty on purpose: a node
|
|
50
|
-
# that declares a channel it is not a DA member of stalls its apply (evolver#83).
|
|
51
|
-
EVOLVER_CHANNEL: ${EVOLVER_CHANNEL:-}
|
|
52
|
-
# ⚠️ PIN A FRONT/SEED NODE, NOT THE COMMITTEE. `devnet1-node0` runs `allowed-inbound = 2`
|
|
53
|
-
# with both slots reserved; pinning it cannot work (every dial is refused).
|
|
54
47
|
EVOLVER_UPSTREAM_NODE_ID: ${EVOLVER_UPSTREAM_NODE_ID:-evolvermeerkatnode920080}
|
|
55
48
|
EVOLVER_DA_PEERS: ${EVOLVER_DA_PEERS:-schednode2}
|
|
56
49
|
EVOLVER_ALLOWED_OUTBOUND: ${EVOLVER_ALLOWED_OUTBOUND:-16}
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
# with no channel owns no stream, so leave this unset (the config's `?` makes it optional).
|
|
60
|
-
#
|
|
61
|
-
# (Deliberately ABSENT, not "false": the config reads `ce.evolver.execution.enabled = ${?EVOLVER_EXECUTION_ENABLED}`
|
|
62
|
-
# — unset means "off", which is exactly a reader node.)
|
|
63
|
-
# p2p transport on 7100, NOT 7000 (most devnet discovery entries advertise 127.0.0.1:7000,
|
|
64
|
-
# which a node on 7000 would dial into its own loopback).
|
|
50
|
+
# Enabled ONLY in this node + persistent shared consumer composition.
|
|
51
|
+
EVOLVER_EXECUTION_ENABLED: "true"
|
|
65
52
|
EVOLVER_NODE_PORT: "7100"
|
|
66
53
|
volumes:
|
|
67
54
|
- node-data:/data
|
|
68
|
-
|
|
69
|
-
|
|
55
|
+
# Whole DIRECTORY, read-only for node; atomic controller rename is visible on restart.
|
|
56
|
+
- subscriptions:/subscriptions:ro
|
|
70
57
|
entrypoint: ["/bin/sh", "/config/node-join.sh"]
|
|
71
58
|
healthcheck:
|
|
72
|
-
|
|
59
|
+
# Process/API health, NOT collection readiness (the processor /ready checks that separately).
|
|
60
|
+
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8440/status >/dev/null || exit 1"]
|
|
73
61
|
interval: 10s
|
|
74
62
|
timeout: 5s
|
|
75
63
|
retries: 30
|
|
76
64
|
start_period: 30s
|
|
77
|
-
# ⚠️ PUBLISHED LOOPBACK-ONLY (B1: the frictionless single-box install path). The host-side
|
|
78
|
-
# `npx … install` bootstrap reaches the node at http://127.0.0.1:${EVOLVER_HOST_HTTP_PORT:-8440}
|
|
79
|
-
# (chain API) and http://127.0.0.1:${EVOLVER_HOST_CONTENT_PORT:-8450} (content) after `up -d
|
|
80
|
-
# node`. Nothing leaves the machine: `host_ip: 127.0.0.1` keeps both listeners local, and the
|
|
81
|
-
# node's p2p/TLS listeners stay unpublished (they are for PEERS, reached over the compose
|
|
82
|
-
# network's DNS).
|
|
83
|
-
#
|
|
84
|
-
# TWO NODES ON ONE HOST: the node ALWAYS binds 8440/8450 internally (node.conf, baked into the
|
|
85
|
-
# image — not env-overridable), so `target` stays fixed at those; only the PUBLISHED host port
|
|
86
|
-
# varies. Give a second install non-clashing host ports via
|
|
87
|
-
# EVOLVER_HOST_HTTP_PORT=8442 EVOLVER_HOST_CONTENT_PORT=8451 COMPOSE_PROJECT_NAME=evolver-mcp2
|
|
88
|
-
# and `install` must be run with the SAME two values (it polls + bootstraps against them).
|
|
89
65
|
ports:
|
|
90
66
|
- target: 8440
|
|
91
67
|
published: "${EVOLVER_HOST_HTTP_PORT:-8440}"
|
|
92
68
|
host_ip: 127.0.0.1
|
|
93
|
-
protocol: tcp
|
|
94
69
|
- target: 8450
|
|
95
70
|
published: "${EVOLVER_HOST_CONTENT_PORT:-8450}"
|
|
96
71
|
host_ip: 127.0.0.1
|
|
97
|
-
protocol: tcp
|
|
98
72
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
image: node:22
|
|
106
|
-
profiles: ["run-only"] # not started by `up -d`; a run-target only
|
|
73
|
+
processor:
|
|
74
|
+
<<: *processor-image
|
|
75
|
+
# One fixed service/owner per node. Never scale this service; SDK owner lock rejects a second.
|
|
76
|
+
container_name: ${EVOLVER_MANAGED_PROCESSOR_CONTAINER:?saved fixed processor container required}
|
|
77
|
+
user: "${EVOLVER_OPERATOR_UID:?operator uid required}:${EVOLVER_OPERATOR_GID:?operator gid required}"
|
|
78
|
+
restart: unless-stopped
|
|
107
79
|
environment:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
80
|
+
EVOLVER_CHAIN_ID: ${EVOLVER_CHAIN_ID:?known genesis block id required}
|
|
81
|
+
APP_NODE_ID: ${APP_NODE_ID:?saved known node id required}
|
|
82
|
+
EVOLVER_MANAGED_NODE_CONTAINER: ${EVOLVER_MANAGED_NODE_CONTAINER:?fixed target required}
|
|
83
|
+
volumes:
|
|
84
|
+
- subscriptions:/subscriptions
|
|
85
|
+
- processor-state:/processor-state
|
|
86
|
+
# Private operator directory; no signing keys or node database mounted into this service.
|
|
87
|
+
- type: bind
|
|
88
|
+
source: ${EVOLVER_CONTROL_HOST_DIR:?daemon-host private operator directory required}
|
|
89
|
+
target: /control
|
|
90
|
+
bind:
|
|
91
|
+
create_host_path: false
|
|
92
|
+
# Privileged composition ONLY. MCP has no Docker socket, config fragment, or restart target.
|
|
93
|
+
- /var/run/docker.sock:/var/run/docker.sock
|
|
94
|
+
group_add: ["${EVOLVER_DOCKER_GID:?Docker socket group id required for fixed restarter}"]
|
|
114
95
|
depends_on:
|
|
115
96
|
node:
|
|
97
|
+
# Private reconciliation control must start even while the configured node is offline.
|
|
98
|
+
# The SDK runtime gates ALL pulls on fresh binding/processing/ACTIVE observations.
|
|
99
|
+
condition: service_started
|
|
100
|
+
healthcheck:
|
|
101
|
+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/health').then(r=>process.exit(r.status===200?0:1)).catch(()=>process.exit(1))"]
|
|
102
|
+
interval: 10s
|
|
103
|
+
timeout: 5s
|
|
104
|
+
retries: 12
|
|
105
|
+
ports:
|
|
106
|
+
- target: 8090
|
|
107
|
+
published: "${EVOLVER_HOST_INBOX_PORT:-8090}"
|
|
108
|
+
host_ip: 127.0.0.1
|
|
109
|
+
|
|
110
|
+
# Per-session reader/control launch context only; never started by ordinary up.
|
|
111
|
+
# Supply only agent state at /state separately. NEVER mount subscriptions/node-data/Docker here.
|
|
112
|
+
mcp:
|
|
113
|
+
image: node:22.23.3-bookworm-slim
|
|
114
|
+
user: "${EVOLVER_OPERATOR_UID:?operator uid required}:${EVOLVER_OPERATOR_GID:?operator gid required}"
|
|
115
|
+
profiles: ["run-only"]
|
|
116
|
+
environment:
|
|
117
|
+
EVOLVER_MCP_CONFIG: /state/config.json
|
|
118
|
+
EVOLVER_CONTENT_URL: http://node:8450
|
|
119
|
+
EVOLVER_KEY_PATH: /state/<agent-identity>.key
|
|
120
|
+
EVOLVER_KEY_TYPE: bls
|
|
121
|
+
volumes:
|
|
122
|
+
- type: bind
|
|
123
|
+
source: ${EVOLVER_CONTROL_HOST_DIR:?daemon-host private operator directory required}
|
|
124
|
+
target: /control
|
|
125
|
+
read_only: true
|
|
126
|
+
bind:
|
|
127
|
+
create_host_path: false
|
|
128
|
+
depends_on:
|
|
129
|
+
processor:
|
|
116
130
|
condition: service_healthy
|
|
117
131
|
restart: "no"
|
|
118
132
|
|
|
119
133
|
volumes:
|
|
120
134
|
node-data:
|
|
135
|
+
subscriptions:
|
|
136
|
+
processor-state:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
# Exact runtime and hash-verified SDK archive, prepared from the pinned release by CI.
|
|
3
|
+
FROM node:22.23.3-bookworm-slim
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
ARG EVOLVER_PROCESSOR_SDK_SHA256
|
|
6
|
+
COPY --from=sdk-candidate /sdk.tgz /candidate/sdk.tgz
|
|
7
|
+
RUN --mount=type=cache,id=evolver-release-npm,target=/root/.npm test -n "$EVOLVER_PROCESSOR_SDK_SHA256" && \
|
|
8
|
+
printf '%s /candidate/sdk.tgz\n' "$EVOLVER_PROCESSOR_SDK_SHA256" | sha256sum -c - && \
|
|
9
|
+
printf '{"private":true,"type":"module"}\n' > package.json && \
|
|
10
|
+
npm install --omit=dev --ignore-scripts --no-save /candidate/sdk.tgz && \
|
|
11
|
+
rm -rf /candidate
|
|
12
|
+
COPY entrypoint.mjs /app/entrypoint.mjs
|
|
13
|
+
ENTRYPOINT ["node", "/app/entrypoint.mjs"]
|
|
14
|
+
CMD ["serve"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Deployment composition only. All collection/storage/config/restart algorithms live in the SDK.
|
|
2
|
+
import { NodeClient } from "@mcsherrylabs/evolver-client";
|
|
3
|
+
import { initializeSubscriptions, subscriptionConfig, startProcessor, subscriptionController,
|
|
4
|
+
startReadServer, startControlServer, dockerNodeRestarter, nodeObserver, nodePuller,
|
|
5
|
+
deadlineFetch, position, PROCESSOR_NODE_VERSION, recoverProcessorOwnerLock } from "@mcsherrylabs/evolver-client/processor";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { mkdir, chown, chmod, readdir } from "node:fs/promises";
|
|
8
|
+
|
|
9
|
+
const STATE_PATH = "/processor-state/inbox.sqlite";
|
|
10
|
+
|
|
11
|
+
export function settingsFrom(env) {
|
|
12
|
+
const binding = { chainId: env.EVOLVER_CHAIN_ID, nodeId: env.APP_NODE_ID };
|
|
13
|
+
// The binding is operator-provisioned and is independently checked by every SDK observation.
|
|
14
|
+
const observe = nodeObserver({ baseUrl: "http://node:8440", binding, timeoutMs: 5000 });
|
|
15
|
+
return { binding, observe, configPath: "/subscriptions/channels.conf", statePath: STATE_PATH,
|
|
16
|
+
controlPath: "/control/control.sock", port: 8090,
|
|
17
|
+
containerName: env.EVOLVER_MANAGED_NODE_CONTAINER };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function initialize(env) {
|
|
21
|
+
const uid = Number(env.EVOLVER_OPERATOR_UID), gid = Number(env.EVOLVER_OPERATOR_GID);
|
|
22
|
+
if (![uid, gid].every(id => Number.isSafeInteger(id) && id >= 1))
|
|
23
|
+
throw new Error("fresh initializer requires explicit non-root operator uid/gid");
|
|
24
|
+
for (const dir of ["/subscriptions", "/processor-state"]) {
|
|
25
|
+
if ((await readdir(dir)).length !== 0) throw new Error("fresh initialization refuses nonempty managed volumes; no migration/reseed");
|
|
26
|
+
}
|
|
27
|
+
for (const dir of ["/subscriptions", "/processor-state"]) {
|
|
28
|
+
await mkdir(dir, { recursive: true }); await chown(dir, uid, gid); await chmod(dir, 0o750);
|
|
29
|
+
}
|
|
30
|
+
process.setgroups([]); process.setgid(gid); process.setuid(uid);
|
|
31
|
+
const defaults = JSON.parse(env.EVOLVER_INITIAL_CHANNELS ?? "[]");
|
|
32
|
+
// This command is invoked ONLY by explicit fresh-install initialization, never service startup.
|
|
33
|
+
await initializeSubscriptions("/subscriptions/channels.conf", defaults);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function recoverManagedOwner(settings, args, say = console.error) {
|
|
37
|
+
if (args.length !== 1 || args[0] !== "--force")
|
|
38
|
+
throw new Error("usage: recover-owner --force (only after stopping ALL collectors and disabling automatic restarts)");
|
|
39
|
+
say("[processor] WARNING: --force acknowledges ALL collectors are stopped and automatic restarts disabled; this command cannot verify that.");
|
|
40
|
+
const result = recoverProcessorOwnerLock({ path: settings.statePath, force: true });
|
|
41
|
+
say(`[processor] ${result.removed ? "Removed owner lock" : "No owner lock present"}: ${result.marker}. Database/WAL, progress, subscriptions and keys unchanged. Start ONE collector normally to acquire a new lock.`);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function startManagedProcessor(settings, sdk = {
|
|
46
|
+
NodeClient, subscriptionConfig, startProcessor, subscriptionController, startReadServer,
|
|
47
|
+
startControlServer, dockerNodeRestarter, nodePuller, deadlineFetch,
|
|
48
|
+
}) {
|
|
49
|
+
const config = sdk.subscriptionConfig(settings.configPath);
|
|
50
|
+
await config.read(); // missing initialized declaration is a refusal, never defaults/[]
|
|
51
|
+
const client = new sdk.NodeClient({ baseUrl: "http://node:8440", fetch: sdk.deadlineFetch(5000,
|
|
52
|
+
(input, init) => fetch(input, { ...init, redirect: "error" })) });
|
|
53
|
+
const runtime = await sdk.startProcessor({
|
|
54
|
+
store: { path: settings.statePath, binding: settings.binding, modules: [] },
|
|
55
|
+
modules: [], config, observe: settings.observe, puller: sdk.nodePuller(client),
|
|
56
|
+
tip: async () => position(String(await client.currentRound()), "0").round,
|
|
57
|
+
say: message => console.error(`[processor] ${message}`),
|
|
58
|
+
}).catch(error => {
|
|
59
|
+
if (error?.name === "ProcessorOwnerLockError") {
|
|
60
|
+
console.error(`[processor] ${error.message}`);
|
|
61
|
+
console.error("[processor] MANUAL FORCE RECOVERY REQUIRED if the owner has died. Using the SAME Compose project/env/files/volumes: 1) docker compose stop processor; stop any other collectors and disable all external automatic restarts. 2) Only when fenced: docker compose run --rm --no-deps processor recover-owner --force. 3) docker compose up -d --no-deps processor. An ordinary restart or --force-recreate does NOT clear the lock. Never delete the database/WAL or reinitialize subscriptions.");
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
});
|
|
65
|
+
let read, control, stopping, loopStarted = false;
|
|
66
|
+
function stop() {
|
|
67
|
+
return stopping ??= (async () => {
|
|
68
|
+
const errors = [];
|
|
69
|
+
for (const close of [
|
|
70
|
+
...(control ? [() => control.close()] : []),
|
|
71
|
+
...(read ? [() => read.close()] : []),
|
|
72
|
+
() => runtime.stop(),
|
|
73
|
+
]) {
|
|
74
|
+
try { await close(); } catch (error) { errors.push(error); }
|
|
75
|
+
}
|
|
76
|
+
if (errors.length) throw new AggregateError(errors, "managed processor cleanup failed");
|
|
77
|
+
})();
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
// Control must survive unavailable/mismatched ACTIVE state so identical SET can reconcile.
|
|
81
|
+
// The shared runtime alone decides whether pacing is authorized; health reads never pace.
|
|
82
|
+
const controller = sdk.subscriptionController({ binding: settings.binding, config, runtime,
|
|
83
|
+
observe: settings.observe, restart: sdk.dockerNodeRestarter({ socketPath: "/var/run/docker.sock",
|
|
84
|
+
containerName: settings.containerName }), timeoutMs: 30000 });
|
|
85
|
+
read = await sdk.startReadServer({ store: runtime.store, host: "0.0.0.0", port: settings.port,
|
|
86
|
+
routes: [
|
|
87
|
+
{ path: "/health", handle: async () => ({ status: 200, json: { binding: settings.binding, process: "running" } }) },
|
|
88
|
+
{ path: "/ready", handle: async () => {
|
|
89
|
+
const state = await controller.inspect();
|
|
90
|
+
const ready = loopStarted && state.outcome === "active" && runtime.lastError === null;
|
|
91
|
+
return { status: ready ? 200 : 503, json: { binding: settings.binding, collectionReady: ready,
|
|
92
|
+
subscriptions: state, lastError: runtime.lastError,
|
|
93
|
+
note: "Ready means verified ACTIVE processing subscriptions and the running shared loop has no reported error; not proof of successful first delivery, caught-up, historical replay or availability of pending bodies." } };
|
|
94
|
+
} },
|
|
95
|
+
] });
|
|
96
|
+
control = await sdk.startControlServer({ socketPath: settings.controlPath, mode: 0o660, controller });
|
|
97
|
+
runtime.run();
|
|
98
|
+
loopStarted = true;
|
|
99
|
+
return { stop };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
try { await stop(); }
|
|
102
|
+
catch (cleanup) { throw new AggregateError([e, cleanup], "managed processor startup and cleanup failed"); }
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
108
|
+
if (process.versions.node !== PROCESSOR_NODE_VERSION)
|
|
109
|
+
throw new Error(`managed processor requires Node ${PROCESSOR_NODE_VERSION}`);
|
|
110
|
+
if (process.argv[2] === "initialize") await initialize(process.env);
|
|
111
|
+
else if (process.argv[2] === "recover-owner") recoverManagedOwner({ statePath: STATE_PATH }, process.argv.slice(3));
|
|
112
|
+
else if (process.argv[2] === "serve") {
|
|
113
|
+
const service = await startManagedProcessor(settingsFrom(process.env));
|
|
114
|
+
let stopping = false;
|
|
115
|
+
const stop = () => {
|
|
116
|
+
if (stopping) return;
|
|
117
|
+
stopping = true;
|
|
118
|
+
service.stop().then(() => process.exit(0), error => { console.error(error); process.exit(1); });
|
|
119
|
+
};
|
|
120
|
+
process.once("SIGTERM", stop); process.once("SIGINT", stop);
|
|
121
|
+
} else throw new Error("expected explicit initialize, serve, or recover-owner --force command");
|
|
122
|
+
}
|
package/dist/bootstrap.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NodeClient } from "@mcsherrylabs/evolver-client";
|
|
2
2
|
import type { Identity, ClaimResult } from "@mcsherrylabs/evolver-client";
|
|
3
|
+
import type { ProcessorClientOptions } from "@mcsherrylabs/evolver-client/processor-client";
|
|
3
4
|
export declare const BOOTSTRAP_FLAG = "--bootstrap";
|
|
4
5
|
export declare const RETRYABLE_CLAIM_ERRORS: readonly ["node_not_ready", "queue_full", "confirmation_timeout", "internal"];
|
|
5
6
|
export declare const DEFAULT_RETRY_MAX = 3;
|
|
@@ -99,6 +100,8 @@ export interface BootstrapPlan {
|
|
|
99
100
|
readonly identity: GeneratedIdentity;
|
|
100
101
|
readonly contentUrl: string;
|
|
101
102
|
readonly contentWarning: string;
|
|
103
|
+
/** Explicit operator/managed-installer pairing; never inferred from target/locality. */
|
|
104
|
+
readonly processor?: ProcessorClientOptions;
|
|
102
105
|
/** The command-line working directory (for the `.target`/`.env*` refusal of `--target local`). */
|
|
103
106
|
readonly cwd: string;
|
|
104
107
|
/** The REALPATH'd server entry (`dist/index.js`) — the ONLY canonicalised value; derived once. */
|
|
@@ -167,6 +170,7 @@ export declare function keyFileFor(identity: Identity): string;
|
|
|
167
170
|
export declare function configFor(opts: {
|
|
168
171
|
name: string;
|
|
169
172
|
chainUrl: string;
|
|
173
|
+
processor?: ProcessorClientOptions;
|
|
170
174
|
}): string;
|
|
171
175
|
export declare function identityFromSeed(seed: SeedRecord): Identity;
|
|
172
176
|
export type ReadSeedResult = {
|