mcp-pacemaker 1.3.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/CHANGELOG.md +424 -0
- package/CONTRIBUTING.md +156 -0
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/SECURITY.md +110 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2113 -0
- package/bin/pooling-config.mjs +553 -0
- package/bin/pooling-execution.mjs +91 -0
- package/bin/pooling-writer.mjs +169 -0
- package/bin/prewarm.mjs +152 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +39 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +165 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +95 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CIy7uxfd.css +1 -0
- package/ui/dist/assets/index-CtaaQsB9.js +8 -0
- package/ui/dist/index.html +14 -0
- package/ui/dist/third-party-manifest.json +579 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Configuration checks shared by `mcp-pacemaker doctor` and the dashboard Health page, so the
|
|
2
|
+
// two cannot drift apart.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, resolve, extname } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SCRIPT_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.py', '.ps1', '.sh', '.rb', '.jar']);
|
|
8
|
+
|
|
9
|
+
// Arguments that address a file on disk, as opposed to a flag, a URL, or a package name.
|
|
10
|
+
// Only a script extension or an explicit `./`-style prefix counts, because package specs passed
|
|
11
|
+
// to a runner (`npx @scope/pkg`, `uvx tool`) also contain slashes and must not be flagged.
|
|
12
|
+
export function relativePathArgs(def) {
|
|
13
|
+
const args = Array.isArray(def?.args) ? def.args : [];
|
|
14
|
+
return args.filter((a) => {
|
|
15
|
+
if (typeof a !== 'string' || !a || a.startsWith('-') || a.includes('://')) return false;
|
|
16
|
+
if (isAbsolute(a)) return false;
|
|
17
|
+
return SCRIPT_EXT.has(extname(a).toLowerCase()) || /^\.\.?[\\/]/.test(a);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Names the bridge routes to itself. A server given one of these is silently unreachable: the
|
|
22
|
+
// bridge answers /api, /admin, /ui and /status before it ever consults the server table, so a
|
|
23
|
+
// client asking for that server gets the bridge's own reply and no error ever names the cause.
|
|
24
|
+
export const RESERVED_NAMES = new Set(['api', 'admin', 'ui', 'status', '.well-known']);
|
|
25
|
+
|
|
26
|
+
export function checkReservedName(name) {
|
|
27
|
+
if (!RESERVED_NAMES.has(name)) return null;
|
|
28
|
+
return { name, status: 'bad', detail: `"${name}" is reserved by the bridge's own routes — rename this server, it is unreachable` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A relative path is resolved against the bridge's base directory, not the directory the config
|
|
32
|
+
// was imported from. When a host's config assumed a project root, those paths point nowhere once
|
|
33
|
+
// imported: the server dies at spawn with MODULE_NOT_FOUND while `status` still lists it as
|
|
34
|
+
// configured, and nothing surfaces it until a client actually calls that server. Reporting it up
|
|
35
|
+
// front is the cheap way to catch a class of failure that is otherwise silent.
|
|
36
|
+
//
|
|
37
|
+
// Returns a check object, or null when there is nothing to report.
|
|
38
|
+
export function checkServerPaths(name, def, baseCwd) {
|
|
39
|
+
if (!def?.command) return null; // http servers have no working directory
|
|
40
|
+
const rel = relativePathArgs(def);
|
|
41
|
+
if (!rel.length) return null;
|
|
42
|
+
|
|
43
|
+
const base = def.cwd ? resolve(baseCwd, def.cwd) : baseCwd;
|
|
44
|
+
const missing = rel.filter((a) => !existsSync(resolve(base, a)));
|
|
45
|
+
if (missing.length) {
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
status: 'bad',
|
|
49
|
+
detail: def.cwd
|
|
50
|
+
? `path not found under cwd ${base}: ${missing.join(', ')}`
|
|
51
|
+
: `no "cwd" set, so ${missing.join(', ')} resolves under ${base} and does not exist`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (!def.cwd) {
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
status: 'warn',
|
|
58
|
+
detail: `relative path with no "cwd" — resolved from ${base}: ${rel.join(', ')}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
package/bin/logs.mjs
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { open, lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { constants, watch } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { setImmediate as yieldTurn } from 'node:timers/promises';
|
|
6
|
+
|
|
7
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
9
|
+
const ANCHOR_BYTES = 256;
|
|
10
|
+
|
|
11
|
+
class LogReader {
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.output = options.stdout ?? process.stdout;
|
|
15
|
+
this.diagnostics = options.stderr ?? process.stderr;
|
|
16
|
+
this.controller = new AbortController();
|
|
17
|
+
this.signal = this.controller.signal;
|
|
18
|
+
this.failure = null;
|
|
19
|
+
this.wake = null;
|
|
20
|
+
this.handles = new Set();
|
|
21
|
+
this.outputs = new Map();
|
|
22
|
+
this.finished = false;
|
|
23
|
+
this.since = this.parseSince(options.since);
|
|
24
|
+
this.grep = options.grep?.toLowerCase() ?? '';
|
|
25
|
+
this.path = resolve(dirname(resolve(options.config ?? resolve(homedir(), '.mcp-pacemaker', 'servers.json'))), 'bridge.log');
|
|
26
|
+
this.interval = options.pollIntervalMs ?? 100;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
parseTime(value) {
|
|
30
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
const [, year, month, day, hour, minute, second, zone] = match;
|
|
33
|
+
const leap = Number(year) % 4 === 0 && (Number(year) % 100 !== 0 || Number(year) % 400 === 0);
|
|
34
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
35
|
+
const invalid = Number(month) < 1 || Number(month) > 12 ||
|
|
36
|
+
Number(day) < 1 || Number(day) > days[Number(month) - 1] ||
|
|
37
|
+
Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59 ||
|
|
38
|
+
(zone !== 'Z' && (Number(zone.slice(1, 3)) > 23 || Number(zone.slice(4)) > 59));
|
|
39
|
+
if (invalid) return null;
|
|
40
|
+
const time = Date.parse(value);
|
|
41
|
+
return Number.isFinite(time) ? time : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
parseSince(value) {
|
|
45
|
+
if (value === undefined) return null;
|
|
46
|
+
if (typeof value === 'string') {
|
|
47
|
+
const duration = /^(\d+(?:\.\d+)?)([smhd])$/.exec(value);
|
|
48
|
+
if (duration) {
|
|
49
|
+
const units = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
|
|
50
|
+
const time = Date.now() - Number(duration[1]) * units[duration[2]];
|
|
51
|
+
if (Number.isFinite(time) &&
|
|
52
|
+
Math.abs(time) <= 8640000000000000) return time;
|
|
53
|
+
} else {
|
|
54
|
+
const time = this.parseTime(value);
|
|
55
|
+
if (time !== null) return time;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new Error('Invalid --since: use a duration such as 30s, 5m, 2h, 1d, or an ISO timestamp with timezone (YYYY-MM-DDTHH:mm:ss[.sss]Z or ±HH:mm).');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
stop(error) {
|
|
62
|
+
if (error &&
|
|
63
|
+
error.code !== 'EPIPE') this.failure ??= error;
|
|
64
|
+
this.controller.abort();
|
|
65
|
+
this.wake?.();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
outputError(output, error) {
|
|
69
|
+
if (error.code !== 'EPIPE') {
|
|
70
|
+
if (this.finished) {
|
|
71
|
+
if (!output.reportedFailure) {
|
|
72
|
+
const name = output.stream === this.output ? 'stdout' : 'stderr';
|
|
73
|
+
process.emitWarning(`Late ${name} write failure after logs stopped: ${error.code ?? 'Error'}: ${error.message}`, {
|
|
74
|
+
code: 'MCP_PACEMAKER_LOGS_WRITE_ERROR',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
this.failure ??= error;
|
|
79
|
+
}
|
|
80
|
+
output.reportedFailure = true;
|
|
81
|
+
}
|
|
82
|
+
this.stop();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
cleanupOutput(output) {
|
|
86
|
+
if (!this.finished ||
|
|
87
|
+
output.pendingWrites !== 0 ||
|
|
88
|
+
output.awaitingError ||
|
|
89
|
+
output.detached) return;
|
|
90
|
+
output.detached = true;
|
|
91
|
+
output.stream.removeListener('error', output.onError);
|
|
92
|
+
output.stream.removeListener('close', output.onClose);
|
|
93
|
+
this.outputs.delete(output.stream);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
attachOutput(stream) {
|
|
97
|
+
const output = {
|
|
98
|
+
stream, pendingWrites: 0, awaitingError: false, errorSeen: false,
|
|
99
|
+
closed: stream.closed, detached: false, reportedFailure: false,
|
|
100
|
+
};
|
|
101
|
+
output.onError = (error) => {
|
|
102
|
+
output.errorSeen = true;
|
|
103
|
+
output.awaitingError = false;
|
|
104
|
+
this.outputError(output, error);
|
|
105
|
+
this.cleanupOutput(output);
|
|
106
|
+
};
|
|
107
|
+
output.onClose = () => {
|
|
108
|
+
output.closed = true;
|
|
109
|
+
output.awaitingError = false;
|
|
110
|
+
this.stop();
|
|
111
|
+
this.cleanupOutput(output);
|
|
112
|
+
};
|
|
113
|
+
this.outputs.set(stream, output);
|
|
114
|
+
stream.on('error', output.onError);
|
|
115
|
+
stream.on('close', output.onClose);
|
|
116
|
+
if (stream.destroyed ||
|
|
117
|
+
stream.writableEnded) this.stop();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async write(stream, data) {
|
|
121
|
+
if (this.signal.aborted) return;
|
|
122
|
+
const output = this.outputs.get(stream);
|
|
123
|
+
output.pendingWrites++;
|
|
124
|
+
await new Promise((done) => {
|
|
125
|
+
const finish = () => {
|
|
126
|
+
this.signal.removeEventListener('abort', finish);
|
|
127
|
+
done();
|
|
128
|
+
};
|
|
129
|
+
let completed = false;
|
|
130
|
+
const complete = (error, expectsErrorEvent = true) => {
|
|
131
|
+
if (completed) return;
|
|
132
|
+
completed = true;
|
|
133
|
+
output.pendingWrites--;
|
|
134
|
+
if (error) {
|
|
135
|
+
// A failed write callback can precede an asynchronous _destroy/error.
|
|
136
|
+
// Cancellation releases the caller, not our responsibility for that event.
|
|
137
|
+
output.awaitingError = expectsErrorEvent && !output.errorSeen && !output.closed;
|
|
138
|
+
this.outputError(output, error);
|
|
139
|
+
}
|
|
140
|
+
finish();
|
|
141
|
+
this.cleanupOutput(output);
|
|
142
|
+
};
|
|
143
|
+
this.signal.addEventListener('abort', finish, { once: true });
|
|
144
|
+
try {
|
|
145
|
+
stream.write(data, complete);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
complete(error, false);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fileId(stat) {
|
|
153
|
+
return `${stat.dev}:${stat.ino}:${stat.birthtimeNs}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async identity(path) {
|
|
157
|
+
try {
|
|
158
|
+
const stat = await lstat(path, { bigint: true });
|
|
159
|
+
if (!stat.isFile()) throw new Error(`Log path is not a regular file: ${path}`);
|
|
160
|
+
return this.fileId(stat);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error.code === 'ENOENT') return null;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async openFile(path) {
|
|
168
|
+
if (await this.identity(path) === null) return null;
|
|
169
|
+
let handle;
|
|
170
|
+
try {
|
|
171
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
172
|
+
this.handles.add(handle);
|
|
173
|
+
const stat = await handle.stat({ bigint: true });
|
|
174
|
+
if (!stat.isFile()) throw new Error(`Log path is not a regular file: ${path}`);
|
|
175
|
+
return {
|
|
176
|
+
handle, id: this.fileId(stat), offset: 0,
|
|
177
|
+
pending: Buffer.alloc(0), pendingBytes: 0, anchor: Buffer.alloc(0),
|
|
178
|
+
recordStart: 0, eligible: this.since === null && !this.options.server,
|
|
179
|
+
matched: false,
|
|
180
|
+
};
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (handle) await this.closeFile({ handle });
|
|
183
|
+
if (error.code === 'ENOENT') return null;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async closeFile(file) {
|
|
189
|
+
if (!file?.handle) return;
|
|
190
|
+
await file.handle.close();
|
|
191
|
+
this.handles.delete(file.handle);
|
|
192
|
+
file.handle = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async snapshot() {
|
|
196
|
+
// Open current first, then verify its name still refers to that generation. This
|
|
197
|
+
// avoids reading the same file as both .1 and current when startup races a roll.
|
|
198
|
+
while (!this.signal.aborted) {
|
|
199
|
+
const current = await this.openFile(this.path);
|
|
200
|
+
let archive;
|
|
201
|
+
try {
|
|
202
|
+
archive = await this.openFile(`${this.path}.1`);
|
|
203
|
+
if (await this.identity(this.path) === (current?.id ?? null)) {
|
|
204
|
+
if (archive?.id === current?.id) {
|
|
205
|
+
await this.closeFile(archive);
|
|
206
|
+
archive = null;
|
|
207
|
+
}
|
|
208
|
+
return { current, archive };
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
await this.closeFile(current);
|
|
212
|
+
await this.closeFile(archive);
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
await this.closeFile(current);
|
|
216
|
+
await this.closeFile(archive);
|
|
217
|
+
}
|
|
218
|
+
return { current: null, archive: null };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async replay(file, start, end) {
|
|
222
|
+
const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
223
|
+
while (start < end &&
|
|
224
|
+
!this.signal.aborted) {
|
|
225
|
+
const { bytesRead } = await file.handle.read(buffer, 0, Math.min(buffer.length, end - start), start);
|
|
226
|
+
if (!bytesRead) throw new Error('Log was truncated while reading record context; retry logs.');
|
|
227
|
+
await this.write(this.output, buffer.subarray(0, bytesRead));
|
|
228
|
+
start += bytesRead;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async line(file, buffer, start, end) {
|
|
233
|
+
const text = buffer.toString('utf8');
|
|
234
|
+
if (text.startsWith('[mcp-bridge] ')) {
|
|
235
|
+
const header = /^\[mcp-bridge\] (\S+)(?:[ \t]+([^\r\n]*))?/.exec(text);
|
|
236
|
+
const time = header ? this.parseTime(header[1]) : null;
|
|
237
|
+
const server = /^\[([^\]\r\n]+)\](?:[ \t]|$)/.exec(header?.[2] ?? '')?.[1];
|
|
238
|
+
file.recordStart = start;
|
|
239
|
+
file.matched = false;
|
|
240
|
+
file.eligible = (this.since === null || (time !== null && time >= this.since)) &&
|
|
241
|
+
(!this.options.server || (time !== null && server === this.options.server));
|
|
242
|
+
}
|
|
243
|
+
if (!file.eligible) return;
|
|
244
|
+
if (!this.grep ||
|
|
245
|
+
file.matched) {
|
|
246
|
+
await this.write(this.output, buffer);
|
|
247
|
+
} else if (text.toLowerCase().includes(this.grep)) {
|
|
248
|
+
// Keep offsets rather than buffering a whole stderr record. A match in a
|
|
249
|
+
// continuation replays its header and preceding context once, in bounded chunks.
|
|
250
|
+
await this.replay(file, file.recordStart, end);
|
|
251
|
+
file.matched = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async consume(file, buffer, start) {
|
|
256
|
+
let from = 0;
|
|
257
|
+
while (from < buffer.length &&
|
|
258
|
+
!this.signal.aborted) {
|
|
259
|
+
const newline = buffer.indexOf(10, from);
|
|
260
|
+
const to = newline < 0 ? buffer.length : newline + 1;
|
|
261
|
+
const part = buffer.subarray(from, to);
|
|
262
|
+
file.pendingBytes += part.length;
|
|
263
|
+
if (file.pendingBytes > MAX_LINE_BYTES) {
|
|
264
|
+
throw new Error(`Log line exceeds the ${MAX_LINE_BYTES}-byte safety limit.`);
|
|
265
|
+
}
|
|
266
|
+
if (file.pendingBytes > file.pending.length) {
|
|
267
|
+
const capacity = Math.min(MAX_LINE_BYTES, Math.max(CHUNK_BYTES, file.pendingBytes * 2));
|
|
268
|
+
const pending = Buffer.allocUnsafe(capacity);
|
|
269
|
+
file.pending.copy(pending, 0, 0, file.pendingBytes - part.length);
|
|
270
|
+
file.pending = pending;
|
|
271
|
+
}
|
|
272
|
+
part.copy(file.pending, file.pendingBytes - part.length);
|
|
273
|
+
if (newline >= 0) {
|
|
274
|
+
const line = file.pending.subarray(0, file.pendingBytes);
|
|
275
|
+
const end = start + to;
|
|
276
|
+
await this.line(file, line, end - file.pendingBytes, end);
|
|
277
|
+
file.pendingBytes = 0;
|
|
278
|
+
}
|
|
279
|
+
from = to;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async reset(file) {
|
|
284
|
+
await this.write(this.diagnostics, `[mcp-pacemaker logs] Log truncated or rewritten: ${this.path}\n`);
|
|
285
|
+
file.offset = 0;
|
|
286
|
+
file.pending = Buffer.alloc(0);
|
|
287
|
+
file.pendingBytes = 0;
|
|
288
|
+
file.anchor = Buffer.alloc(0);
|
|
289
|
+
file.recordStart = 0;
|
|
290
|
+
file.matched = false;
|
|
291
|
+
file.eligible = this.since === null && !this.options.server;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async drain(file) {
|
|
295
|
+
const stat = await file.handle.stat();
|
|
296
|
+
let changed = stat.size < file.offset;
|
|
297
|
+
if (!changed &&
|
|
298
|
+
file.anchor.length) {
|
|
299
|
+
const anchor = Buffer.alloc(file.anchor.length);
|
|
300
|
+
const { bytesRead } = await file.handle.read(anchor, 0, anchor.length, file.offset - anchor.length);
|
|
301
|
+
changed = bytesRead !== anchor.length || !anchor.equals(file.anchor);
|
|
302
|
+
}
|
|
303
|
+
if (changed) await this.reset(file);
|
|
304
|
+
// Use a size snapshot so a busy writer cannot starve cancellation or rotation.
|
|
305
|
+
while (file.offset < stat.size &&
|
|
306
|
+
!this.signal.aborted) {
|
|
307
|
+
const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, stat.size - file.offset));
|
|
308
|
+
const { bytesRead } = await file.handle.read(buffer, 0, buffer.length, file.offset);
|
|
309
|
+
if (!bytesRead) break;
|
|
310
|
+
const bytes = buffer.subarray(0, bytesRead);
|
|
311
|
+
await this.consume(file, bytes, file.offset);
|
|
312
|
+
file.offset += bytesRead;
|
|
313
|
+
file.anchor = Buffer.concat([file.anchor, bytes]).subarray(-ANCHOR_BYTES);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async finish(file) {
|
|
318
|
+
if (file.pendingBytes) {
|
|
319
|
+
const line = file.pending.subarray(0, file.pendingBytes);
|
|
320
|
+
await this.line(file, line, file.offset - file.pendingBytes, file.offset);
|
|
321
|
+
file.pendingBytes = 0;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async wait() {
|
|
326
|
+
if (this.signal.aborted) return;
|
|
327
|
+
await new Promise((done) => {
|
|
328
|
+
const finish = () => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
this.signal.removeEventListener('abort', finish);
|
|
331
|
+
this.wake = null;
|
|
332
|
+
done();
|
|
333
|
+
};
|
|
334
|
+
const timer = setTimeout(finish, this.interval);
|
|
335
|
+
this.wake = finish;
|
|
336
|
+
this.signal.addEventListener('abort', finish, { once: true });
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async run() {
|
|
341
|
+
const stop = () => this.stop();
|
|
342
|
+
let watcher;
|
|
343
|
+
let active;
|
|
344
|
+
let archiveId = null;
|
|
345
|
+
let waiting = false;
|
|
346
|
+
for (const stream of new Set([this.output, this.diagnostics])) this.attachOutput(stream);
|
|
347
|
+
process.on('SIGINT', stop);
|
|
348
|
+
process.on('SIGTERM', stop);
|
|
349
|
+
this.options.signal?.addEventListener('abort', stop, { once: true });
|
|
350
|
+
if (this.options.signal?.aborted) stop();
|
|
351
|
+
try {
|
|
352
|
+
if (this.options.follow) {
|
|
353
|
+
try {
|
|
354
|
+
watcher = watch(await realpath(dirname(this.path)), () => this.wake?.());
|
|
355
|
+
watcher.on('error', () => watcher.close());
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error.code !== 'ENOENT') throw error;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
do {
|
|
361
|
+
const snapshot = await this.snapshot();
|
|
362
|
+
if (!active &&
|
|
363
|
+
!snapshot.current &&
|
|
364
|
+
!this.options.follow &&
|
|
365
|
+
!this.signal.aborted) {
|
|
366
|
+
throw new Error(`Log file not found: ${this.path}`);
|
|
367
|
+
}
|
|
368
|
+
if (active) {
|
|
369
|
+
const retained = [snapshot.current, snapshot.archive].find((file) => file?.id === active.id);
|
|
370
|
+
if (retained) {
|
|
371
|
+
active.handle = retained.handle;
|
|
372
|
+
if (retained === snapshot.current) snapshot.current = active;
|
|
373
|
+
else snapshot.archive = active;
|
|
374
|
+
await this.drain(active);
|
|
375
|
+
} else {
|
|
376
|
+
await this.write(this.diagnostics, '[mcp-pacemaker logs] Previous log generation is no longer retained; unread records may have been lost.\n');
|
|
377
|
+
active = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (snapshot.current?.id !== active?.id) {
|
|
381
|
+
if (active) {
|
|
382
|
+
await this.finish(active);
|
|
383
|
+
archiveId = active.id;
|
|
384
|
+
await this.closeFile(active);
|
|
385
|
+
active = null;
|
|
386
|
+
}
|
|
387
|
+
if (snapshot.archive &&
|
|
388
|
+
snapshot.archive.id !== archiveId) {
|
|
389
|
+
await this.drain(snapshot.archive);
|
|
390
|
+
await this.finish(snapshot.archive);
|
|
391
|
+
archiveId = snapshot.archive.id;
|
|
392
|
+
}
|
|
393
|
+
active = snapshot.current;
|
|
394
|
+
if (active) await this.drain(active);
|
|
395
|
+
} else if (!active &&
|
|
396
|
+
snapshot.archive &&
|
|
397
|
+
snapshot.archive.id !== archiveId) {
|
|
398
|
+
await this.drain(snapshot.archive);
|
|
399
|
+
await this.finish(snapshot.archive);
|
|
400
|
+
archiveId = snapshot.archive.id;
|
|
401
|
+
}
|
|
402
|
+
await this.closeFile(snapshot.archive);
|
|
403
|
+
if (!this.options.follow) {
|
|
404
|
+
if (active) await this.finish(active);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
// In particular on Windows, retaining an open .1 handle can prevent the
|
|
408
|
+
// writer from replacing it at the next roll. Keep cursors, not idle handles.
|
|
409
|
+
await this.closeFile(active);
|
|
410
|
+
if (!snapshot.current) {
|
|
411
|
+
if (!waiting) await this.write(this.diagnostics, `[mcp-pacemaker logs] Waiting for ${this.path} to be created.\n`);
|
|
412
|
+
waiting = true;
|
|
413
|
+
} else {
|
|
414
|
+
waiting = false;
|
|
415
|
+
}
|
|
416
|
+
await this.options.onIdle?.();
|
|
417
|
+
await this.wait();
|
|
418
|
+
} while (!this.signal.aborted);
|
|
419
|
+
} finally {
|
|
420
|
+
watcher?.close();
|
|
421
|
+
await Promise.all([...this.handles].map((handle) => handle.close()));
|
|
422
|
+
this.handles.clear();
|
|
423
|
+
// Drain already-scheduled events, but never wait for a stalled caller-owned
|
|
424
|
+
// write. Its stream guards remain until the callback and error/close settle.
|
|
425
|
+
await yieldTurn();
|
|
426
|
+
this.finished = true;
|
|
427
|
+
for (const output of this.outputs.values()) this.cleanupOutput(output);
|
|
428
|
+
process.removeListener('SIGINT', stop);
|
|
429
|
+
process.removeListener('SIGTERM', stop);
|
|
430
|
+
this.options.signal?.removeEventListener('abort', stop);
|
|
431
|
+
}
|
|
432
|
+
if (this.failure) throw this.failure;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Dump bridge.log.1 then bridge.log beside config (default ~/.mcp-pacemaker/servers.json).
|
|
438
|
+
* Options: config, follow, since (s/m/h/d duration or strict zoned ISO), server (exact
|
|
439
|
+
* header name), grep (case-insensitive literal substring within any record line).
|
|
440
|
+
* Matching records include all their context. Follow emits complete lines as they
|
|
441
|
+
* arrive; an unfinished line is emitted at EOF only for a finite dump or file rotation.
|
|
442
|
+
* The bridge rotates between records, so each new file starts with fresh context.
|
|
443
|
+
* Memory is bounded: records are replayed by file offset; lines over 1 MiB error
|
|
444
|
+
* explicitly rather than being silently cut. Only retained log generations can be read;
|
|
445
|
+
* if both names have moved past the cursor, follow reports the possible history gap.
|
|
446
|
+
* Embedders may supply stdout/stderr Writable streams, an AbortSignal, pollIntervalMs
|
|
447
|
+
* (default 100), and onIdle (called after a follow scan, before waiting).
|
|
448
|
+
* Resolves on completion, cancellation, or EPIPE; rejects input/filesystem/output errors.
|
|
449
|
+
* Pending writes stay error-protected after cancellation. Later non-EPIPE failures
|
|
450
|
+
* produce a process warning with code MCP_PACEMAKER_LOGS_WRITE_ERROR.
|
|
451
|
+
* Never closes caller-owned output streams or changes process.exitCode.
|
|
452
|
+
*/
|
|
453
|
+
export async function runLogs(options = {}) {
|
|
454
|
+
for (const name of ['config', 'server', 'grep']) {
|
|
455
|
+
if (options[name] !== undefined &&
|
|
456
|
+
typeof options[name] !== 'string') throw new Error(`Invalid --${name}: expected text.`);
|
|
457
|
+
}
|
|
458
|
+
if (options.config === '' ||
|
|
459
|
+
options.server === '') throw new Error('--config and --server must not be empty.');
|
|
460
|
+
if (options.pollIntervalMs !== undefined &&
|
|
461
|
+
(!Number.isFinite(options.pollIntervalMs) || options.pollIntervalMs < 1 || options.pollIntervalMs > 2147483647)) {
|
|
462
|
+
throw new Error('pollIntervalMs must be between 1 and 2147483647.');
|
|
463
|
+
}
|
|
464
|
+
await new LogReader(options).run();
|
|
465
|
+
}
|