@mcsherrylabs/evolver-mcp 0.9.4 → 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.
@@ -0,0 +1,8 @@
1
+ .git
2
+ node_modules
3
+ dist
4
+ .ci-sdk
5
+ .env
6
+ .env.*
7
+ **/.env
8
+ **/.env.*
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
- ARG EVOLVER_REGISTRY=nexus.mcsherrylabs.com
12
- ARG EVOLVER_VERSION=v0.24.6
13
- FROM ${EVOLVER_REGISTRY}/evolver-node:${EVOLVER_VERSION}
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), the pattern from the evolver repo's
3
- # deployments/group-chat/app-node-init.sh, made publishable: nothing is bind-mounted and
4
- # the node id is derived rather than required, so this artifact runs in an empty
5
- # directory with no arguments.
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
- # ⚠️ THE AGENT WRITES HERE TOO, as uid 1000, not as this node's uid (10001). Without o+rwx on
163
- # the directory the agent gets EACCES creating the file at all. The Docker socket already grants
164
- # equivalent access (docker exec), so this does not widen the effective boundary.
165
- chmod o+rwx /data/chain
166
-
167
- # ── WHICH CHANNELS THIS NODE DECLARES ─────────────────────────────────────
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
  #
@@ -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
- # ⚠️ OFF. This is a READER, and processing mode is what a node running an APP turns on.
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
- # ⚠️ THE CHANNEL LIST IS GENERATED BY node-join.sh, and `include required` means this node
142
- # REFUSES TO START if the fragment is missing rather than silently declaring nothing.
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
- # Evolver devnet-follower NODE + the evolver-mcp stdio server's launch context.
2
- #
3
- # One `docker compose -f deploy/docker-compose.yml up -d` starts a LOCAL Evolver node that
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
- # ── the LOCAL devnet follower ──────────────────────────────────────────────
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
- # Build FROM the pinned evolver-node and bake /config in (see deploy/Dockerfile). No bind
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
- EVOLVER_REGISTRY: ${EVOLVER_REGISTRY:-nexus.mcsherrylabs.com}
40
- EVOLVER_VERSION: ${EVOLVER_VERSION:-v0.24.6}
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
- # The node claims its OWN identity (node keys never leave this container) and persists the
45
- # name to /data/keys/node-id. `<APP_NAME>node<random6>` keeps a shared chain collision-free.
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
- # ⚠️ PROCESSING MODE STAYS OFF. Apply would be paced by whatever drains /api/channel/next;
58
- # with no drainer the cursor never advances and committed reads answer not_found. A reader
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
- # INVOKED VIA /bin/sh (a baked script's exec bit is irrelevant; node-join.sh is COPYed into
69
- # /config by the Dockerfile, not bind-mounted, so no checkout file-mode concerns).
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
- test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8440/api/explorer/blocks?limit=1 >/dev/null || exit 1"]
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
- # ── the MCP launch context (documentation only) ─────────────────────────────
100
- # evolver-mcp is a STDIO server launched per session by an agent runtime — NOT a long-running
101
- # compose service. This service exists to (a) pin the exact env contract and (b) give a
102
- # containerized operator a `docker compose run --rm mcp …` target. `depends_on` documents that
103
- # the node must be healthy before an MCP launch, since the MCP probes and exits if unreachable.
104
- mcp:
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
- # Chain API — the LOCAL node, not devnet.
109
- EVOLVER_MCP_CONFIG: "/state/config.json"
110
- # Content listener — the LOCAL node's content cache, a SEPARATE port from the chain API.
111
- EVOLVER_CONTENT_URL: "http://node:8450"
112
- EVOLVER_KEY_PATH: "/state/<identity>.key"
113
- EVOLVER_KEY_TYPE: "bls"
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,8 @@
1
+ .git
2
+ node_modules
3
+ dist
4
+ .ci-sdk
5
+ .env
6
+ .env.*
7
+ **/.env
8
+ **/.env.*
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "nodeImage": "nexus.mcsherrylabs.com/evolver-node:v0.25.0",
3
+ "images": {
4
+ "processor": "evolver-mcp-processor",
5
+ "node": "evolver-mcp-node",
6
+ "cli": "evolver-mcp"
7
+ }
8
+ }
@@ -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 = {