@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.
Files changed (3) hide show
  1. package/README.md +53 -0
  2. package/dist/index.js +1311 -0
  3. 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
+ }