@mcsherrylabs/pi-meerkat 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +34 -10
  2. package/dist/installer/committee-init.sh +17 -33
  3. package/dist/installer/committee.conf +12 -34
  4. package/dist/installer/genesis.devnet.conf +92 -0
  5. package/dist/installer/node-follower.conf +232 -0
  6. package/dist/installer/node-join.sh +15 -1
  7. package/dist/installer/node.conf +4 -231
  8. package/dist/installer/node.local.conf +8 -0
  9. package/dist/installer/runtime.json +1 -1
  10. package/dist/installer-runtime/dist/scripts/recover-owner.js +7 -9
  11. package/dist/installer-runtime/dist/scripts/subscriptions-init.js +3 -4
  12. package/dist/installer-runtime/dist/scripts/versions.mjs +5 -10
  13. package/dist/installer-runtime/dist/src/announcements.js +1 -289
  14. package/dist/installer-runtime/dist/src/app.js +12 -535
  15. package/dist/installer-runtime/dist/src/chainfetch.js +0 -64
  16. package/dist/installer-runtime/dist/src/env.js +47 -32
  17. package/dist/installer-runtime/dist/src/registry/index.js +11 -2
  18. package/dist/installer-runtime/dist/src/registry/provision.js +46 -1
  19. package/dist/installer-runtime/dist/src/service.js +151 -0
  20. package/dist/installer.d.ts +185 -18
  21. package/dist/installer.d.ts.map +1 -1
  22. package/dist/installer.js +161 -52
  23. package/dist/installer.js.map +1 -1
  24. package/dist/mcp-announce.d.ts +18 -0
  25. package/dist/mcp-announce.d.ts.map +1 -1
  26. package/dist/mcp-announce.js +18 -4
  27. package/dist/mcp-announce.js.map +1 -1
  28. package/dist/mcp-cli.d.ts.map +1 -1
  29. package/dist/mcp-cli.js +28 -18
  30. package/dist/mcp-cli.js.map +1 -1
  31. package/dist/mcp-config.d.ts +1 -0
  32. package/dist/mcp-config.d.ts.map +1 -1
  33. package/dist/mcp-config.js +2 -1
  34. package/dist/mcp-config.js.map +1 -1
  35. package/dist/mcp-upstream.d.ts +1 -1
  36. package/dist/mcp-upstream.d.ts.map +1 -1
  37. package/dist/mcp-upstream.js +7 -2
  38. package/dist/mcp-upstream.js.map +1 -1
  39. package/dist/tools.js +4 -4
  40. package/dist/tools.js.map +1 -1
  41. package/installer/Node.Dockerfile +5 -0
  42. package/installer/Registry.Dockerfile +34 -9
  43. package/installer/check-single-sdk.mjs +151 -0
  44. package/installer/runtime-control.mjs +179 -16
  45. package/package.json +5 -5
  46. package/skills/channel-subscription/SKILL.md +3 -3
  47. package/dist/installer-runtime/dist/src/runtime.js +0 -164
@@ -22,6 +22,29 @@ async function until(description, check) {
22
22
  }
23
23
  throw new Error(`${description} not ready within ${TIMEOUT / 1000}s: ${reason}; state retained, nothing reset`);
24
24
  }
25
+
26
+ // DevNet catch-up is long (~14k blocks) and progress stays observable. A fixed short deadline
27
+ // would fail a healthy sync, so this waits generously, fails only on STALLED progress, and reports
28
+ // advancement to stderr only. `readProgress` returns null (not observed yet), `{ done: value }`
29
+ // (finished), or `{ progress: scalar }` (still advancing). Progress must be comparable with `<=`;
30
+ // the wait succeeds only on `done`, and fails on a stall or the whole budget expiring.
31
+ async function untilProgress(description, readProgress, { budgetMs = 45 * 60 * 1000, stallMs = 10 * 60 * 1000 } = {}) {
32
+ const end = Date.now() + budgetMs;
33
+ let last = null, lastAt = Date.now(), started = false;
34
+ while (Date.now() < end) {
35
+ const value = await readProgress();
36
+ if (value && value.done !== undefined) return value.done;
37
+ if (value && value.progress !== undefined) {
38
+ if (!started) { started = true; lastAt = Date.now(); }
39
+ if (last === null || value.progress > last) { last = value.progress; lastAt = Date.now(); }
40
+ }
41
+ if (started && Date.now() - lastAt > stallMs)
42
+ throw new Error(`${description} stalled (no progress for ${Math.round((Date.now() - lastAt) / 1000)}s at ${last}); state retained, nothing reset`);
43
+ if (started && last !== null && Date.now() - lastAt > 30_000) process.stderr.write(`[installer] ${description} at ${last}…\n`);
44
+ await new Promise(resolve => setTimeout(resolve, 1000));
45
+ }
46
+ throw new Error(`${description} not observed within ${Math.round(budgetMs / 1000)}s (last ${last}); state retained, nothing reset`);
47
+ }
25
48
  async function child(command, args, env) {
26
49
  await new Promise((resolve, reject) => {
27
50
  const proc = spawn(command, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
@@ -36,8 +59,16 @@ async function child(command, args, env) {
36
59
  }
37
60
  function parameters() {
38
61
  const args = JSON.parse(argument);
39
- if (!/^[A-Za-z0-9]{1,50}$/.test(args.nodeId) || !/^[A-Za-z0-9]{1,50}$/.test(args.committeeId) || !/^[A-Za-z0-9]{1,50}$/.test(args.identity) ||
40
- !/^meerkat\.[0-9a-f]{24}\.announcements$/.test(args.channel)) throw new Error('invalid saved deployment parameters');
62
+ if (!/^[A-Za-z0-9]{1,50}$/.test(args.nodeId) || !/^[A-Za-z0-9]{1,50}$/.test(args.identity) ||
63
+ typeof args.channel !== 'string' || !args.channel) throw new Error('invalid saved deployment parameters');
64
+ if (args.mode === 'devnet') {
65
+ if (args.channel !== 'ce.meerkat.announcements') throw new Error('invalid saved devnet channel');
66
+ } else if (args.mode === 'local') {
67
+ if (!/^meerkat\.[0-9a-f]{24}\.announcements$/.test(args.channel)) throw new Error('invalid saved local channel');
68
+ if (!/^[A-Za-z0-9]{1,50}$/.test(args.committeeId)) throw new Error('invalid saved local committee identity');
69
+ } else {
70
+ throw new Error('invalid saved installation mode');
71
+ }
41
72
  if (args.binding && (args.binding.nodeId !== args.nodeId || !/^[0-9a-f]{64}$/.test(args.binding.chainId))) throw new Error('invalid saved binding');
42
73
  return args;
43
74
  }
@@ -129,44 +160,176 @@ async function main() {
129
160
  return;
130
161
  }
131
162
  if (mode === 'node-binding') {
132
- const binding = await until('owned processing node', async () => {
133
- const value = await json('http://node:8440/api/channel/processor-state');
134
- if (!value.processing || !/^[0-9a-f]{64}$/.test(value.binding?.chainId)) throw new Error('processing/genesis observation not ready');
135
- if (value.binding.nodeId !== args.nodeId) throw Object.assign(new Error('owned node identity differs; refusing re-pairing'), { permanent: true });
136
- if (args.binding && args.binding.chainId !== value.binding.chainId) throw Object.assign(new Error('saved genesis differs; refusing re-pairing'), { permanent: true });
137
- return value.binding;
138
- });
163
+ // DevNet followers sync from block 0 (~14k blocks); wait generously and fail on STALL, not a
164
+ // short fixed deadline. Local is a fresh committee and becomes ready quickly.
165
+ const chainId = args.mode === 'devnet' ? 'c8925985ae7948d2e608efcf863928d15476c4b561ad773fdca6f0d53d4fea05' : undefined;
166
+ const reads = async () => {
167
+ try {
168
+ const value = await json('http://node:8440/api/channel/processor-state');
169
+ if (!value.processing || !/^[0-9a-f]{64}$/.test(value.binding?.chainId)) {
170
+ // Followers report a synced height while catching up; expose it as stalled-detectable progress.
171
+ try {
172
+ const status = await json('http://node:8440/status');
173
+ if (Number.isInteger(status.blockHeight) && status.blockHeight >= 0) return { progress: status.blockHeight };
174
+ } catch { /* not up yet */ }
175
+ return null;
176
+ }
177
+ if (value.binding.nodeId !== args.nodeId) throw Object.assign(new Error('owned node identity differs; refusing re-pairing'), { permanent: true });
178
+ if (args.binding && args.binding.chainId !== value.binding.chainId) throw Object.assign(new Error('saved genesis differs; refusing re-pairing'), { permanent: true });
179
+ if (chainId && value.binding.chainId !== chainId) throw Object.assign(new Error('devnet chain id differs; refusing pairing'), { permanent: true });
180
+ return { done: value.binding };
181
+ } catch (e) { if (e?.permanent) throw e; return null; }
182
+ };
183
+ const binding = args.mode === 'devnet'
184
+ ? await untilProgress('devnet follower catch-up/processing readiness', reads)
185
+ : await until('owned processing node', async () => {
186
+ const value = await reads();
187
+ if (value === null) throw new Error('processing/genesis observation not ready');
188
+ if (value.done === undefined) throw new Error('processing/genesis observation not ready');
189
+ return value.done;
190
+ });
139
191
  process.stdout.write(JSON.stringify(binding) + '\n');
140
192
  return;
141
193
  }
142
194
  if (!args.binding) throw new Error('missing saved node binding');
143
195
  const headers = { 'x-evolver-chain-id': args.binding.chainId, 'x-evolver-node-id': args.binding.nodeId };
144
196
  if (mode === 'ready') {
145
- await until('Meerkat registry/channel/inbox', async () => {
197
+ // DevNet's read-only registry must walk the shared channel's full history (~14k blocks of
198
+ // announcements) before it can serve faithfully; wait on its walk progress, not a fixed deadline.
199
+ // Local's channel is freshly claimed and walks almost immediately.
200
+ const check = async () => {
146
201
  const summary = await json('http://127.0.0.1:8090/summary', headers);
147
202
  await json('http://127.0.0.1:8090/inbox', headers);
148
203
  if (summary.channel !== args.channel) throw new Error('registry channel differs');
149
204
  const terms = await json(`http://node:8440/api/explorer/channels/${encodeURIComponent(args.channel)}`);
150
205
  if (!Array.isArray(terms.owners) || !terms.owners.length || terms.writePolicy !== 'open' || terms.da) throw new Error('registry channel not provisioned with open writes/no DA');
151
- });
206
+ return summary;
207
+ };
208
+ if (args.mode === 'devnet') {
209
+ await untilProgress('devnet registry channel walk/index', async () => {
210
+ try {
211
+ const summary = await check();
212
+ const asOf = Number(summary.asOfRound), tip = Number(summary.tipRound);
213
+ if (Number.isSafeInteger(asOf) && Number.isSafeInteger(tip) && tip >= 0) {
214
+ // Fully caught up (asOf >= tip) with everything else already valid via check().
215
+ return asOf >= tip ? { done: true } : { progress: asOf };
216
+ }
217
+ return null;
218
+ } catch (e) { if (e?.permanent) throw e; return null; }
219
+ });
220
+ } else {
221
+ await until('Meerkat registry/channel/inbox', async () => { await check(); });
222
+ }
152
223
  return;
153
224
  }
154
225
  if (mode === 'bootstrap') {
155
226
  const { resolveUpstreamBin } = await import('../dist/mcp-upstream.js');
156
227
  const cli = await resolveUpstreamBin();
157
- await child(process.execPath, [cli, '--bootstrap', '--target', 'local', '--identity', args.identity], {
158
- PATH: process.env.PATH, HOME: agentDir, EVOLVER_NODE: 'http://node:8440', EVOLVER_FAUCET_URL: 'http://committee:8440',
159
- EVOLVER_CONTENT_URL: 'http://node:8450', EVOLVER_MCP_STATE_DIR: agentDir, EVOLVER_BOOTSTRAP_CWD: agentDir,
160
- });
228
+ const devnet = args.mode === 'devnet';
229
+ const childEnv = {
230
+ PATH: process.env.PATH, HOME: agentDir, EVOLVER_NODE: 'http://node:8440', EVOLVER_CONTENT_URL: 'http://node:8450',
231
+ EVOLVER_FAUCET_URL: devnet ? 'https://devnet.mcsherrylabs.com' : 'http://committee:8440',
232
+ EVOLVER_MCP_STATE_DIR: agentDir, EVOLVER_BOOTSTRAP_CWD: agentDir,
233
+ };
234
+ await child(process.execPath, [cli, '--bootstrap', '--target', devnet ? 'devnet' : 'local', '--identity', args.identity], childEnv);
161
235
  const config = { version: 1, url: 'http://node:8440', identity: args.identity, tls: { insecure: false },
162
236
  meerkat: { contentUrl: 'http://node:8450', keyPath: join(agentDir, `${args.identity}.key`), keyType: 'bls',
163
- registryUrl: 'http://127.0.0.1:8090', chainId: args.binding.chainId, nodeId: args.nodeId, channel: args.channel } };
237
+ registryUrl: 'http://127.0.0.1:8090', chainId: args.binding.chainId, nodeId: args.nodeId, channel: args.channel,
238
+ controlSocketPath: '/control/control.sock' } };
164
239
  let existing;
165
240
  try { existing = await loadFacadeConfig(configPath); } catch (e) { if (e.code !== 'ENOENT') throw e; }
166
241
  if (existing) { if (JSON.stringify(existing) !== JSON.stringify(config)) throw new Error('unrelated saved facade authority; refusing replacement'); }
167
242
  else await saveFacadeConfig(configPath, config);
168
243
  return;
169
244
  }
245
+ if (mode === 'claim-channel') {
246
+ // LOCAL only: the agent identity owns the install's private channel. Claim it ONCE through the
247
+ // released child's own tools (sign + submit), so the agent key never leaves the child. Read the
248
+ // terms first; already owned by the agent → done; owned by someone else → refuse; unclaimed →
249
+ // claim then reconcile by reading (never blind-resend).
250
+ if (args.mode !== 'local') throw new Error('claim-channel is a local-mode operation only');
251
+ const { OwnedUpstream } = await import('../dist/mcp-upstream.js');
252
+ const { claimTxId, submissionResult } = await import('../dist/mcp-announce.js');
253
+ const facadeConfig = await loadFacadeConfig(configPath);
254
+ // CHAIN-AGREED view (the local committee), never the processing node: a processing node answers
255
+ // committed-state reads as of its cursor, so a claim it has not applied yet reads `owners: []`.
256
+ const termsUrl = () => `http://committee:8440/api/explorer/channels/${encodeURIComponent(args.channel)}`;
257
+ const readTerms = async () => { const r = await fetch(termsUrl(), { signal: AbortSignal.timeout(10000) }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); };
258
+ const ownersOf = terms => Array.isArray(terms.owners) ? terms.owners : [];
259
+ // The claim commits asynchronously after submit; poll the terms (up to 180s) so the
260
+ // reconcile-by-read settles regardless of commit latency, never a single immediate read.
261
+ const awaitOwned = async () => {
262
+ const end = Date.now() + 180_000;
263
+ while (Date.now() < end) {
264
+ const owners = ownersOf(await readTerms());
265
+ if (owners.some(o => o.name === args.identity)) return;
266
+ if (owners.length > 0) throw new Error(`channel ${args.channel} is owned by ${owners.map(o => o.name).join(', ')} — refusing to claim`);
267
+ await new Promise(resolve => setTimeout(resolve, 1000));
268
+ }
269
+ throw new Error(`channel ${args.channel} was not claimed by the agent within 180s — refusing to proceed`);
270
+ };
271
+ // Already owned by us (from a prior, possibly crashed, claim) → done, idempotent.
272
+ if (ownersOf(await readTerms()).some(o => o.name === args.identity)) {
273
+ process.stderr.write(`[installer] channel ${args.channel} already owned by the agent — nothing to claim\n`);
274
+ return;
275
+ }
276
+ // The processing node refuses submissions (`node_not_ready`) until its own node identity is
277
+ // established. Wait until the chain-agreed view has that identity before signing anything.
278
+ const identityEnd = Date.now() + 180_000;
279
+ for (;;) {
280
+ const r = await fetch(`http://committee:8440/api/identity/${encodeURIComponent(args.nodeId)}`, { signal: AbortSignal.timeout(10000) }).catch(() => null);
281
+ if (r?.status === 200) break;
282
+ if (Date.now() > identityEnd) throw new Error(`node identity ${args.nodeId} not established on chain within 180s; nothing submitted`);
283
+ await new Promise(resolve => setTimeout(resolve, 1000));
284
+ }
285
+ // The processing node submits through an admit filter keyed on the AGENT identity (claimed by
286
+ // the earlier `bootstrap` mode), and in processing mode its applied state is paced by the
287
+ // facade's own polls — so the agent identity can lag behind the committee's chain-agreed view.
288
+ // A submission in that window is refused with `unknown_key` (`admitFilter rejected
289
+ // (UnknownKey)`), which ordered and charged nothing but is final (never blind-resend). Wait for
290
+ // the processing node itself to report the agent identity before signing, so the claim is
291
+ // admitted on the first submission instead of racing its apply.
292
+ const agentIdentityEnd = Date.now() + 180_000;
293
+ for (;;) {
294
+ const r = await fetch(`http://node:8440/api/identity/${encodeURIComponent(args.identity)}`, { signal: AbortSignal.timeout(10000) }).catch(() => null);
295
+ if (r?.status === 200) break;
296
+ if (Date.now() > agentIdentityEnd) throw new Error(`agent identity ${args.identity} not applied by the processing node within 180s; nothing submitted`);
297
+ await new Promise(resolve => setTimeout(resolve, 1000));
298
+ }
299
+ // Sign then submit in ONE owned child session, then reconcile by reading (never blind-resend).
300
+ // Only `node_not_ready` is retried: it is a definite refusal that ordered and charged nothing.
301
+ // The child blocks repeat submission within a session, so a retry closes that session and signs
302
+ // afresh in a new one; the refused transaction was never ordered, so this cannot pay twice.
303
+ const submitEnd = Date.now() + 180_000;
304
+ let outcome;
305
+ for (;;) {
306
+ const upstream = new OwnedUpstream(facadeConfig);
307
+ await upstream.connect();
308
+ try {
309
+ const txId = claimTxId(await upstream.callTool('evolver_sign_claim_channel', { channel: args.channel, writePolicy: 'open' }), args.channel);
310
+ try {
311
+ outcome = submissionResult(await upstream.callTool('evolver_submit', { txId }), txId);
312
+ } catch (e) {
313
+ // Submit outcome unknown; reconcile by reading, never resend.
314
+ process.stderr.write(`[installer] claim submit uncertain (${e instanceof Error ? e.message : String(e)}) — reconciling by reading\n`);
315
+ outcome = undefined;
316
+ }
317
+ } finally {
318
+ await upstream.close();
319
+ }
320
+ if (!(outcome?.kind === 'refused' && outcome.reason === 'node_not_ready') || Date.now() > submitEnd) break;
321
+ process.stderr.write('[installer] node not ready for submissions yet (nothing ordered or charged) — retrying with a fresh claim\n');
322
+ await new Promise(resolve => setTimeout(resolve, 3000));
323
+ }
324
+ // A definite refusal or fine is final: report the chain's own reason, never wait or resend.
325
+ if (outcome && (outcome.kind === 'refused' || outcome.kind === 'fined'))
326
+ throw new Error(`channel ${args.channel} claim was ${outcome.kind} by the chain: ${outcome.message}`);
327
+ if (outcome && outcome.kind !== 'committed')
328
+ process.stderr.write(`[installer] claim submit outcome ${outcome.kind} — reconciling by reading\n`);
329
+ await awaitOwned();
330
+ process.stderr.write(`[installer] channel ${args.channel} claimed (open) by the agent\n`);
331
+ return;
332
+ }
170
333
  throw new Error('unknown installer runtime operation');
171
334
  }
172
335
  await main().catch(error => { console.error(error instanceof Error ? error.message : 'installer runtime failed'); process.exitCode = 1; });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mcsherrylabs/pi-meerkat",
3
- "version": "0.8.0",
4
- "description": "Meerkat zero-parameter local installer, stdio MCP facade and registry tools, with a private pinned Evolver MCP child. Preserves the announcement contract and existing Pi extension.",
3
+ "version": "0.11.0",
4
+ "description": "Meerkat zero-parameter DevNet installer (local chain with --local), stdio MCP facade and registry tools, with a private pinned Evolver MCP child. Preserves the announcement contract and existing Pi extension.",
5
5
  "bin": {
6
6
  "meerkat-mcp": "./dist/mcp-cli.js"
7
7
  },
@@ -29,9 +29,9 @@
29
29
  "check:plan-two:client": "tsx test/mcp-client-checks.ts"
30
30
  },
31
31
  "dependencies": {
32
- "@mcsherrylabs/evolver-client": "0.22.1",
33
- "@mcsherrylabs/evolver-core": "0.1.0",
34
- "@mcsherrylabs/evolver-mcp": "0.15.0",
32
+ "@mcsherrylabs/evolver-client": "0.24.0",
33
+ "@mcsherrylabs/evolver-core": "0.2.2",
34
+ "@mcsherrylabs/evolver-mcp": "0.17.0",
35
35
  "@modelcontextprotocol/sdk": "1.32.1",
36
36
  "typebox": "1.3.32"
37
37
  },
@@ -11,8 +11,8 @@ The shared SDK processor is the node's ONE ordered-stream consumer. Never poll
11
11
  ## Subscription management
12
12
 
13
13
  This extension has no config-file or Docker restart tools. Use the managed MCP
14
- channel subscribe/unsubscribe/subscriptions tools
15
- when an operator has paired a private processor control socket with the expected
14
+ channel subscribe/unsubscribe/subscriptions tools on the control socket, which
15
+ the installer provisions automatically on a fresh install against the expected
16
16
  node and chain. An unmanaged endpoint cannot be configured by these tools.
17
17
 
18
18
  Saved choices, including an EMPTY set, override all startup/module defaults.
@@ -26,7 +26,7 @@ Removing its channel parks the module; re-enabling it does not replay old inbox
26
26
 
27
27
  ## Availability and reads
28
28
 
29
- A channel owner may use `evolver_sign_set_channel_da` to arrange availability through
29
+ A channel owner may use `evolver_sign_channel_da` to arrange availability through
30
30
  a DA group containing the NODE identity, not the agent identity. Only the owner may
31
31
  change a channel's policy. Without DA, bodies are best effort.
32
32
 
@@ -1,164 +0,0 @@
1
- // Meerkat composition root for the shared processor, not a second collector/runtime.
2
- // All paths/capabilities are operator-fixed here; requests can supply only a channel id.
3
- import { join } from "node:path";
4
- import { dockerNodeRestarter, nodeObserver, nodePuller, startControlServer, startProcessor, startReadServer, subscriptionConfig, subscriptionController, } from "@mcsherrylabs/evolver-client/processor";
5
- import { ensureStateDir } from "./env.js";
6
- const productionSdk = {
7
- dockerNodeRestarter, nodeObserver, nodePuller, startControlServer, startProcessor,
8
- startReadServer, subscriptionConfig, subscriptionController,
9
- };
10
- export function processorBinding() {
11
- return { chainId: process.env.EVOLVER_CHAIN_ID?.trim() ?? "", nodeId: process.env.APP_NODE_ID?.trim() ?? "" };
12
- }
13
- export function processorConfigPath(target) {
14
- return process.env.EVOLVER_SUBSCRIPTIONS_FILE?.trim() || join(target.stateDir, "subscriptions", "channels.conf");
15
- }
16
- export function processorStatePath(target) {
17
- return join(target.stateDir, "registry.db");
18
- }
19
- export async function startMeerkatProcessor(args, sdk = productionSdk) {
20
- const binding = processorBinding();
21
- // Synchronous SDK validation of fixed expectations, NOT an online-node startup dependency.
22
- const observe = sdk.nodeObserver({ baseUrl: args.target.nodeUrl, binding });
23
- const config = sdk.subscriptionConfig(processorConfigPath(args.target));
24
- await config.read(); // missing/malformed declaration refuses before opening storage; never reseed
25
- let initialized = false;
26
- ensureStateDir(args.target);
27
- const runtime = await sdk.startProcessor({
28
- store: {
29
- path: processorStatePath(args.target), binding,
30
- modules: args.worker ? [args.worker] : [],
31
- limits: { maxEntriesPerChannel: 1000, maxBodyBytes: 64 * 1024 * 1024 },
32
- },
33
- puller: sdk.nodePuller(args.node), config,
34
- // SDK initialization normally attempts setup (which can pace). Defer that attempt until
35
- // the caller explicitly paces/runs: service composition binds recovery listeners first.
36
- // Every subsequent pace still uses the SDK's verified node/chain/ACTIVE gate.
37
- observe: async () => {
38
- if (!initialized)
39
- throw new Error("Meerkat listeners not started; collection deferred");
40
- return observe();
41
- },
42
- tip: async () => String(await args.node.currentRound()),
43
- modules: args.lifecycle ? [args.lifecycle] : [], say: args.say,
44
- }).catch(error => {
45
- if (error?.name === "ProcessorOwnerLockError") {
46
- args.say(error.message);
47
- args.say("MANUAL FORCE RECOVERY REQUIRED if the owner has died. Using the SAME Compose project/env/files/volumes: 1) docker compose stop app; stop any other collectors and disable all external automatic restarts. 2) Only when fenced: docker compose run --rm --no-deps app node dist/scripts/recover-owner.js --force. 3) docker compose up -d --no-deps app. For a host-run app, stop it and its supervisor first, then run node dist/scripts/recover-owner.js --force with the SAME target/state environment and restart normally. Ordinary restart or --force-recreate does NOT clear the lock. Never delete database/WAL or reinitialize subscriptions.");
48
- }
49
- throw error;
50
- });
51
- initialized = true;
52
- return runtime;
53
- }
54
- /** Always attempt every release, including when another close fails. */
55
- export async function closeMeerkat(actions) {
56
- const errors = [];
57
- for (const action of actions) {
58
- try {
59
- await action();
60
- }
61
- catch (error) {
62
- errors.push(error);
63
- }
64
- }
65
- if (errors.length)
66
- throw new AggregateError(errors, "Meerkat shutdown failed");
67
- }
68
- export async function startMeerkatServers(args, sdk = productionSdk) {
69
- const servers = [];
70
- try {
71
- servers.push(await sdk.startReadServer({
72
- store: args.runtime.store, port: args.port,
73
- host: process.env.PROCESSOR_READ_HOST?.trim() || "127.0.0.1", routes: args.routes, say: args.say,
74
- }));
75
- const containerName = process.env.EVOLVER_MANAGED_NODE_CONTAINER?.trim();
76
- const socketPath = process.env.EVOLVER_PROCESSOR_CONTROL_SOCKET?.trim() || (containerName ? "/control/control.sock" : undefined);
77
- const dockerSocket = containerName ? "/var/run/docker.sock" : undefined;
78
- if (socketPath || dockerSocket || containerName) {
79
- if (!socketPath || !dockerSocket || !containerName)
80
- throw new Error("managed control requires explicit control socket, Docker socket and fixed node container");
81
- const binding = processorBinding();
82
- const controller = sdk.subscriptionController({
83
- binding, runtime: args.runtime, config: sdk.subscriptionConfig(processorConfigPath(args.target)),
84
- observe: sdk.nodeObserver({ baseUrl: args.target.nodeUrl, binding }),
85
- restart: sdk.dockerNodeRestarter({ socketPath: dockerSocket, containerName }),
86
- });
87
- servers.push(await sdk.startControlServer({ socketPath, mode: 0o660, controller, say: args.say }));
88
- }
89
- return { port: servers[0].port, close: () => closeMeerkat([...servers].reverse().map(server => () => server.close())) };
90
- }
91
- catch (error) {
92
- try {
93
- await closeMeerkat(servers.reverse().map(server => () => server.close()));
94
- }
95
- catch (cleanup) {
96
- throw new AggregateError([error, cleanup], "Meerkat listener startup failed");
97
- }
98
- throw error;
99
- }
100
- }
101
- /** One service owner: bind listeners before run/setup, and release the worker on partial bind. */
102
- export async function startMeerkatService(args, sdk = productionSdk) {
103
- const runtime = await startMeerkatProcessor(args, sdk);
104
- let servers;
105
- try {
106
- servers = await startMeerkatServers({ ...args, runtime, routes: args.routes(runtime) }, sdk);
107
- runtime.run();
108
- let stopping;
109
- return { runtime, port: servers.port, stop() {
110
- return stopping ??= closeMeerkat([() => servers.close(), () => runtime.stop()]);
111
- } };
112
- }
113
- catch (error) {
114
- try {
115
- await closeMeerkat([...(servers ? [() => servers.close()] : []), () => runtime.stop()]);
116
- }
117
- catch (cleanup) {
118
- throw new AggregateError([error, cleanup], "Meerkat service startup failed");
119
- }
120
- throw error;
121
- }
122
- }
123
- /** Signals and normal/error completion share one cleanup path, including partial async startup. */
124
- export function meerkatShutdown(signals = process) {
125
- let stopping;
126
- const releases = [];
127
- const stop = () => stopping ??= closeMeerkat([...releases].reverse());
128
- const onSignal = () => { void stop().then(() => signals.exit(0), error => { console.error(error); signals.exit(1); }); };
129
- signals.once("SIGINT", onSignal);
130
- signals.once("SIGTERM", onSignal);
131
- return {
132
- async own(start, release) {
133
- if (stopping)
134
- throw new Error("Meerkat stopped during startup");
135
- const pending = start();
136
- // Register before awaiting so a signal DURING startup waits for and releases its result.
137
- releases.push(async () => {
138
- let resource;
139
- try {
140
- resource = await pending;
141
- }
142
- catch {
143
- return;
144
- } // startup owns its partial cleanup
145
- await release(resource);
146
- });
147
- const resource = await pending;
148
- if (stopping) {
149
- await stopping;
150
- throw new Error("Meerkat stopped during startup");
151
- }
152
- return resource;
153
- },
154
- async close() {
155
- try {
156
- await stop();
157
- }
158
- finally {
159
- signals.removeListener("SIGINT", onSignal);
160
- signals.removeListener("SIGTERM", onSignal);
161
- }
162
- },
163
- };
164
- }