@mcsherrylabs/pi-meerkat 0.7.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -33
- package/dist/installer/committee-init.sh +117 -0
- package/dist/installer/committee.conf +102 -0
- package/dist/installer/genesis.devnet.conf +92 -0
- package/dist/installer/node-join.sh +235 -0
- package/dist/installer/node.conf +238 -0
- package/dist/installer/node.devnet.conf +238 -0
- package/dist/installer/runtime.json +1 -0
- package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
- package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
- package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
- package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
- package/dist/installer-runtime/dist/src/announcements.js +448 -0
- package/dist/installer-runtime/dist/src/app.js +552 -0
- package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
- package/dist/installer-runtime/dist/src/env.js +437 -0
- package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
- package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
- package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
- package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
- package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
- package/dist/installer-runtime/dist/src/registry/http.js +194 -0
- package/dist/installer-runtime/dist/src/registry/index.js +61 -0
- package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
- package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
- package/dist/installer-runtime/dist/src/registry/provision.js +225 -0
- package/dist/installer-runtime/dist/src/registry/store.js +242 -0
- package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
- package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
- package/dist/installer-runtime/dist/src/runtime.js +192 -0
- package/dist/installer-runtime/dist/src/store.js +138 -0
- package/dist/installer-runtime/package.json +1 -0
- package/dist/installer.d.ts +401 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +446 -0
- package/dist/installer.js.map +1 -0
- package/dist/mcp-announce.d.ts +18 -0
- package/dist/mcp-announce.d.ts.map +1 -1
- package/dist/mcp-announce.js +18 -4
- package/dist/mcp-announce.js.map +1 -1
- package/dist/mcp-cli.d.ts.map +1 -1
- package/dist/mcp-cli.js +41 -5
- package/dist/mcp-cli.js.map +1 -1
- package/dist/mcp-config.d.ts +1 -0
- package/dist/mcp-config.d.ts.map +1 -1
- package/dist/mcp-config.js +2 -1
- package/dist/mcp-config.js.map +1 -1
- package/dist/mcp-upstream.d.ts +1 -1
- package/dist/mcp-upstream.d.ts.map +1 -1
- package/dist/mcp-upstream.js +7 -2
- package/dist/mcp-upstream.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +8 -4
- package/dist/registry.js.map +1 -1
- package/dist/tools.js +4 -4
- package/dist/tools.js.map +1 -1
- package/installer/Committee.Dockerfile +6 -0
- package/installer/Node.Dockerfile +10 -0
- package/installer/Registry.Dockerfile +13 -0
- package/installer/runtime-control.mjs +335 -0
- package/installer/session-files.mjs +16 -0
- package/package.json +8 -7
- package/skills/channel-subscription/SKILL.md +3 -3
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Fixed-target installer helper, not an MCP tool or arbitrary URL/shell executor.
|
|
2
|
+
import { mkdir, readFile, writeFile, unlink, lstat } from 'node:fs/promises';
|
|
3
|
+
import { publishSessionPid } from './session-files.mjs';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { loadFacadeConfig, saveFacadeConfig } from '../dist/mcp-config.js';
|
|
7
|
+
const [mode, argument] = process.argv.slice(2);
|
|
8
|
+
const TIMEOUT = 180_000;
|
|
9
|
+
const agentDir = '/meerkat/agent';
|
|
10
|
+
const configPath = join(agentDir, 'facade.json');
|
|
11
|
+
async function json(path, headers) {
|
|
12
|
+
const response = await fetch(path, { headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(5000) });
|
|
13
|
+
if (!response.ok) throw new Error(`read ${new URL(path).pathname}: HTTP ${response.status}`);
|
|
14
|
+
return response.json();
|
|
15
|
+
}
|
|
16
|
+
async function until(description, check) {
|
|
17
|
+
const end = Date.now() + TIMEOUT;
|
|
18
|
+
let reason = 'not observed';
|
|
19
|
+
while (Date.now() < end) {
|
|
20
|
+
try { return await check(); } catch (e) { if (e?.permanent) throw e; reason = e instanceof Error ? e.message : 'read failed'; }
|
|
21
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`${description} not ready within ${TIMEOUT / 1000}s: ${reason}; state retained, nothing reset`);
|
|
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
|
+
}
|
|
48
|
+
async function child(command, args, env) {
|
|
49
|
+
await new Promise((resolve, reject) => {
|
|
50
|
+
const proc = spawn(command, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
51
|
+
let reason = '';
|
|
52
|
+
proc.stderr.on('data', data => { reason = (reason + String(data)).slice(-8192); });
|
|
53
|
+
const timer = setTimeout(() => proc.kill('SIGTERM'), TIMEOUT);
|
|
54
|
+
const hard = setTimeout(() => proc.kill('SIGKILL'), TIMEOUT + 5000);
|
|
55
|
+
proc.once('error', reject);
|
|
56
|
+
proc.once('close', code => { clearTimeout(timer); clearTimeout(hard);
|
|
57
|
+
code === 0 ? resolve() : reject(new Error(`released Evolver bootstrap failed: ${reason}`)); });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function parameters() {
|
|
61
|
+
const args = JSON.parse(argument);
|
|
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
|
+
}
|
|
72
|
+
if (args.binding && (args.binding.nodeId !== args.nodeId || !/^[0-9a-f]{64}$/.test(args.binding.chainId))) throw new Error('invalid saved binding');
|
|
73
|
+
return args;
|
|
74
|
+
}
|
|
75
|
+
const sessions = join(agentDir, 'sessions');
|
|
76
|
+
function sessionPath(token) {
|
|
77
|
+
if (!/^[0-9a-f]{32}$/.test(token)) throw new Error('invalid owned session token');
|
|
78
|
+
return join(sessions, `${token}.json`);
|
|
79
|
+
}
|
|
80
|
+
async function stopSession(token) {
|
|
81
|
+
const path = sessionPath(token);
|
|
82
|
+
let saved;
|
|
83
|
+
try { saved = JSON.parse(await readFile(path, 'utf8')); }
|
|
84
|
+
catch (e) { if (e.code === 'ENOENT') return; throw e; }
|
|
85
|
+
if (!Number.isSafeInteger(saved.pid) || (saved.pid !== 0 && saved.pid < 2)) throw new Error('invalid owned session process');
|
|
86
|
+
if (saved.pid === 0) {
|
|
87
|
+
// The host allocated this token before starting exec; cancellation wins the startup race.
|
|
88
|
+
await writeFile(`${path}.cancel`, '', { flag: 'wx', mode: 0o600 }).catch(e => { if (e.code !== 'EEXIST') throw e; });
|
|
89
|
+
} else {
|
|
90
|
+
let cmd;
|
|
91
|
+
try { cmd = (await readFile(`/proc/${saved.pid}/cmdline`, 'utf8')).split('\0'); }
|
|
92
|
+
catch (e) {
|
|
93
|
+
if (e.code === 'ENOENT') {
|
|
94
|
+
try { await lstat(path); } catch (gone) { if (gone.code === 'ENOENT') return; throw gone; }
|
|
95
|
+
throw new Error('owned session ended without cleanup; no PID reuse or lock recovery');
|
|
96
|
+
}
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
if (!cmd.includes('serve-session') || !cmd.includes(token)) throw new Error('owned session PID no longer matches; refusing signal');
|
|
100
|
+
process.kill(saved.pid, 'SIGTERM');
|
|
101
|
+
}
|
|
102
|
+
const end = Date.now() + 15_000;
|
|
103
|
+
while (Date.now() < end) {
|
|
104
|
+
try {
|
|
105
|
+
await lstat(path);
|
|
106
|
+
if (saved.pid === 0 && JSON.parse(await readFile(path, 'utf8')).pid > 0) { await stopSession(token); return; }
|
|
107
|
+
} catch (e) { if (e.code === 'ENOENT') return; throw e; }
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
109
|
+
}
|
|
110
|
+
throw new Error('owned facade did not finish child/socket cleanup within 15s');
|
|
111
|
+
}
|
|
112
|
+
async function main() {
|
|
113
|
+
if (mode === 'prepare-session') {
|
|
114
|
+
await mkdir(sessions, { recursive: true, mode: 0o700 });
|
|
115
|
+
await writeFile(sessionPath(argument), JSON.stringify({ pid: 0 }), { flag: 'wx', mode: 0o600 });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (mode === 'discard-session') {
|
|
119
|
+
const path = sessionPath(argument);
|
|
120
|
+
const saved = JSON.parse(await readFile(path, 'utf8'));
|
|
121
|
+
if (saved.pid !== 0) throw new Error('cannot discard a running owned session');
|
|
122
|
+
await unlink(path);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (mode === 'serve-session') {
|
|
126
|
+
const path = sessionPath(argument);
|
|
127
|
+
const saved = JSON.parse(await readFile(path, 'utf8'));
|
|
128
|
+
if (saved.pid !== 0) throw new Error('owned session token is already running');
|
|
129
|
+
// Import/configuration has a startup interval before the facade installs its handlers.
|
|
130
|
+
// Install guards BEFORE publishing the PID; publish atomically so a concurrent stop never
|
|
131
|
+
// sees a truncated/partial descriptor or a live PID with no cleanup handler.
|
|
132
|
+
let cancelled = false;
|
|
133
|
+
const earlyStop = () => { cancelled = true; };
|
|
134
|
+
process.once('SIGTERM', earlyStop); process.once('SIGINT', earlyStop);
|
|
135
|
+
try {
|
|
136
|
+
await publishSessionPid(path, process.pid);
|
|
137
|
+
try { await lstat(`${path}.cancel`); cancelled = true; } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
138
|
+
if (cancelled) return;
|
|
139
|
+
process.argv = [process.execPath, new URL('../dist/mcp-cli.js', import.meta.url).pathname, '--config', configPath];
|
|
140
|
+
// The CLI's existing shutdown path handles EOF/signals and awaits its exact private child.
|
|
141
|
+
const facade = import('../dist/mcp-cli.js');
|
|
142
|
+
const abort = setInterval(() => { if (cancelled && process.listenerCount('SIGTERM') > 0) process.emit('SIGTERM'); }, 10);
|
|
143
|
+
try { await facade; } finally { clearInterval(abort); }
|
|
144
|
+
} finally {
|
|
145
|
+
process.off('SIGTERM', earlyStop); process.off('SIGINT', earlyStop);
|
|
146
|
+
await unlink(path);
|
|
147
|
+
await unlink(`${path}.cancel`).catch(e => { if (e.code !== 'ENOENT') throw e; });
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (mode === 'stop-session') { await stopSession(argument); return; }
|
|
152
|
+
const args = parameters();
|
|
153
|
+
if (mode === 'committee-ready') {
|
|
154
|
+
await until('owned local consensus committee', async () => {
|
|
155
|
+
const status = await json('http://committee:8440/status');
|
|
156
|
+
if (status.nodeId && status.nodeId !== args.committeeId) throw Object.assign(new Error('owned committee identity differs'), { permanent: true });
|
|
157
|
+
if (status.nodeId !== args.committeeId || !Number.isInteger(status.blockHeight) || status.blockHeight < 2 || status.selfQuorumIndex !== 0 || status.appliedRound !== null)
|
|
158
|
+
throw new Error('committee has not committed/funded its local chain with ordinary apply');
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (mode === 'node-binding') {
|
|
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
|
+
});
|
|
191
|
+
process.stdout.write(JSON.stringify(binding) + '\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!args.binding) throw new Error('missing saved node binding');
|
|
195
|
+
const headers = { 'x-evolver-chain-id': args.binding.chainId, 'x-evolver-node-id': args.binding.nodeId };
|
|
196
|
+
if (mode === 'ready') {
|
|
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 () => {
|
|
201
|
+
const summary = await json('http://127.0.0.1:8090/summary', headers);
|
|
202
|
+
await json('http://127.0.0.1:8090/inbox', headers);
|
|
203
|
+
if (summary.channel !== args.channel) throw new Error('registry channel differs');
|
|
204
|
+
const terms = await json(`http://node:8440/api/explorer/channels/${encodeURIComponent(args.channel)}`);
|
|
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');
|
|
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
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (mode === 'bootstrap') {
|
|
226
|
+
const { resolveUpstreamBin } = await import('../dist/mcp-upstream.js');
|
|
227
|
+
const cli = await resolveUpstreamBin();
|
|
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);
|
|
235
|
+
const config = { version: 1, url: 'http://node:8440', identity: args.identity, tls: { insecure: false },
|
|
236
|
+
meerkat: { contentUrl: 'http://node:8450', keyPath: join(agentDir, `${args.identity}.key`), keyType: 'bls',
|
|
237
|
+
registryUrl: 'http://127.0.0.1:8090', chainId: args.binding.chainId, nodeId: args.nodeId, channel: args.channel,
|
|
238
|
+
controlSocketPath: '/control/control.sock' } };
|
|
239
|
+
let existing;
|
|
240
|
+
try { existing = await loadFacadeConfig(configPath); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
241
|
+
if (existing) { if (JSON.stringify(existing) !== JSON.stringify(config)) throw new Error('unrelated saved facade authority; refusing replacement'); }
|
|
242
|
+
else await saveFacadeConfig(configPath, config);
|
|
243
|
+
return;
|
|
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
|
+
}
|
|
333
|
+
throw new Error('unknown installer runtime operation');
|
|
334
|
+
}
|
|
335
|
+
await main().catch(error => { console.error(error instanceof Error ? error.message : 'installer runtime failed'); process.exitCode = 1; });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A remote session PID must become visible all at once; stop-session may read concurrently.
|
|
2
|
+
import { open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
export async function publishSessionPid(path, pid, prepared = async () => {}) {
|
|
4
|
+
const temporary = `${path}.${pid}.tmp`;
|
|
5
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
6
|
+
let renamed = false;
|
|
7
|
+
try {
|
|
8
|
+
try { await handle.writeFile(JSON.stringify({ pid })); await handle.sync(); }
|
|
9
|
+
finally { await handle.close(); }
|
|
10
|
+
await prepared();
|
|
11
|
+
await rename(temporary, path);
|
|
12
|
+
renamed = true;
|
|
13
|
+
} finally {
|
|
14
|
+
if (!renamed) await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcsherrylabs/pi-meerkat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Meerkat stdio MCP facade and registry tools, with a private pinned Evolver MCP child.
|
|
3
|
+
"version": "0.10.1",
|
|
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
|
},
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"build": "tsc -p tsconfig.build.json && node ../../scripts/bundle-installer.mjs",
|
|
21
21
|
"prepack": "npm run build",
|
|
22
22
|
"clean": "rm -rf dist",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"check:surface": "tsx test/surface-checks.ts",
|
|
26
26
|
"test": "npm run build && npm run typecheck && npm run check:contract && npm run check:surface && npm run check:inbox && npm run check:mcp:unit && npm run check:plan-two:client",
|
|
27
27
|
"check:inbox": "tsx test/inbox-tool-checks.ts",
|
|
28
|
-
"check:mcp:unit": "tsx test/mcp-announce-checks.ts && tsx test/mcp-facade-unit-checks.ts",
|
|
28
|
+
"check:mcp:unit": "tsx test/mcp-announce-checks.ts && tsx test/mcp-facade-unit-checks.ts && tsx test/installer-checks.ts && tsx test/detail-read-checks.ts && node test/installer-session-checks.mjs",
|
|
29
29
|
"check:plan-two:client": "tsx test/mcp-client-checks.ts"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@mcsherrylabs/evolver-client": "0.
|
|
33
|
-
"@mcsherrylabs/evolver-core": "0.1
|
|
34
|
-
"@mcsherrylabs/evolver-mcp": "0.
|
|
32
|
+
"@mcsherrylabs/evolver-client": "0.23.0",
|
|
33
|
+
"@mcsherrylabs/evolver-core": "0.2.1",
|
|
34
|
+
"@mcsherrylabs/evolver-mcp": "0.16.0",
|
|
35
35
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
36
36
|
"typebox": "1.3.32"
|
|
37
37
|
},
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"types": "./dist/index.d.ts",
|
|
49
49
|
"files": [
|
|
50
50
|
"dist",
|
|
51
|
+
"installer",
|
|
51
52
|
"extensions",
|
|
52
53
|
"skills",
|
|
53
54
|
"README.md"
|
|
@@ -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
|
-
|
|
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 `
|
|
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
|
|