@ycodium-ai/plugin-resource-telemetry 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/index.js +1311 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @ycodium-ai/plugin-resource-telemetry
|
|
2
|
+
|
|
3
|
+
Measures what this machine's processes cost in CPU, memory, and disk. The
|
|
4
|
+
diagnostics page reads this plugin; there is no separate settings card.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
A Rust sidecar samples processes. This package owns the protocol, the merge,
|
|
9
|
+
and the history projection, and exposes:
|
|
10
|
+
|
|
11
|
+
- **snapshots** — a live stream (latest 8 frames; slow subscribers drop old
|
|
12
|
+
ones, not host memory)
|
|
13
|
+
- **snapshot** — one current sample on demand
|
|
14
|
+
- **history** — bucketed CPU, memory, and I/O from the sidecar ring buffer
|
|
15
|
+
- **retry** — closes the collector's circuit breaker and restarts the monitor
|
|
16
|
+
|
|
17
|
+
The sidecar path is injected by the host as `config:resourceMonitorPath`. If
|
|
18
|
+
the host cannot resolve a binary, that key is omitted and spawn is refused.
|
|
19
|
+
Unloading the plugin stops the sidecar.
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
A Ycodium server that can resolve `ycodium-resource-monitor`. The plugin asks
|
|
24
|
+
to spawn that one program, at most one at a time.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
ycodium plugin add @ycodium-ai/plugin-resource-telemetry
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The plugin ships enabled with Ycodium as `core.resource-telemetry`. Disabling
|
|
33
|
+
it stops the measuring process; the diagnostics page reports that it is not
|
|
34
|
+
running rather than showing stale figures. Uninstalling it writes a removal
|
|
35
|
+
onto the home patch, so a restart or upgrade does not bring it back.
|
|
36
|
+
Reinstalling the unversioned bundled package restores the same entry without
|
|
37
|
+
an npm fetch.
|
|
38
|
+
|
|
39
|
+
## Surfaces
|
|
40
|
+
|
|
41
|
+
- **Web / Desktop** — diagnostics page, via this plugin's commands and stream.
|
|
42
|
+
- **Mobile** — the same stream is in the background-activity reporter; there
|
|
43
|
+
is no native diagnostics page that draws the process table.
|
|
44
|
+
|
|
45
|
+
## Developing
|
|
46
|
+
|
|
47
|
+
The package builds its own code into `dist/index.js`. The host supplies the
|
|
48
|
+
shared `@ycodium-ai/plugin-api` at runtime. The plugin requires plugin API
|
|
49
|
+
version 2. The monitor binary stays with the host.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
UNLICENSED.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import * as NodeProcess from "node:process";
|
|
3
|
+
import {
|
|
4
|
+
definePlugin
|
|
5
|
+
} from "@ycodium-ai/plugin-api";
|
|
6
|
+
|
|
7
|
+
// src/model.ts
|
|
8
|
+
var RESOURCE_MONITOR_PROTOCOL_VERSION = 2;
|
|
9
|
+
var MAX_DELTA_INTERVAL_MS = 3e4;
|
|
10
|
+
var MAX_HISTORY_WINDOW_MS = 60 * 6e4;
|
|
11
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
var finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
13
|
+
var nonNegativeInt = (value) => {
|
|
14
|
+
const parsed = finite(value);
|
|
15
|
+
return parsed === null || parsed < 0 || !Number.isInteger(parsed) ? null : parsed;
|
|
16
|
+
};
|
|
17
|
+
var positiveInt = (value) => {
|
|
18
|
+
const parsed = nonNegativeInt(value);
|
|
19
|
+
return parsed === null || parsed === 0 ? null : parsed;
|
|
20
|
+
};
|
|
21
|
+
var nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
22
|
+
var boolean = (value) => typeof value === "boolean" ? value : null;
|
|
23
|
+
var readEventVersion = (value) => isRecord(value) ? finite(value["version"]) : null;
|
|
24
|
+
var parseCapabilities = (value) => {
|
|
25
|
+
if (!isRecord(value)) return null;
|
|
26
|
+
const flags = [
|
|
27
|
+
"cumulativeCpuTime",
|
|
28
|
+
"currentCpuPercent",
|
|
29
|
+
"residentMemory",
|
|
30
|
+
"virtualMemory",
|
|
31
|
+
"ioBytes",
|
|
32
|
+
"processStartTime",
|
|
33
|
+
"processTree"
|
|
34
|
+
];
|
|
35
|
+
const parsed = {};
|
|
36
|
+
for (const flag of flags) {
|
|
37
|
+
const item = boolean(value[flag]);
|
|
38
|
+
if (item === null) return null;
|
|
39
|
+
parsed[flag] = item;
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
};
|
|
43
|
+
var parseProcessSample = (value) => {
|
|
44
|
+
if (!isRecord(value)) return null;
|
|
45
|
+
const pid2 = positiveInt(value["pid"]);
|
|
46
|
+
const ppid = nonNegativeInt(value["ppid"]);
|
|
47
|
+
const startTimeMs = nonNegativeInt(value["startTimeMs"]);
|
|
48
|
+
const runTimeMs = nonNegativeInt(value["runTimeMs"]);
|
|
49
|
+
const cpuPercent = finite(value["cpuPercent"]);
|
|
50
|
+
const cpuTimeMs = nonNegativeInt(value["cpuTimeMs"]);
|
|
51
|
+
const residentBytes = nonNegativeInt(value["residentBytes"]);
|
|
52
|
+
const virtualBytes = nonNegativeInt(value["virtualBytes"]);
|
|
53
|
+
const ioReadBytes = nonNegativeInt(value["ioReadBytes"]);
|
|
54
|
+
const ioWriteBytes = nonNegativeInt(value["ioWriteBytes"]);
|
|
55
|
+
const ioSemantics = value["ioSemantics"];
|
|
56
|
+
const name = value["name"];
|
|
57
|
+
const command = value["command"];
|
|
58
|
+
const status = value["status"];
|
|
59
|
+
if (pid2 === null || ppid === null || startTimeMs === null || runTimeMs === null || cpuPercent === null || cpuTimeMs === null || residentBytes === null || virtualBytes === null || ioReadBytes === null || ioWriteBytes === null || typeof name !== "string" || typeof command !== "string" || typeof status !== "string" || ioSemantics !== "storage" && ioSemantics !== "all-io") {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
pid: pid2,
|
|
64
|
+
ppid,
|
|
65
|
+
startTimeMs,
|
|
66
|
+
runTimeMs,
|
|
67
|
+
name,
|
|
68
|
+
command,
|
|
69
|
+
status,
|
|
70
|
+
cpuPercent,
|
|
71
|
+
cpuTimeMs,
|
|
72
|
+
residentBytes,
|
|
73
|
+
virtualBytes,
|
|
74
|
+
ioReadBytes,
|
|
75
|
+
ioWriteBytes,
|
|
76
|
+
ioSemantics
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
var parseSnapshot = (value) => {
|
|
80
|
+
const sequence = nonNegativeInt(value["sequence"]);
|
|
81
|
+
const sampledAtUnixMs = nonNegativeInt(value["sampledAtUnixMs"]);
|
|
82
|
+
const collectionDurationMicros = nonNegativeInt(value["collectionDurationMicros"]);
|
|
83
|
+
const scannedProcessCount = nonNegativeInt(value["scannedProcessCount"]);
|
|
84
|
+
const retainedProcessCount = nonNegativeInt(value["retainedProcessCount"]);
|
|
85
|
+
const inaccessibleProcessCount = nonNegativeInt(value["inaccessibleProcessCount"]);
|
|
86
|
+
const rawProcesses = value["processes"];
|
|
87
|
+
if (sequence === null || sampledAtUnixMs === null || collectionDurationMicros === null || scannedProcessCount === null || retainedProcessCount === null || inaccessibleProcessCount === null || !Array.isArray(rawProcesses)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const processes = [];
|
|
91
|
+
for (const item of rawProcesses) {
|
|
92
|
+
const parsed = parseProcessSample(item);
|
|
93
|
+
if (parsed === null) return null;
|
|
94
|
+
processes.push(parsed);
|
|
95
|
+
}
|
|
96
|
+
const requestId = nonEmptyString(value["requestId"]);
|
|
97
|
+
return {
|
|
98
|
+
type: "snapshot",
|
|
99
|
+
sequence,
|
|
100
|
+
sampledAtUnixMs,
|
|
101
|
+
collectionDurationMicros,
|
|
102
|
+
scannedProcessCount,
|
|
103
|
+
retainedProcessCount,
|
|
104
|
+
inaccessibleProcessCount,
|
|
105
|
+
...requestId === null ? {} : { requestId },
|
|
106
|
+
processes
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
var parseMonitorEvent = (value) => {
|
|
110
|
+
if (!isRecord(value)) return null;
|
|
111
|
+
switch (value["type"]) {
|
|
112
|
+
case "hello": {
|
|
113
|
+
const sidecarVersion = nonEmptyString(value["sidecarVersion"]);
|
|
114
|
+
const sidecarPid = positiveInt(value["sidecarPid"]);
|
|
115
|
+
const platform = nonEmptyString(value["platform"]);
|
|
116
|
+
const arch = nonEmptyString(value["arch"]);
|
|
117
|
+
const capabilities = parseCapabilities(value["capabilities"]);
|
|
118
|
+
if (sidecarVersion === null || sidecarPid === null || platform === null || arch === null || capabilities === null) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return { type: "hello", sidecarVersion, sidecarPid, platform, arch, capabilities };
|
|
122
|
+
}
|
|
123
|
+
case "snapshot":
|
|
124
|
+
return parseSnapshot(value);
|
|
125
|
+
case "historyChunk": {
|
|
126
|
+
const requestId = nonEmptyString(value["requestId"]);
|
|
127
|
+
const done = boolean(value["done"]);
|
|
128
|
+
const rawSnapshots = value["snapshots"];
|
|
129
|
+
if (requestId === null || done === null || !Array.isArray(rawSnapshots)) return null;
|
|
130
|
+
const snapshots = [];
|
|
131
|
+
for (const item of rawSnapshots) {
|
|
132
|
+
if (!isRecord(item)) return null;
|
|
133
|
+
const parsed = parseSnapshot(item);
|
|
134
|
+
if (parsed === null) return null;
|
|
135
|
+
snapshots.push(parsed);
|
|
136
|
+
}
|
|
137
|
+
return { type: "historyChunk", requestId, done, snapshots };
|
|
138
|
+
}
|
|
139
|
+
case "error": {
|
|
140
|
+
const code = nonEmptyString(value["code"]);
|
|
141
|
+
const message = nonEmptyString(value["message"]);
|
|
142
|
+
const recoverable = boolean(value["recoverable"]);
|
|
143
|
+
if (code === null || message === null || recoverable === null) return null;
|
|
144
|
+
return { type: "error", code, message, recoverable };
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var emptyGroupCounters = () => ({
|
|
151
|
+
cpuTimeMs: 0,
|
|
152
|
+
ioReadBytes: 0,
|
|
153
|
+
ioWriteBytes: 0,
|
|
154
|
+
processStarts: 0,
|
|
155
|
+
processExits: 0
|
|
156
|
+
});
|
|
157
|
+
var emptyTelemetryCounters = () => ({
|
|
158
|
+
backend: emptyGroupCounters(),
|
|
159
|
+
monitor: emptyGroupCounters(),
|
|
160
|
+
all: emptyGroupCounters()
|
|
161
|
+
});
|
|
162
|
+
var processIdentityKey = (pid2, startTimeMs) => `${pid2}:${startTimeMs}`;
|
|
163
|
+
var toIso = (epochMs) => new Date(epochMs).toISOString();
|
|
164
|
+
var finiteNonNegative = (value) => Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
165
|
+
var categoryGroup = (category) => category === "resource-monitor" ? "monitor" : "backend";
|
|
166
|
+
var delta = (input) => {
|
|
167
|
+
if (input.elapsedMs <= 0 || input.elapsedMs > MAX_DELTA_INTERVAL_MS || input.current < input.previous) {
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
return input.current - input.previous;
|
|
171
|
+
};
|
|
172
|
+
var processDepths = (processes, roots) => {
|
|
173
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
174
|
+
for (const process of processes) {
|
|
175
|
+
const children = childrenByParent.get(process.ppid) ?? [];
|
|
176
|
+
children.push(process.pid);
|
|
177
|
+
childrenByParent.set(process.ppid, children);
|
|
178
|
+
}
|
|
179
|
+
const depths = /* @__PURE__ */ new Map();
|
|
180
|
+
const queue = [...roots].map((pid2) => ({ pid: pid2, depth: 0 }));
|
|
181
|
+
while (queue.length > 0) {
|
|
182
|
+
const current = queue.shift();
|
|
183
|
+
if (!current || depths.has(current.pid)) continue;
|
|
184
|
+
depths.set(current.pid, current.depth);
|
|
185
|
+
for (const childPid of childrenByParent.get(current.pid) ?? []) {
|
|
186
|
+
queue.push({ pid: childPid, depth: current.depth + 1 });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return depths;
|
|
190
|
+
};
|
|
191
|
+
var orderProcessTree = (processes, rootPids) => {
|
|
192
|
+
const processesByPid = new Map(processes.map((process) => [process.identity.pid, process]));
|
|
193
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
194
|
+
for (const process of processes) {
|
|
195
|
+
const children = childrenByParent.get(process.ppid) ?? [];
|
|
196
|
+
children.push(process);
|
|
197
|
+
childrenByParent.set(process.ppid, children);
|
|
198
|
+
}
|
|
199
|
+
for (const children of childrenByParent.values()) {
|
|
200
|
+
children.sort((left, right) => left.identity.pid - right.identity.pid);
|
|
201
|
+
}
|
|
202
|
+
const ordered = [];
|
|
203
|
+
const visited = /* @__PURE__ */ new Set();
|
|
204
|
+
const visit = (process) => {
|
|
205
|
+
if (visited.has(process.identity.pid)) return;
|
|
206
|
+
visited.add(process.identity.pid);
|
|
207
|
+
ordered.push(process);
|
|
208
|
+
for (const child of childrenByParent.get(process.identity.pid) ?? []) visit(child);
|
|
209
|
+
};
|
|
210
|
+
for (const rootPid of rootPids) {
|
|
211
|
+
const root = processesByPid.get(rootPid);
|
|
212
|
+
if (root) visit(root);
|
|
213
|
+
}
|
|
214
|
+
for (const process of processes.toSorted(
|
|
215
|
+
(left, right) => left.depth - right.depth || left.identity.pid - right.identity.pid
|
|
216
|
+
)) {
|
|
217
|
+
visit(process);
|
|
218
|
+
}
|
|
219
|
+
return ordered;
|
|
220
|
+
};
|
|
221
|
+
var incrementCounters = (counters, update) => ({
|
|
222
|
+
cpuTimeMs: counters.cpuTimeMs + (update.cpuTimeMs ?? 0),
|
|
223
|
+
ioReadBytes: counters.ioReadBytes + (update.ioReadBytes ?? 0),
|
|
224
|
+
ioWriteBytes: counters.ioWriteBytes + (update.ioWriteBytes ?? 0),
|
|
225
|
+
processStarts: counters.processStarts + (update.processStarts ?? 0),
|
|
226
|
+
processExits: counters.processExits + (update.processExits ?? 0)
|
|
227
|
+
});
|
|
228
|
+
var applyLifecycleCounters = (input) => {
|
|
229
|
+
let backend = input.counters.backend;
|
|
230
|
+
let monitor = input.counters.monitor;
|
|
231
|
+
let all = input.counters.all;
|
|
232
|
+
const bump = (category, update) => {
|
|
233
|
+
if (categoryGroup(category) === "monitor") monitor = incrementCounters(monitor, update);
|
|
234
|
+
else backend = incrementCounters(backend, update);
|
|
235
|
+
all = incrementCounters(all, update);
|
|
236
|
+
};
|
|
237
|
+
for (const processDelta of input.deltas) bump(processDelta.category, processDelta);
|
|
238
|
+
for (const [identityKey, current] of input.current) {
|
|
239
|
+
if (input.previous.has(identityKey)) continue;
|
|
240
|
+
bump(current.process.category, { processStarts: 1 });
|
|
241
|
+
}
|
|
242
|
+
for (const [identityKey, previous] of input.previous) {
|
|
243
|
+
if (input.current.has(identityKey)) continue;
|
|
244
|
+
bump(previous.process.category, { processExits: 1 });
|
|
245
|
+
}
|
|
246
|
+
return { backend, monitor, all };
|
|
247
|
+
};
|
|
248
|
+
var aggregate = (processes, counters) => ({
|
|
249
|
+
processCount: processes.length,
|
|
250
|
+
currentCpuPercent: processes.reduce((total, process) => total + process.cpuPercent, 0),
|
|
251
|
+
cpuTimeMs: counters.cpuTimeMs,
|
|
252
|
+
currentRssBytes: processes.reduce((total, process) => total + process.residentBytes, 0),
|
|
253
|
+
peakRssBytes: processes.reduce((total, process) => total + process.peakResidentBytes, 0),
|
|
254
|
+
ioReadBytes: counters.ioReadBytes,
|
|
255
|
+
ioWriteBytes: counters.ioWriteBytes,
|
|
256
|
+
ioReadBytesPerSecond: processes.reduce(
|
|
257
|
+
(total, process) => total + process.ioReadBytesPerSecond,
|
|
258
|
+
0
|
|
259
|
+
),
|
|
260
|
+
ioWriteBytesPerSecond: processes.reduce(
|
|
261
|
+
(total, process) => total + process.ioWriteBytesPerSecond,
|
|
262
|
+
0
|
|
263
|
+
),
|
|
264
|
+
processStarts: counters.processStarts,
|
|
265
|
+
processExits: counters.processExits
|
|
266
|
+
});
|
|
267
|
+
var mergeProcesses = (input) => {
|
|
268
|
+
const sampledAtMs = input.snapshot.sampledAtUnixMs;
|
|
269
|
+
const samples = input.snapshot.processes;
|
|
270
|
+
const rootPids = [input.serverPid];
|
|
271
|
+
const depths = processDepths(samples, new Set(rootPids));
|
|
272
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
273
|
+
for (const sample of samples) {
|
|
274
|
+
const children = childrenByParent.get(sample.ppid) ?? [];
|
|
275
|
+
children.push(sample.pid);
|
|
276
|
+
childrenByParent.set(sample.ppid, children);
|
|
277
|
+
}
|
|
278
|
+
const nextPrevious = /* @__PURE__ */ new Map();
|
|
279
|
+
const deltas = [];
|
|
280
|
+
const normalized = samples.map((sample) => {
|
|
281
|
+
const identityKey = processIdentityKey(sample.pid, sample.startTimeMs);
|
|
282
|
+
const previous = input.previous.get(identityKey);
|
|
283
|
+
const elapsedMs = previous ? sampledAtMs - previous.sampledAtMs : 0;
|
|
284
|
+
const cpuTimeDelta = previous ? delta({ current: sample.cpuTimeMs, previous: previous.process.cpuTimeMs, elapsedMs }) : 0;
|
|
285
|
+
const ioReadDelta = previous ? delta({ current: sample.ioReadBytes, previous: previous.process.ioReadBytes, elapsedMs }) : 0;
|
|
286
|
+
const ioWriteDelta = previous ? delta({ current: sample.ioWriteBytes, previous: previous.process.ioWriteBytes, elapsedMs }) : 0;
|
|
287
|
+
const category = sample.pid === input.serverPid ? "server" : sample.pid === input.sidecarPid ? "resource-monitor" : "server-child";
|
|
288
|
+
const cpuPercent = previous && elapsedMs > 0 && elapsedMs <= MAX_DELTA_INTERVAL_MS ? cpuTimeDelta / elapsedMs * 100 : finiteNonNegative(sample.cpuPercent);
|
|
289
|
+
const process = {
|
|
290
|
+
identity: { pid: sample.pid, startTimeMs: sample.startTimeMs },
|
|
291
|
+
ppid: sample.ppid,
|
|
292
|
+
childPids: [...childrenByParent.get(sample.pid) ?? []].toSorted(
|
|
293
|
+
(left, right) => left - right
|
|
294
|
+
),
|
|
295
|
+
depth: depths.get(sample.pid) ?? 0,
|
|
296
|
+
name: sample.name,
|
|
297
|
+
command: sample.command,
|
|
298
|
+
status: sample.status,
|
|
299
|
+
category,
|
|
300
|
+
cpuPercent: finiteNonNegative(cpuPercent),
|
|
301
|
+
cpuTimeMs: sample.cpuTimeMs,
|
|
302
|
+
residentBytes: sample.residentBytes,
|
|
303
|
+
peakResidentBytes: Math.max(sample.residentBytes, previous?.process.peakResidentBytes ?? 0),
|
|
304
|
+
virtualBytes: sample.virtualBytes,
|
|
305
|
+
ioReadBytes: sample.ioReadBytes,
|
|
306
|
+
ioWriteBytes: sample.ioWriteBytes,
|
|
307
|
+
ioReadBytesPerSecond: elapsedMs > 0 ? finiteNonNegative(ioReadDelta * 1e3 / elapsedMs) : 0,
|
|
308
|
+
ioWriteBytesPerSecond: elapsedMs > 0 ? finiteNonNegative(ioWriteDelta * 1e3 / elapsedMs) : 0,
|
|
309
|
+
ioSemantics: sample.ioSemantics,
|
|
310
|
+
runTimeMs: sample.runTimeMs,
|
|
311
|
+
firstSeenAtIso: previous?.process.firstSeenAtIso ?? toIso(sampledAtMs),
|
|
312
|
+
lastSeenAtIso: toIso(sampledAtMs)
|
|
313
|
+
};
|
|
314
|
+
nextPrevious.set(identityKey, { process, sampledAtMs });
|
|
315
|
+
deltas.push({
|
|
316
|
+
identityKey,
|
|
317
|
+
category,
|
|
318
|
+
cpuTimeMs: cpuTimeDelta,
|
|
319
|
+
ioReadBytes: ioReadDelta,
|
|
320
|
+
ioWriteBytes: ioWriteDelta
|
|
321
|
+
});
|
|
322
|
+
return process;
|
|
323
|
+
});
|
|
324
|
+
const ordered = orderProcessTree(normalized, rootPids);
|
|
325
|
+
const counters = applyLifecycleCounters({
|
|
326
|
+
counters: input.counters,
|
|
327
|
+
deltas,
|
|
328
|
+
current: nextPrevious,
|
|
329
|
+
previous: input.previous
|
|
330
|
+
});
|
|
331
|
+
const monitorProcesses = ordered.filter(
|
|
332
|
+
(process) => categoryGroup(process.category) === "monitor"
|
|
333
|
+
);
|
|
334
|
+
const backendProcesses = ordered.filter(
|
|
335
|
+
(process) => categoryGroup(process.category) === "backend"
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
sampledAtMs,
|
|
339
|
+
processes: ordered,
|
|
340
|
+
previous: nextPrevious,
|
|
341
|
+
counters,
|
|
342
|
+
groups: {
|
|
343
|
+
backend: aggregate(backendProcesses, counters.backend),
|
|
344
|
+
monitor: aggregate(monitorProcesses, counters.monitor),
|
|
345
|
+
all: aggregate(ordered, counters.all)
|
|
346
|
+
},
|
|
347
|
+
deltas
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
var normalizeHistoryInput = (input) => {
|
|
351
|
+
const windowMs = Math.max(1e3, Math.min(MAX_HISTORY_WINDOW_MS, Math.round(input.windowMs)));
|
|
352
|
+
return {
|
|
353
|
+
windowMs,
|
|
354
|
+
bucketMs: Math.max(1e3, Math.min(windowMs, Math.round(input.bucketMs)))
|
|
355
|
+
};
|
|
356
|
+
};
|
|
357
|
+
var summarizeProcesses = (samples) => {
|
|
358
|
+
const groups = /* @__PURE__ */ new Map();
|
|
359
|
+
for (const sample of samples) {
|
|
360
|
+
const identityKey = processIdentityKey(
|
|
361
|
+
sample.process.identity.pid,
|
|
362
|
+
sample.process.identity.startTimeMs
|
|
363
|
+
);
|
|
364
|
+
const current = groups.get(identityKey) ?? [];
|
|
365
|
+
current.push(sample);
|
|
366
|
+
groups.set(identityKey, current);
|
|
367
|
+
}
|
|
368
|
+
return [...groups.values()].map((processSamples) => {
|
|
369
|
+
const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs);
|
|
370
|
+
const first = sorted[0];
|
|
371
|
+
const latest = sorted[sorted.length - 1];
|
|
372
|
+
const cpuTotal = sorted.reduce((total, sample) => total + sample.process.cpuPercent, 0);
|
|
373
|
+
return {
|
|
374
|
+
identity: latest.process.identity,
|
|
375
|
+
ppid: latest.process.ppid,
|
|
376
|
+
depth: latest.process.depth,
|
|
377
|
+
name: latest.process.name,
|
|
378
|
+
command: latest.process.command,
|
|
379
|
+
category: latest.process.category,
|
|
380
|
+
firstSeenAtIso: first.process.firstSeenAtIso,
|
|
381
|
+
lastSeenAtIso: latest.process.lastSeenAtIso,
|
|
382
|
+
currentCpuPercent: latest.process.cpuPercent,
|
|
383
|
+
avgCpuPercent: cpuTotal / sorted.length,
|
|
384
|
+
maxCpuPercent: Math.max(...sorted.map((sample) => sample.process.cpuPercent)),
|
|
385
|
+
cpuTimeMs: sorted.reduce((total, sample) => total + sample.cpuTimeMs, 0),
|
|
386
|
+
currentRssBytes: latest.process.residentBytes,
|
|
387
|
+
peakRssBytes: Math.max(...sorted.map((sample) => sample.process.residentBytes)),
|
|
388
|
+
ioReadBytes: sorted.reduce((total, sample) => total + sample.ioReadBytes, 0),
|
|
389
|
+
ioWriteBytes: sorted.reduce((total, sample) => total + sample.ioWriteBytes, 0),
|
|
390
|
+
ioSemantics: latest.process.ioSemantics,
|
|
391
|
+
sampleCount: sorted.length
|
|
392
|
+
};
|
|
393
|
+
}).toSorted(
|
|
394
|
+
(left, right) => right.cpuTimeMs - left.cpuTimeMs || right.peakRssBytes - left.peakRssBytes
|
|
395
|
+
);
|
|
396
|
+
};
|
|
397
|
+
var buildBuckets = (input) => {
|
|
398
|
+
const buckets = [];
|
|
399
|
+
for (let startedAtMs = input.nowMs - input.windowMs; startedAtMs < input.nowMs; startedAtMs += input.bucketMs) {
|
|
400
|
+
const endedAtMs = Math.min(input.nowMs, startedAtMs + input.bucketMs);
|
|
401
|
+
const samples = input.samples.filter(
|
|
402
|
+
(sample) => sample.sampledAtMs >= startedAtMs && (endedAtMs === input.nowMs ? sample.sampledAtMs <= endedAtMs : sample.sampledAtMs < endedAtMs)
|
|
403
|
+
);
|
|
404
|
+
const cpuTotal = samples.reduce((total, sample) => total + sample.cpuPercent, 0);
|
|
405
|
+
buckets.push({
|
|
406
|
+
startedAtIso: toIso(startedAtMs),
|
|
407
|
+
endedAtIso: toIso(endedAtMs),
|
|
408
|
+
avgCpuPercent: samples.length === 0 ? 0 : cpuTotal / samples.length,
|
|
409
|
+
maxCpuPercent: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.cpuPercent)),
|
|
410
|
+
maxRssBytes: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.rssBytes)),
|
|
411
|
+
ioReadBytes: samples.reduce((total, sample) => total + sample.ioReadBytes, 0),
|
|
412
|
+
ioWriteBytes: samples.reduce((total, sample) => total + sample.ioWriteBytes, 0),
|
|
413
|
+
maxProcessCount: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.processCount))
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return buckets;
|
|
417
|
+
};
|
|
418
|
+
var buildHistory = (input) => {
|
|
419
|
+
const { windowMs, bucketMs } = normalizeHistoryInput(input);
|
|
420
|
+
const windowStartMs = input.readAtMs - windowMs;
|
|
421
|
+
const eligible = input.snapshots.filter((snapshot) => snapshot.sampledAtUnixMs <= input.readAtMs).toSorted((left, right) => left.sampledAtUnixMs - right.sampledAtUnixMs);
|
|
422
|
+
const inWindow = eligible.filter((snapshot) => snapshot.sampledAtUnixMs >= windowStartMs);
|
|
423
|
+
const preceding = eligible.findLast((snapshot) => snapshot.sampledAtUnixMs < windowStartMs);
|
|
424
|
+
const snapshots = preceding ? [preceding, ...inWindow] : inWindow;
|
|
425
|
+
const aggregateSamples = [];
|
|
426
|
+
const processSamples = [];
|
|
427
|
+
let previous = /* @__PURE__ */ new Map();
|
|
428
|
+
let counters = emptyTelemetryCounters();
|
|
429
|
+
let previousSnapshotAtMs;
|
|
430
|
+
for (const snapshot of snapshots) {
|
|
431
|
+
const windowFraction = previousSnapshotAtMs !== void 0 && previousSnapshotAtMs < windowStartMs && snapshot.sampledAtUnixMs > previousSnapshotAtMs ? Math.max(
|
|
432
|
+
0,
|
|
433
|
+
Math.min(
|
|
434
|
+
1,
|
|
435
|
+
(snapshot.sampledAtUnixMs - windowStartMs) / (snapshot.sampledAtUnixMs - previousSnapshotAtMs)
|
|
436
|
+
)
|
|
437
|
+
) : 1;
|
|
438
|
+
previousSnapshotAtMs = snapshot.sampledAtUnixMs;
|
|
439
|
+
const merged = mergeProcesses({
|
|
440
|
+
serverPid: input.serverPid,
|
|
441
|
+
sidecarPid: input.sidecarPid,
|
|
442
|
+
snapshot,
|
|
443
|
+
previous,
|
|
444
|
+
counters
|
|
445
|
+
});
|
|
446
|
+
previous = new Map([...previous, ...merged.previous]);
|
|
447
|
+
counters = merged.counters;
|
|
448
|
+
if (snapshot.sampledAtUnixMs < windowStartMs) continue;
|
|
449
|
+
const deltas = windowFraction === 1 ? merged.deltas : merged.deltas.map((processDelta) => ({
|
|
450
|
+
...processDelta,
|
|
451
|
+
cpuTimeMs: Math.round(processDelta.cpuTimeMs * windowFraction),
|
|
452
|
+
ioReadBytes: Math.round(processDelta.ioReadBytes * windowFraction),
|
|
453
|
+
ioWriteBytes: Math.round(processDelta.ioWriteBytes * windowFraction)
|
|
454
|
+
}));
|
|
455
|
+
const deltasByIdentity = new Map(
|
|
456
|
+
deltas.map((processDelta) => [processDelta.identityKey, processDelta])
|
|
457
|
+
);
|
|
458
|
+
aggregateSamples.push({
|
|
459
|
+
sampledAtMs: snapshot.sampledAtUnixMs,
|
|
460
|
+
cpuPercent: merged.groups.all.currentCpuPercent,
|
|
461
|
+
rssBytes: merged.groups.all.currentRssBytes,
|
|
462
|
+
processCount: merged.groups.all.processCount,
|
|
463
|
+
ioReadBytes: deltas.reduce((total, item) => total + item.ioReadBytes, 0),
|
|
464
|
+
ioWriteBytes: deltas.reduce((total, item) => total + item.ioWriteBytes, 0)
|
|
465
|
+
});
|
|
466
|
+
for (const process of merged.processes) {
|
|
467
|
+
const processDelta = deltasByIdentity.get(
|
|
468
|
+
processIdentityKey(process.identity.pid, process.identity.startTimeMs)
|
|
469
|
+
);
|
|
470
|
+
processSamples.push({
|
|
471
|
+
sampledAtMs: snapshot.sampledAtUnixMs,
|
|
472
|
+
process,
|
|
473
|
+
cpuTimeMs: processDelta?.cpuTimeMs ?? 0,
|
|
474
|
+
ioReadBytes: processDelta?.ioReadBytes ?? 0,
|
|
475
|
+
ioWriteBytes: processDelta?.ioWriteBytes ?? 0
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
windowMs,
|
|
481
|
+
bucketMs,
|
|
482
|
+
retainedSampleCount: aggregateSamples.length + processSamples.length,
|
|
483
|
+
buckets: buildBuckets({
|
|
484
|
+
samples: aggregateSamples,
|
|
485
|
+
nowMs: input.readAtMs,
|
|
486
|
+
windowMs,
|
|
487
|
+
bucketMs
|
|
488
|
+
}),
|
|
489
|
+
topProcesses: summarizeProcesses(processSamples)
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// src/sidecar.ts
|
|
494
|
+
import * as NodeCrypto from "node:crypto";
|
|
495
|
+
var SAMPLE_INTERVAL_MS = 1e3;
|
|
496
|
+
var UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS = 5e3;
|
|
497
|
+
var BATTERY_SAMPLE_INTERVAL_MS = 5e3;
|
|
498
|
+
var CONSTRAINED_SAMPLE_INTERVAL_MS = 15e3;
|
|
499
|
+
var HANDSHAKE_TIMEOUT_MS = 5e3;
|
|
500
|
+
var SAMPLE_REQUEST_TIMEOUT_MS = 5e3;
|
|
501
|
+
var HISTORY_REQUEST_TIMEOUT_MS = 15e3;
|
|
502
|
+
var INITIAL_RESTART_DELAY_MS = 500;
|
|
503
|
+
var MAX_RESTART_DELAY_MS = 1e4;
|
|
504
|
+
var FAILURE_WINDOW_MS = 6e4;
|
|
505
|
+
var MAX_FAILURES_PER_WINDOW = 5;
|
|
506
|
+
var STDERR_TAIL_LIMIT = 2e3;
|
|
507
|
+
var resolveSampleIntervalMs = (power, liveSubscriberCount) => {
|
|
508
|
+
if (power === null || power.stale || power.source === "unknown") {
|
|
509
|
+
return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS;
|
|
510
|
+
}
|
|
511
|
+
if (power.suspended || power.locked === "true" || power.lowPowerMode === "true" || power.thermalState === "serious" || power.thermalState === "critical") {
|
|
512
|
+
return CONSTRAINED_SAMPLE_INTERVAL_MS;
|
|
513
|
+
}
|
|
514
|
+
return power.onBattery === "false" ? SAMPLE_INTERVAL_MS : BATTERY_SAMPLE_INTERVAL_MS;
|
|
515
|
+
};
|
|
516
|
+
var restartDelayMs = (attempt) => Math.min(INITIAL_RESTART_DELAY_MS * 2 ** attempt, MAX_RESTART_DELAY_MS);
|
|
517
|
+
var toError = (value) => value instanceof Error ? value : new Error(String(value));
|
|
518
|
+
var TelemetrySidecar = class {
|
|
519
|
+
input;
|
|
520
|
+
state = {
|
|
521
|
+
status: "starting",
|
|
522
|
+
hello: null,
|
|
523
|
+
lastSampleAtMs: null,
|
|
524
|
+
lastError: null,
|
|
525
|
+
restartCount: 0,
|
|
526
|
+
collectionDurationMicros: 0,
|
|
527
|
+
scannedProcessCount: 0,
|
|
528
|
+
retainedProcessCount: 0,
|
|
529
|
+
inaccessibleProcessCount: 0
|
|
530
|
+
};
|
|
531
|
+
listeners = /* @__PURE__ */ new Set();
|
|
532
|
+
pendingSamples = /* @__PURE__ */ new Map();
|
|
533
|
+
pendingHistories = /* @__PURE__ */ new Map();
|
|
534
|
+
handle = null;
|
|
535
|
+
generation = 0;
|
|
536
|
+
liveSubscriberCount = 0;
|
|
537
|
+
power = null;
|
|
538
|
+
sampleIntervalMs = UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS;
|
|
539
|
+
streamingApplied = false;
|
|
540
|
+
intervalApplied = 0;
|
|
541
|
+
stopped = false;
|
|
542
|
+
supervisor = null;
|
|
543
|
+
supervisorRunning = false;
|
|
544
|
+
/** 命令按序写,不并发:两条命令交错写进同一条 stdin 会拼出一行坏 JSON。 */
|
|
545
|
+
writeChain = Promise.resolve();
|
|
546
|
+
retrySignal = null;
|
|
547
|
+
/** 重启请求可能落在「刚失败、还没开始等」的缝里;记成标志,等的那一方进门先查。 */
|
|
548
|
+
retryRequested = false;
|
|
549
|
+
constructor(input) {
|
|
550
|
+
this.input = input;
|
|
551
|
+
}
|
|
552
|
+
/** 起监督循环。不 await:activate 有 5 秒上限,握手不该占用它。 */
|
|
553
|
+
start() {
|
|
554
|
+
if (this.supervisor !== null || this.stopped) return;
|
|
555
|
+
if (this.input.program === null) {
|
|
556
|
+
this.state.status = "unavailable";
|
|
557
|
+
this.state.lastError = "the resource monitor binary was not resolved on this environment";
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.supervisor = this.supervise().catch((error) => {
|
|
561
|
+
this.state.status = "unavailable";
|
|
562
|
+
this.state.lastError = toError(error).message;
|
|
563
|
+
this.input.log.warn("resource monitor supervisor stopped unexpectedly", {
|
|
564
|
+
error: toError(error).message
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/** 插件停用即杀 sidecar:不再重启,句柄自己关掉,不指望别人替它收尾。 */
|
|
569
|
+
async stop() {
|
|
570
|
+
this.stopped = true;
|
|
571
|
+
this.retrySignal?.();
|
|
572
|
+
this.retrySignal = null;
|
|
573
|
+
const handle = this.handle;
|
|
574
|
+
this.handle = null;
|
|
575
|
+
if (handle !== null) {
|
|
576
|
+
try {
|
|
577
|
+
await handle.kill();
|
|
578
|
+
} catch {
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
this.failPending(new Error("the resource monitor was stopped"));
|
|
582
|
+
this.state.status = "stopped";
|
|
583
|
+
this.state.hello = null;
|
|
584
|
+
const supervisor = this.supervisor;
|
|
585
|
+
this.supervisor = null;
|
|
586
|
+
if (supervisor !== null) await supervisor;
|
|
587
|
+
}
|
|
588
|
+
health() {
|
|
589
|
+
return {
|
|
590
|
+
status: this.state.status,
|
|
591
|
+
sidecarVersion: this.state.hello?.sidecarVersion ?? null,
|
|
592
|
+
sidecarPid: this.state.hello?.sidecarPid ?? null,
|
|
593
|
+
restartCount: this.state.restartCount,
|
|
594
|
+
lastSampleAtIso: this.state.lastSampleAtMs === null ? null : new Date(this.state.lastSampleAtMs).toISOString(),
|
|
595
|
+
lastError: this.state.lastError,
|
|
596
|
+
sampleIntervalMs: this.sampleIntervalMs,
|
|
597
|
+
collectionDurationMicros: this.state.collectionDurationMicros,
|
|
598
|
+
scannedProcessCount: this.state.scannedProcessCount,
|
|
599
|
+
retainedProcessCount: this.state.retainedProcessCount,
|
|
600
|
+
inaccessibleProcessCount: this.state.inaccessibleProcessCount
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** sidecar 自己的 pid,用来把它从被观察的进程里认出来。 */
|
|
604
|
+
sidecarPid() {
|
|
605
|
+
return this.state.hello?.sidecarPid ?? null;
|
|
606
|
+
}
|
|
607
|
+
onSnapshot(listener) {
|
|
608
|
+
this.listeners.add(listener);
|
|
609
|
+
return () => {
|
|
610
|
+
this.listeners.delete(listener);
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
/** 订阅方 +1;返回的函数是 -1。数到 0 就关掉推流。 */
|
|
614
|
+
retainStreaming() {
|
|
615
|
+
this.liveSubscriberCount += 1;
|
|
616
|
+
void this.applyControl();
|
|
617
|
+
let released = false;
|
|
618
|
+
return () => {
|
|
619
|
+
if (released) return;
|
|
620
|
+
released = true;
|
|
621
|
+
this.liveSubscriberCount = Math.max(0, this.liveSubscriberCount - 1);
|
|
622
|
+
void this.applyControl();
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/** 电源快照只能轮询(宿主不推给插件),拿到就重算节奏。 */
|
|
626
|
+
applyPower(power) {
|
|
627
|
+
this.power = power;
|
|
628
|
+
void this.applyControl();
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* 显式重启。熔断之后唯一能合上电路的东西;跑着的时候调它没有意义,返回 false。
|
|
632
|
+
*/
|
|
633
|
+
retry() {
|
|
634
|
+
if (this.stopped || this.input.program === null) return false;
|
|
635
|
+
if (this.state.status === "healthy" || this.state.status === "starting") return false;
|
|
636
|
+
if (this.handle !== null) return false;
|
|
637
|
+
if (!this.supervisorRunning) return false;
|
|
638
|
+
this.retryRequested = true;
|
|
639
|
+
this.retrySignal?.();
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
async sampleNow() {
|
|
643
|
+
this.assertCommandable();
|
|
644
|
+
const requestId = NodeCrypto.randomUUID();
|
|
645
|
+
const settled = new Promise((resolve, reject) => {
|
|
646
|
+
this.pendingSamples.set(requestId, { resolve, reject });
|
|
647
|
+
});
|
|
648
|
+
try {
|
|
649
|
+
await this.write({
|
|
650
|
+
version: RESOURCE_MONITOR_PROTOCOL_VERSION,
|
|
651
|
+
type: "sampleNow",
|
|
652
|
+
requestId
|
|
653
|
+
});
|
|
654
|
+
return await withTimeout(settled, SAMPLE_REQUEST_TIMEOUT_MS, "sampleNow");
|
|
655
|
+
} finally {
|
|
656
|
+
this.pendingSamples.delete(requestId);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async readHistory(windowMs) {
|
|
660
|
+
this.assertCommandable();
|
|
661
|
+
const requestId = NodeCrypto.randomUUID();
|
|
662
|
+
const settled = new Promise((resolve, reject) => {
|
|
663
|
+
this.pendingHistories.set(requestId, { resolve, reject, snapshots: [] });
|
|
664
|
+
});
|
|
665
|
+
try {
|
|
666
|
+
await this.write({
|
|
667
|
+
version: RESOURCE_MONITOR_PROTOCOL_VERSION,
|
|
668
|
+
type: "readHistory",
|
|
669
|
+
requestId,
|
|
670
|
+
windowMs: Math.max(0, Math.round(windowMs))
|
|
671
|
+
});
|
|
672
|
+
return await withTimeout(settled, HISTORY_REQUEST_TIMEOUT_MS, "readHistory");
|
|
673
|
+
} finally {
|
|
674
|
+
this.pendingHistories.delete(requestId);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// ── 内部 ────────────────────────────────────────────────────
|
|
678
|
+
assertCommandable() {
|
|
679
|
+
if (this.handle === null || this.state.status !== "healthy" && this.state.status !== "degraded") {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`the resource monitor is not running: ${this.state.lastError ?? this.state.status}`
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
write(command) {
|
|
686
|
+
const handle = this.handle;
|
|
687
|
+
if (handle === null) return Promise.reject(new Error("the resource monitor is not running"));
|
|
688
|
+
const line = `${JSON.stringify(command)}
|
|
689
|
+
`;
|
|
690
|
+
this.writeChain = this.writeChain.then(
|
|
691
|
+
() => handle.writeStdin(line),
|
|
692
|
+
() => handle.writeStdin(line)
|
|
693
|
+
);
|
|
694
|
+
return this.writeChain;
|
|
695
|
+
}
|
|
696
|
+
/** 把「想要的采样节奏 / 推不推流」推给 sidecar;只在真的变了时才写命令。 */
|
|
697
|
+
async applyControl() {
|
|
698
|
+
const desiredInterval = resolveSampleIntervalMs(this.power, this.liveSubscriberCount);
|
|
699
|
+
this.sampleIntervalMs = desiredInterval;
|
|
700
|
+
const desiredStreaming = this.liveSubscriberCount > 0;
|
|
701
|
+
if (this.handle === null) return;
|
|
702
|
+
if (this.state.status !== "healthy" && this.state.status !== "degraded") return;
|
|
703
|
+
try {
|
|
704
|
+
if (this.intervalApplied !== desiredInterval) {
|
|
705
|
+
await this.write({
|
|
706
|
+
version: RESOURCE_MONITOR_PROTOCOL_VERSION,
|
|
707
|
+
type: "setSampleInterval",
|
|
708
|
+
sampleIntervalMs: desiredInterval
|
|
709
|
+
});
|
|
710
|
+
this.intervalApplied = desiredInterval;
|
|
711
|
+
}
|
|
712
|
+
if (this.streamingApplied !== desiredStreaming) {
|
|
713
|
+
await this.write({
|
|
714
|
+
version: RESOURCE_MONITOR_PROTOCOL_VERSION,
|
|
715
|
+
type: "setStreaming",
|
|
716
|
+
enabled: desiredStreaming
|
|
717
|
+
});
|
|
718
|
+
this.streamingApplied = desiredStreaming;
|
|
719
|
+
}
|
|
720
|
+
} catch (error) {
|
|
721
|
+
this.input.log.warn("resource monitor control write failed", {
|
|
722
|
+
error: toError(error).message
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
failPending(error) {
|
|
727
|
+
for (const pending of this.pendingSamples.values()) pending.reject(error);
|
|
728
|
+
this.pendingSamples.clear();
|
|
729
|
+
for (const pending of this.pendingHistories.values()) pending.reject(error);
|
|
730
|
+
this.pendingHistories.clear();
|
|
731
|
+
}
|
|
732
|
+
async supervise() {
|
|
733
|
+
this.supervisorRunning = true;
|
|
734
|
+
try {
|
|
735
|
+
await this.superviseLoop();
|
|
736
|
+
} finally {
|
|
737
|
+
this.supervisorRunning = false;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async superviseLoop() {
|
|
741
|
+
let failures = [];
|
|
742
|
+
let attempt = 0;
|
|
743
|
+
while (!this.stopped) {
|
|
744
|
+
const outcome = await this.runAttempt();
|
|
745
|
+
if (this.stopped) break;
|
|
746
|
+
if (outcome.fatal) {
|
|
747
|
+
this.state.status = "unavailable";
|
|
748
|
+
this.state.lastError = outcome.error.message;
|
|
749
|
+
this.failPending(outcome.error);
|
|
750
|
+
this.input.log.warn("resource monitor cannot start", { error: outcome.error.message });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const now = Date.now();
|
|
754
|
+
failures = failures.filter((failedAt) => now - failedAt <= FAILURE_WINDOW_MS);
|
|
755
|
+
if (failures.length === 0) attempt = 0;
|
|
756
|
+
failures.push(now);
|
|
757
|
+
const exhausted = failures.length >= MAX_FAILURES_PER_WINDOW;
|
|
758
|
+
this.state.status = exhausted ? "unavailable" : "degraded";
|
|
759
|
+
this.state.hello = null;
|
|
760
|
+
this.state.lastError = outcome.error.message;
|
|
761
|
+
this.state.restartCount += 1;
|
|
762
|
+
this.failPending(outcome.error);
|
|
763
|
+
this.input.log.warn("resource monitor stopped", {
|
|
764
|
+
error: outcome.error.message,
|
|
765
|
+
restartCount: this.state.restartCount,
|
|
766
|
+
circuitOpen: exhausted
|
|
767
|
+
});
|
|
768
|
+
if (exhausted) {
|
|
769
|
+
await this.waitForRetry();
|
|
770
|
+
if (this.stopped) break;
|
|
771
|
+
failures = [];
|
|
772
|
+
attempt = 0;
|
|
773
|
+
this.state.status = "starting";
|
|
774
|
+
this.state.lastError = null;
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
const manuallyRetried = await this.waitForRetryOrDelay(restartDelayMs(attempt));
|
|
778
|
+
attempt = manuallyRetried ? 0 : attempt + 1;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
consumeRetryRequest() {
|
|
782
|
+
const requested = this.retryRequested;
|
|
783
|
+
this.retryRequested = false;
|
|
784
|
+
return requested;
|
|
785
|
+
}
|
|
786
|
+
waitForRetry() {
|
|
787
|
+
if (this.consumeRetryRequest()) return Promise.resolve();
|
|
788
|
+
return new Promise((resolve) => {
|
|
789
|
+
this.retrySignal = () => {
|
|
790
|
+
this.retrySignal = null;
|
|
791
|
+
this.retryRequested = false;
|
|
792
|
+
resolve();
|
|
793
|
+
};
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
waitForRetryOrDelay(delayMs) {
|
|
797
|
+
if (this.consumeRetryRequest()) return Promise.resolve(true);
|
|
798
|
+
return new Promise((resolve) => {
|
|
799
|
+
const timer = setTimeout(() => {
|
|
800
|
+
this.retrySignal = null;
|
|
801
|
+
resolve(false);
|
|
802
|
+
}, delayMs);
|
|
803
|
+
this.retrySignal = () => {
|
|
804
|
+
this.retrySignal = null;
|
|
805
|
+
this.retryRequested = false;
|
|
806
|
+
clearTimeout(timer);
|
|
807
|
+
resolve(true);
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* 一次 incarnation:起进程、握手、跑到它以某种方式结束为止。返回结束的原因;
|
|
813
|
+
* `fatal` 表示这个原因重试也没用。
|
|
814
|
+
*/
|
|
815
|
+
async runAttempt() {
|
|
816
|
+
const program = this.input.program;
|
|
817
|
+
if (program === null) {
|
|
818
|
+
return {
|
|
819
|
+
error: new Error("the resource monitor binary was not resolved on this environment"),
|
|
820
|
+
fatal: true
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
let handle;
|
|
824
|
+
try {
|
|
825
|
+
handle = await this.input.process.spawn({ program });
|
|
826
|
+
} catch (error2) {
|
|
827
|
+
const reason = error2.reason;
|
|
828
|
+
return {
|
|
829
|
+
error: toError(error2),
|
|
830
|
+
fatal: reason === "undeclared" || reason === "not-declared" || reason === "unknown-key"
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
this.generation += 1;
|
|
834
|
+
const generation = this.generation;
|
|
835
|
+
this.handle = handle;
|
|
836
|
+
this.writeChain = Promise.resolve();
|
|
837
|
+
this.streamingApplied = false;
|
|
838
|
+
this.intervalApplied = 0;
|
|
839
|
+
this.state.status = "starting";
|
|
840
|
+
this.state.hello = null;
|
|
841
|
+
let settle = () => void 0;
|
|
842
|
+
const ended = new Promise((resolve) => {
|
|
843
|
+
settle = resolve;
|
|
844
|
+
});
|
|
845
|
+
let onHello = () => void 0;
|
|
846
|
+
const helloReceived = new Promise((resolve) => {
|
|
847
|
+
onHello = resolve;
|
|
848
|
+
});
|
|
849
|
+
let stderrTail = "";
|
|
850
|
+
const stdoutPump = pumpLines(handle.stdout, (line) => {
|
|
851
|
+
this.consumeLine(line, generation, onHello, settle);
|
|
852
|
+
}).then(
|
|
853
|
+
() => settle(new Error("the resource monitor event stream closed")),
|
|
854
|
+
(error2) => settle(toError(error2))
|
|
855
|
+
);
|
|
856
|
+
const stderrPump = pumpChunks(handle.stderr, (text) => {
|
|
857
|
+
stderrTail = `${stderrTail}${text}`.slice(-STDERR_TAIL_LIMIT);
|
|
858
|
+
}).catch(() => void 0);
|
|
859
|
+
void handle.exited.then(
|
|
860
|
+
(code) => {
|
|
861
|
+
const tail = stderrTail.trim();
|
|
862
|
+
settle(
|
|
863
|
+
new Error(
|
|
864
|
+
`the resource monitor exited with code ${String(code)}${tail.length > 0 ? `: ${tail}` : ""}`
|
|
865
|
+
)
|
|
866
|
+
);
|
|
867
|
+
},
|
|
868
|
+
() => settle(new Error("the resource monitor ended unexpectedly"))
|
|
869
|
+
);
|
|
870
|
+
const hello = await raceHandshake(helloReceived, ended, HANDSHAKE_TIMEOUT_MS);
|
|
871
|
+
if (hello !== null) {
|
|
872
|
+
this.state.hello = hello;
|
|
873
|
+
this.state.status = "healthy";
|
|
874
|
+
this.state.lastError = null;
|
|
875
|
+
try {
|
|
876
|
+
await this.write({
|
|
877
|
+
version: RESOURCE_MONITOR_PROTOCOL_VERSION,
|
|
878
|
+
type: "configure",
|
|
879
|
+
rootPid: this.input.rootPid,
|
|
880
|
+
sampleIntervalMs: resolveSampleIntervalMs(this.power, this.liveSubscriberCount),
|
|
881
|
+
externalProcesses: []
|
|
882
|
+
});
|
|
883
|
+
this.intervalApplied = resolveSampleIntervalMs(this.power, this.liveSubscriberCount);
|
|
884
|
+
this.sampleIntervalMs = this.intervalApplied;
|
|
885
|
+
if (this.liveSubscriberCount > 0) {
|
|
886
|
+
await this.write({
|
|
887
|
+
version: RESOURCE_MONITOR_PROTOCOL_VERSION,
|
|
888
|
+
type: "setStreaming",
|
|
889
|
+
enabled: true
|
|
890
|
+
});
|
|
891
|
+
this.streamingApplied = true;
|
|
892
|
+
}
|
|
893
|
+
} catch (error2) {
|
|
894
|
+
settle(toError(error2));
|
|
895
|
+
}
|
|
896
|
+
} else {
|
|
897
|
+
settle(new Error(`the resource monitor handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`));
|
|
898
|
+
}
|
|
899
|
+
const error = await ended;
|
|
900
|
+
this.handle = null;
|
|
901
|
+
try {
|
|
902
|
+
await handle.kill();
|
|
903
|
+
} catch {
|
|
904
|
+
}
|
|
905
|
+
await Promise.all([stdoutPump, stderrPump]);
|
|
906
|
+
return { error, fatal: false };
|
|
907
|
+
}
|
|
908
|
+
consumeLine(line, generation, onHello, settle) {
|
|
909
|
+
if (line.trim().length === 0) return;
|
|
910
|
+
let value;
|
|
911
|
+
try {
|
|
912
|
+
value = JSON.parse(line);
|
|
913
|
+
} catch {
|
|
914
|
+
settle(new Error("the resource monitor wrote a line that is not JSON"));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const version = readEventVersion(value);
|
|
918
|
+
if (version !== null && version !== RESOURCE_MONITOR_PROTOCOL_VERSION) {
|
|
919
|
+
settle(
|
|
920
|
+
new Error(
|
|
921
|
+
`the resource monitor speaks protocol ${String(version)}, this build expects ${String(RESOURCE_MONITOR_PROTOCOL_VERSION)}`
|
|
922
|
+
)
|
|
923
|
+
);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const event = parseMonitorEvent(value);
|
|
927
|
+
if (event === null) {
|
|
928
|
+
settle(new Error("the resource monitor sent an event this build does not understand"));
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
switch (event.type) {
|
|
932
|
+
case "hello":
|
|
933
|
+
onHello(event);
|
|
934
|
+
return;
|
|
935
|
+
case "snapshot": {
|
|
936
|
+
this.state.status = "healthy";
|
|
937
|
+
this.state.lastError = null;
|
|
938
|
+
this.state.lastSampleAtMs = event.sampledAtUnixMs;
|
|
939
|
+
this.state.collectionDurationMicros = event.collectionDurationMicros;
|
|
940
|
+
this.state.scannedProcessCount = event.scannedProcessCount;
|
|
941
|
+
this.state.retainedProcessCount = event.retainedProcessCount;
|
|
942
|
+
this.state.inaccessibleProcessCount = event.inaccessibleProcessCount;
|
|
943
|
+
const requestId = event.requestId;
|
|
944
|
+
if (requestId !== void 0) {
|
|
945
|
+
const pending = this.pendingSamples.get(requestId);
|
|
946
|
+
if (pending !== void 0) {
|
|
947
|
+
this.pendingSamples.delete(requestId);
|
|
948
|
+
pending.resolve(event);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
for (const listener of this.listeners) listener({ generation, snapshot: event });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
case "historyChunk": {
|
|
955
|
+
const pending = this.pendingHistories.get(event.requestId);
|
|
956
|
+
if (pending === void 0) return;
|
|
957
|
+
pending.snapshots.push(...event.snapshots);
|
|
958
|
+
if (event.done) {
|
|
959
|
+
this.pendingHistories.delete(event.requestId);
|
|
960
|
+
pending.resolve(pending.snapshots);
|
|
961
|
+
}
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
case "error":
|
|
965
|
+
this.state.lastError = event.message;
|
|
966
|
+
if (event.recoverable) {
|
|
967
|
+
this.state.status = "degraded";
|
|
968
|
+
this.input.log.warn("resource monitor reported an error", {
|
|
969
|
+
code: event.code,
|
|
970
|
+
error: event.message
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
settle(new Error(`the resource monitor failed (${event.code}): ${event.message}`));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
var raceHandshake = (hello, ended, timeoutMs) => {
|
|
980
|
+
let timer;
|
|
981
|
+
return Promise.race([
|
|
982
|
+
hello,
|
|
983
|
+
ended.then(() => null),
|
|
984
|
+
new Promise((resolve) => {
|
|
985
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
986
|
+
})
|
|
987
|
+
]).finally(() => {
|
|
988
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
989
|
+
});
|
|
990
|
+
};
|
|
991
|
+
var withTimeout = (promise, timeoutMs, operation) => {
|
|
992
|
+
let timer;
|
|
993
|
+
return Promise.race([
|
|
994
|
+
promise,
|
|
995
|
+
new Promise((_, reject) => {
|
|
996
|
+
timer = setTimeout(() => {
|
|
997
|
+
reject(
|
|
998
|
+
new Error(
|
|
999
|
+
`the resource monitor '${operation}' request timed out after ${String(timeoutMs)}ms`
|
|
1000
|
+
)
|
|
1001
|
+
);
|
|
1002
|
+
}, timeoutMs);
|
|
1003
|
+
})
|
|
1004
|
+
]).finally(() => {
|
|
1005
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1006
|
+
});
|
|
1007
|
+
};
|
|
1008
|
+
var pumpLines = async (source, onLine) => {
|
|
1009
|
+
const decoder = new TextDecoder();
|
|
1010
|
+
let buffer = "";
|
|
1011
|
+
for await (const chunk of source) {
|
|
1012
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1013
|
+
let index = buffer.indexOf("\n");
|
|
1014
|
+
while (index >= 0) {
|
|
1015
|
+
onLine(buffer.slice(0, index));
|
|
1016
|
+
buffer = buffer.slice(index + 1);
|
|
1017
|
+
index = buffer.indexOf("\n");
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
onLine(buffer);
|
|
1021
|
+
};
|
|
1022
|
+
var pumpChunks = async (source, onText) => {
|
|
1023
|
+
const decoder = new TextDecoder();
|
|
1024
|
+
for await (const chunk of source) onText(decoder.decode(chunk, { stream: true }));
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
// src/index.ts
|
|
1028
|
+
var unknownPower = (readAtIso) => ({
|
|
1029
|
+
source: "unknown",
|
|
1030
|
+
idle: "unknown",
|
|
1031
|
+
idleSeconds: null,
|
|
1032
|
+
locked: "unknown",
|
|
1033
|
+
suspended: false,
|
|
1034
|
+
onBattery: "unknown",
|
|
1035
|
+
lowPowerMode: "unknown",
|
|
1036
|
+
thermalState: "unknown",
|
|
1037
|
+
stale: true,
|
|
1038
|
+
updatedAtIso: readAtIso
|
|
1039
|
+
});
|
|
1040
|
+
var EMPTY_PAYLOAD_SCHEMA = {
|
|
1041
|
+
type: "object",
|
|
1042
|
+
properties: {},
|
|
1043
|
+
additionalProperties: false
|
|
1044
|
+
};
|
|
1045
|
+
var HISTORY_PAYLOAD_SCHEMA = {
|
|
1046
|
+
type: "object",
|
|
1047
|
+
properties: {
|
|
1048
|
+
windowMs: { type: "number", minimum: 0 },
|
|
1049
|
+
bucketMs: { type: "number", minimum: 0 }
|
|
1050
|
+
},
|
|
1051
|
+
additionalProperties: false
|
|
1052
|
+
};
|
|
1053
|
+
var DEFAULT_HISTORY_WINDOW_MS = 15 * 6e4;
|
|
1054
|
+
var DEFAULT_HISTORY_BUCKET_MS = 15e3;
|
|
1055
|
+
var POWER_POLL_INTERVAL_MS = 3e4;
|
|
1056
|
+
var TelemetryRuntime = class {
|
|
1057
|
+
ctx;
|
|
1058
|
+
sidecar;
|
|
1059
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
1060
|
+
previous = /* @__PURE__ */ new Map();
|
|
1061
|
+
counters = emptyTelemetryCounters();
|
|
1062
|
+
latest;
|
|
1063
|
+
lastGeneration = 0;
|
|
1064
|
+
lastSequence = -1;
|
|
1065
|
+
/** 合并按序做:两份快照并发进来会各自读到同一份 `previous`,速率就双算了。 */
|
|
1066
|
+
chain = Promise.resolve();
|
|
1067
|
+
powerWarned = false;
|
|
1068
|
+
attributionWarned = false;
|
|
1069
|
+
constructor(ctx) {
|
|
1070
|
+
this.ctx = ctx;
|
|
1071
|
+
const program = ctx.config.resourceMonitorPath?.trim();
|
|
1072
|
+
this.sidecar = new TelemetrySidecar({
|
|
1073
|
+
process: ctx.process,
|
|
1074
|
+
program: program === void 0 || program.length === 0 ? null : program,
|
|
1075
|
+
rootPid: NodeProcess.pid,
|
|
1076
|
+
log: ctx.log
|
|
1077
|
+
});
|
|
1078
|
+
const readAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
1079
|
+
const empty = mergeProcesses({
|
|
1080
|
+
serverPid: NodeProcess.pid,
|
|
1081
|
+
sidecarPid: null,
|
|
1082
|
+
snapshot: {
|
|
1083
|
+
type: "snapshot",
|
|
1084
|
+
sequence: 0,
|
|
1085
|
+
sampledAtUnixMs: Date.now(),
|
|
1086
|
+
collectionDurationMicros: 0,
|
|
1087
|
+
scannedProcessCount: 0,
|
|
1088
|
+
retainedProcessCount: 0,
|
|
1089
|
+
inaccessibleProcessCount: 0,
|
|
1090
|
+
processes: []
|
|
1091
|
+
},
|
|
1092
|
+
previous: /* @__PURE__ */ new Map(),
|
|
1093
|
+
counters: emptyTelemetryCounters()
|
|
1094
|
+
});
|
|
1095
|
+
this.latest = {
|
|
1096
|
+
readAtIso,
|
|
1097
|
+
sampleIntervalMs: this.sidecar.health().sampleIntervalMs,
|
|
1098
|
+
processes: [],
|
|
1099
|
+
groups: empty.groups,
|
|
1100
|
+
power: unknownPower(readAtIso),
|
|
1101
|
+
attribution: { readAtIso, entries: [] },
|
|
1102
|
+
health: this.sidecar.health()
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
start() {
|
|
1106
|
+
this.sidecar.onSnapshot((item) => this.enqueue(item));
|
|
1107
|
+
this.sidecar.start();
|
|
1108
|
+
}
|
|
1109
|
+
async stop() {
|
|
1110
|
+
this.subscribers.clear();
|
|
1111
|
+
await this.sidecar.stop();
|
|
1112
|
+
}
|
|
1113
|
+
currentSnapshot() {
|
|
1114
|
+
this.latest = { ...this.latest, health: this.sidecar.health() };
|
|
1115
|
+
return this.latest;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* 取一份尽量新的:能采就现采一次,采不到就退回缓存的那一份,不让读侧失败。
|
|
1119
|
+
*
|
|
1120
|
+
* 采到的那一份是经**订阅回调**进合并的(sidecar 把每一份快照都广播出去,包括
|
|
1121
|
+
* 应答 `sampleNow` 的那一份),所以这里等的是合并队列,不是再喂一次。
|
|
1122
|
+
*/
|
|
1123
|
+
async refreshedSnapshot() {
|
|
1124
|
+
try {
|
|
1125
|
+
await this.sidecar.sampleNow();
|
|
1126
|
+
await this.chain;
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
this.ctx.log.warn("resource monitor sample failed", { error: messageOf(error) });
|
|
1129
|
+
}
|
|
1130
|
+
return this.currentSnapshot();
|
|
1131
|
+
}
|
|
1132
|
+
async readHistory(input) {
|
|
1133
|
+
const normalized = normalizeHistoryInput(input);
|
|
1134
|
+
let snapshots = [];
|
|
1135
|
+
try {
|
|
1136
|
+
snapshots = await this.sidecar.readHistory(normalized.windowMs);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
this.ctx.log.warn("resource monitor history read failed", { error: messageOf(error) });
|
|
1139
|
+
}
|
|
1140
|
+
const health = this.sidecar.health();
|
|
1141
|
+
const built = buildHistory({
|
|
1142
|
+
readAtMs: Date.now(),
|
|
1143
|
+
windowMs: normalized.windowMs,
|
|
1144
|
+
bucketMs: normalized.bucketMs,
|
|
1145
|
+
serverPid: NodeProcess.pid,
|
|
1146
|
+
sidecarPid: this.sidecar.sidecarPid(),
|
|
1147
|
+
snapshots
|
|
1148
|
+
});
|
|
1149
|
+
return {
|
|
1150
|
+
readAtIso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1151
|
+
windowMs: built.windowMs,
|
|
1152
|
+
bucketMs: built.bucketMs,
|
|
1153
|
+
sampleIntervalMs: health.sampleIntervalMs,
|
|
1154
|
+
retainedSampleCount: built.retainedSampleCount,
|
|
1155
|
+
buckets: built.buckets,
|
|
1156
|
+
topProcesses: built.topProcesses,
|
|
1157
|
+
health
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
retry() {
|
|
1161
|
+
return { accepted: this.sidecar.retry(), snapshot: this.currentSnapshot() };
|
|
1162
|
+
}
|
|
1163
|
+
/** 订阅一条流:+1 推流引用,返回的函数在订阅方挂断时调。 */
|
|
1164
|
+
retain(listener) {
|
|
1165
|
+
this.subscribers.add(listener);
|
|
1166
|
+
const release = this.sidecar.retainStreaming();
|
|
1167
|
+
void this.sidecar.sampleNow().catch(() => void 0);
|
|
1168
|
+
return () => {
|
|
1169
|
+
this.subscribers.delete(listener);
|
|
1170
|
+
release();
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
/** 电源轮询:拿到就喂给 sidecar 重算节奏,并把它记进下一份快照。 */
|
|
1174
|
+
async pollPower() {
|
|
1175
|
+
this.sidecar.applyPower(await this.readPower());
|
|
1176
|
+
}
|
|
1177
|
+
enqueue(item) {
|
|
1178
|
+
this.chain = this.chain.then(
|
|
1179
|
+
() => this.ingest(item),
|
|
1180
|
+
() => this.ingest(item)
|
|
1181
|
+
);
|
|
1182
|
+
void this.chain.catch((error) => {
|
|
1183
|
+
this.ctx.log.warn("resource telemetry ingest failed", { error: messageOf(error) });
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
async ingest(item) {
|
|
1187
|
+
if (item.generation < this.lastGeneration) return;
|
|
1188
|
+
if (item.generation === this.lastGeneration && item.snapshot.sequence <= this.lastSequence) {
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const merged = mergeProcesses({
|
|
1192
|
+
serverPid: NodeProcess.pid,
|
|
1193
|
+
sidecarPid: this.sidecar.sidecarPid(),
|
|
1194
|
+
snapshot: item.snapshot,
|
|
1195
|
+
previous: this.previous,
|
|
1196
|
+
counters: this.counters
|
|
1197
|
+
});
|
|
1198
|
+
this.previous = merged.previous;
|
|
1199
|
+
this.counters = merged.counters;
|
|
1200
|
+
this.lastGeneration = item.generation;
|
|
1201
|
+
this.lastSequence = item.snapshot.sequence;
|
|
1202
|
+
const [power, attribution] = await Promise.all([this.readPower(), this.readAttribution()]);
|
|
1203
|
+
const readAtIso = new Date(merged.sampledAtMs).toISOString();
|
|
1204
|
+
this.sidecar.applyPower(power);
|
|
1205
|
+
this.latest = {
|
|
1206
|
+
readAtIso,
|
|
1207
|
+
sampleIntervalMs: this.sidecar.health().sampleIntervalMs,
|
|
1208
|
+
processes: merged.processes,
|
|
1209
|
+
groups: merged.groups,
|
|
1210
|
+
power: power ?? unknownPower(readAtIso),
|
|
1211
|
+
attribution: attribution ?? { readAtIso, entries: [] },
|
|
1212
|
+
health: this.sidecar.health()
|
|
1213
|
+
};
|
|
1214
|
+
for (const listener of this.subscribers) listener(this.latest);
|
|
1215
|
+
}
|
|
1216
|
+
/** 宿主没装遥测层(`ycodium x` 那种 CLI 装配)时这条会被拒;那不该拖垮采样。 */
|
|
1217
|
+
async readPower() {
|
|
1218
|
+
try {
|
|
1219
|
+
return await this.ctx.projections.getPower();
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
if (!this.powerWarned) {
|
|
1222
|
+
this.powerWarned = true;
|
|
1223
|
+
this.ctx.log.warn("host power state is unavailable", { error: messageOf(error) });
|
|
1224
|
+
}
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
async readAttribution() {
|
|
1229
|
+
try {
|
|
1230
|
+
return await this.ctx.projections.getAttribution();
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
if (!this.attributionWarned) {
|
|
1233
|
+
this.attributionWarned = true;
|
|
1234
|
+
this.ctx.log.warn("host io attribution is unavailable", { error: messageOf(error) });
|
|
1235
|
+
}
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
var messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
1241
|
+
var numberField = (payload, key, fallback) => {
|
|
1242
|
+
const value = payload[key];
|
|
1243
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
1244
|
+
};
|
|
1245
|
+
var index_default = definePlugin({
|
|
1246
|
+
id: "resource-telemetry",
|
|
1247
|
+
apiVersion: 2,
|
|
1248
|
+
activate: (ctx) => {
|
|
1249
|
+
const runtime = new TelemetryRuntime(ctx);
|
|
1250
|
+
runtime.start();
|
|
1251
|
+
ctx.stream({
|
|
1252
|
+
name: "snapshots",
|
|
1253
|
+
description: "Live process, group, power and I/O telemetry for this environment.",
|
|
1254
|
+
scope: "read",
|
|
1255
|
+
payloadSchema: EMPTY_PAYLOAD_SCHEMA,
|
|
1256
|
+
// 持续推状态的流必须封顶:慢订阅方丢掉的是旧的那几份,不是宿主的内存。
|
|
1257
|
+
buffer: { latest: 8 },
|
|
1258
|
+
handle: async (_payload, emit, signal) => {
|
|
1259
|
+
const release = runtime.retain((snapshot) => {
|
|
1260
|
+
if (!signal.aborted) emit(snapshot);
|
|
1261
|
+
});
|
|
1262
|
+
try {
|
|
1263
|
+
emit(runtime.currentSnapshot());
|
|
1264
|
+
await new Promise((resolve) => {
|
|
1265
|
+
if (signal.aborted) {
|
|
1266
|
+
resolve();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
1270
|
+
});
|
|
1271
|
+
} finally {
|
|
1272
|
+
release();
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
ctx.command({
|
|
1277
|
+
name: "snapshot",
|
|
1278
|
+
description: "One current telemetry snapshot, sampled on demand.",
|
|
1279
|
+
scope: "read",
|
|
1280
|
+
payloadSchema: EMPTY_PAYLOAD_SCHEMA,
|
|
1281
|
+
handle: () => runtime.refreshedSnapshot()
|
|
1282
|
+
});
|
|
1283
|
+
ctx.command({
|
|
1284
|
+
name: "history",
|
|
1285
|
+
description: "Bucketed CPU, memory and I/O history replayed from the sidecar's ring buffer.",
|
|
1286
|
+
scope: "read",
|
|
1287
|
+
payloadSchema: HISTORY_PAYLOAD_SCHEMA,
|
|
1288
|
+
handle: (payload) => runtime.readHistory({
|
|
1289
|
+
windowMs: numberField(payload, "windowMs", DEFAULT_HISTORY_WINDOW_MS),
|
|
1290
|
+
bucketMs: numberField(payload, "bucketMs", DEFAULT_HISTORY_BUCKET_MS)
|
|
1291
|
+
})
|
|
1292
|
+
});
|
|
1293
|
+
ctx.command({
|
|
1294
|
+
name: "retry",
|
|
1295
|
+
description: "Closes the collector's circuit breaker and restarts the resource monitor.",
|
|
1296
|
+
// 起进程的动作不是只读:熔断之后要不要再试一次,得由拿着 operate 凭据的人决定。
|
|
1297
|
+
scope: "operate",
|
|
1298
|
+
payloadSchema: EMPTY_PAYLOAD_SCHEMA,
|
|
1299
|
+
handle: () => runtime.retry()
|
|
1300
|
+
});
|
|
1301
|
+
ctx.interval({
|
|
1302
|
+
name: "power",
|
|
1303
|
+
everyMs: POWER_POLL_INTERVAL_MS,
|
|
1304
|
+
handle: () => runtime.pollPower()
|
|
1305
|
+
});
|
|
1306
|
+
ctx.onDispose(() => runtime.stop());
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
export {
|
|
1310
|
+
index_default as default
|
|
1311
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ycodium-ai/plugin-resource-telemetry",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Measures what this machine's processes cost in CPU, memory and disk.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telemetry",
|
|
7
|
+
"ycodium",
|
|
8
|
+
"ycodium-plugin"
|
|
9
|
+
],
|
|
10
|
+
"license": "UNLICENSED",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@effect/vitest": "4.0.0-beta.103",
|
|
20
|
+
"effect": "4.0.0-beta.103",
|
|
21
|
+
"esbuild": "0.28.1",
|
|
22
|
+
"vite-plus": "0.2.2",
|
|
23
|
+
"@ycodium-ai/plugin-api": "0.1.1"
|
|
24
|
+
},
|
|
25
|
+
"ycodium": {
|
|
26
|
+
"plugin": "./dist/index.js",
|
|
27
|
+
"displayName": "Resource monitoring",
|
|
28
|
+
"description": "Measures what this machine's processes cost in CPU, memory and disk.",
|
|
29
|
+
"icon": "activity",
|
|
30
|
+
"permissions": [
|
|
31
|
+
{
|
|
32
|
+
"kind": "process.spawn",
|
|
33
|
+
"programs": [
|
|
34
|
+
"config:resourceMonitorPath"
|
|
35
|
+
],
|
|
36
|
+
"maxConcurrent": 1
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node build.mjs",
|
|
42
|
+
"typecheck": "tsgo --noEmit",
|
|
43
|
+
"test": "vp test run"
|
|
44
|
+
}
|
|
45
|
+
}
|