agent-yes 1.288.0 → 1.289.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/default.config.yaml +106 -0
- package/dist/{SUPPORTED_CLIS-BHowMgMY.js → SUPPORTED_CLIS-BlgVWZrM.js} +2 -2
- package/dist/{SUPPORTED_CLIS-ChhJyq4h.js → SUPPORTED_CLIS-C65PuIs6.js} +3 -3
- package/dist/{agentShare-T3RskvDT.js → agentShare-Cm15-dfZ.js} +3 -3
- package/dist/{callback-CuohbCJA.js → callback-Bbplrsbv.js} +3 -3
- package/dist/{callback-DeaETuHt.js → callback-Dwnkeqpz.js} +3 -3
- package/dist/cli.js +11 -11
- package/dist/cmdPs-BYBZQ0Vc.js +331 -0
- package/dist/{cwdConflictWarn-DIPD6HH5.js → cwdConflictWarn-BgXN56Jc.js} +1 -1
- package/dist/{cwdPassthroughHint-C0tfZlgZ.js → cwdPassthroughHint-CDEGVteh.js} +1 -1
- package/dist/{expose-Ds9aUFsq.js → expose-BlddYYx9.js} +1 -1
- package/dist/{forkNested-Dt5XP8ZH.js → forkNested-CJ7lQQCe.js} +1 -1
- package/dist/{hist-DSYaJEd3.js → hist-DZxq7NMA.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/{initMsg-CeF4QNOM.js → initMsg-DoltWTZj.js} +1 -1
- package/dist/{initMsg-DejbYYjX.js → initMsg-axyQAPLK.js} +1 -1
- package/dist/{nodeRuntime-_zeWDISM.js → nodeRuntime-CipjIwfO.js} +1 -1
- package/dist/{notifyDaemon-BORqPeAl.js → notifyDaemon-Q3FF-WEZ.js} +47 -39
- package/dist/{openBrowser-BNP8xByu.js → openBrowser-THOzOGNm.js} +1 -1
- package/dist/{parentLink-Bv8ihRcu.js → parentLink-bpDGuQi6.js} +1 -1
- package/dist/{parentLink-HWunrH1s.js → parentLink-fdiuB13i.js} +1 -1
- package/dist/{parentPingLoop-COePRYr9.js → parentPingLoop-Dc7co5D7.js} +4 -4
- package/dist/{cmdPs-Bm4JG7hi.js → procStats-CyD8h5nx.js} +2 -329
- package/dist/{remotes-b6reVXzo.js → remotes-BjsglpjL.js} +2 -2
- package/dist/{remotes-BlHTR9ow.js → remotes-CAqtL_Ib.js} +1 -1
- package/dist/{rustBinary-BmXzqj5z.js → rustBinary-B57oRftk.js} +2 -2
- package/dist/{rustBinary-D12k7iOD.js → rustBinary-Dq0AytZ7.js} +2 -2
- package/dist/{schedule-DLoUmyJh.js → schedule-pim0Izhs.js} +6 -6
- package/dist/{serve-DaDlOtss.js → serve-CMsvjqPU.js} +174 -27
- package/dist/{setup-Cqy8nRrq.js → setup-BmxYINCx.js} +4 -4
- package/dist/{spawnGate-B2hA_2NQ.js → spawnGate-B26UnzqP.js} +2 -2
- package/dist/{spawnGate-FLwTAPW6.js → spawnGate-C8MSRt7X.js} +1 -1
- package/dist/{subcommands-DKTa3E6O.js → subcommands-CJBV06rg.js} +2 -2
- package/dist/{subcommands-BfiOyfz7.js → subcommands-CWDqhKQe.js} +21 -21
- package/dist/{systemPathLink-Dl2gJqeg.js → systemPathLink-BVdk48Vz.js} +1 -1
- package/dist/{terminal-C0tnGnrZ.js → terminal-CcmBzB3G.js} +2 -2
- package/dist/{todoCli-MWrejmdM.js → todoCli-azqbjBtz.js} +1 -1
- package/dist/{tray-BhPVpP_r.js → tray-CIt9mCKZ.js} +1 -1
- package/dist/trayApp-BovAZs7m.js +5 -0
- package/dist/{trayApp-DvL9qW6V.js → trayApp-DuZBkGl_.js} +2 -2
- package/dist/{ts-DJLp2_bv.js → ts-CSp4mJ7Z.js} +23 -9
- package/dist/{versionChecker-CkO7wYyV.js → versionChecker-CWCN4vwm.js} +2 -2
- package/dist/{webrtcRemote-H9Hok0_R.js → webrtcRemote-Bevc5ogS.js} +1 -1
- package/dist/{widget-CJqpScCG.js → widget-D2cCC8nz.js} +3 -3
- package/dist/{workspaceConfig-D6QndOCz.js → workspaceConfig-DTvPtr5j.js} +1 -1
- package/dist/{ws-DGENGNrr.js → ws-B5Qb2c-1.js} +3 -3
- package/dist/{ws-DzCKvzWD.js → ws-CyO9r4jx.js} +3 -3
- package/package.json +1 -1
- package/ts/agentNice.spec.ts +42 -2
- package/ts/index.ts +5 -4
- package/ts/notifyDaemon.spec.ts +16 -0
- package/ts/notifyDaemon.ts +22 -0
- package/ts/serve.ts +14 -0
- package/ts/serveSampler.spec.ts +390 -0
- package/ts/serveSampler.ts +218 -0
- package/dist/trayApp-BrAMlCY7.js +0 -5
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// The sampler's only outside dependency is one `sampleTrees` call per sweep, so
|
|
4
|
+
// mocking it here lets every branch below run against synthetic process trees —
|
|
5
|
+
// no /proc, no live fleet, and identical behaviour on macOS and Linux CI.
|
|
6
|
+
const sampleTrees = vi.fn();
|
|
7
|
+
vi.mock("./procStats.ts", () => ({
|
|
8
|
+
sampleTrees: (...args: unknown[]) => sampleTrees(...args),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
getBucketSecs,
|
|
13
|
+
getSnapshot,
|
|
14
|
+
resField,
|
|
15
|
+
resourcesField,
|
|
16
|
+
startSampler,
|
|
17
|
+
stopSampler,
|
|
18
|
+
sweep,
|
|
19
|
+
windowLen,
|
|
20
|
+
} from "./serveSampler.ts";
|
|
21
|
+
|
|
22
|
+
/** A TreeStats literal — the shape sampleTrees hands back per root pid. */
|
|
23
|
+
function tree(pid: number, rss: number, cpuPercent: number, procs: number) {
|
|
24
|
+
return { pid, rss, cpuPercent, procs };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A whole sampleTrees result: the given trees plus an unattributed rollup. */
|
|
28
|
+
function result(
|
|
29
|
+
trees: Array<ReturnType<typeof tree>>,
|
|
30
|
+
unattributed = tree(0, 0, 0, 0),
|
|
31
|
+
) {
|
|
32
|
+
return {
|
|
33
|
+
trees: new Map(trees.map((t) => [t.pid, t])),
|
|
34
|
+
unattributed,
|
|
35
|
+
claimed: new Map(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The sampler keeps its window in module-level state (one sampler per daemon),
|
|
40
|
+
// so each test starts from a known-empty snapshot rather than inheriting the
|
|
41
|
+
// previous test's buckets.
|
|
42
|
+
function resetState() {
|
|
43
|
+
stopSampler();
|
|
44
|
+
const snap = getSnapshot();
|
|
45
|
+
snap.agents.clear();
|
|
46
|
+
snap.unattributed = { cpu_seconds: 0, rss: 0, procs: 0 };
|
|
47
|
+
snap.bucketSecs = 0;
|
|
48
|
+
// getBucketSecs() reads the same private width `sweep` sets; drive it back to
|
|
49
|
+
// the unchosen state through the public path (an empty sweep re-primes it).
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
sampleTrees.mockReset();
|
|
54
|
+
resetState();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
stopSampler();
|
|
59
|
+
vi.useRealTimers();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("windowLen", () => {
|
|
63
|
+
it("tracks the adaptive bucket width", () => {
|
|
64
|
+
expect(windowLen(60)).toBe(60); // fast: 1m buckets, 1h window
|
|
65
|
+
expect(windowLen(300)).toBe(12); // slow: 5m buckets, 1h window
|
|
66
|
+
expect(windowLen(1)).toBe(60); // anything ≤60 counts as fast
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("sweep", () => {
|
|
71
|
+
it("with no live roots still settles a bucket width without sampling", async () => {
|
|
72
|
+
const width = await sweep([]);
|
|
73
|
+
expect(width).toBeGreaterThan(0);
|
|
74
|
+
expect([60, 300]).toContain(width);
|
|
75
|
+
// No roots means there is nothing to snapshot — the expensive process walk
|
|
76
|
+
// must be skipped entirely, not called with an empty list.
|
|
77
|
+
expect(sampleTrees).not.toHaveBeenCalled();
|
|
78
|
+
expect(getSnapshot().bucketSecs).toBe(width);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps the already-chosen width when a later sweep finds no roots", async () => {
|
|
82
|
+
sampleTrees.mockResolvedValueOnce(result([tree(100, 1, 0, 1)]));
|
|
83
|
+
const chosen = await sweep([100]);
|
|
84
|
+
|
|
85
|
+
// The whole fleet exiting must not re-open the width decision: the console's
|
|
86
|
+
// axis label would jump under it mid-session.
|
|
87
|
+
expect(await sweep([])).toBe(chosen);
|
|
88
|
+
expect(getSnapshot().bucketSecs).toBe(chosen);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("records one bucket per sampled agent and the unattributed rollup", async () => {
|
|
92
|
+
sampleTrees.mockResolvedValue(
|
|
93
|
+
result([tree(100, 2048, 50, 3), tree(200, 4096, 10, 1)], tree(0, 8192, 20, 9)),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await sweep([100, 200]);
|
|
97
|
+
|
|
98
|
+
const snap = getSnapshot();
|
|
99
|
+
expect([...snap.agents.keys()].sort()).toEqual([100, 200]);
|
|
100
|
+
expect(snap.agents.get(100)).toHaveLength(1);
|
|
101
|
+
expect(snap.agents.get(100)![0]![1].rss).toBe(2048);
|
|
102
|
+
expect(snap.agents.get(100)![0]![1].procs).toBe(3);
|
|
103
|
+
expect(snap.unattributed.rss).toBe(8192);
|
|
104
|
+
expect(snap.unattributed.procs).toBe(9);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("walks roots deepest-first so a nested agent claims its subtree before its parent", async () => {
|
|
108
|
+
sampleTrees.mockResolvedValue(result([tree(100, 1, 0, 1)]));
|
|
109
|
+
|
|
110
|
+
await sweep([100, 200, 300]);
|
|
111
|
+
|
|
112
|
+
// `ay ps` semantics: sampleTrees attributes first-come, so the caller must
|
|
113
|
+
// hand it the reversed list. Getting this backwards silently credits a
|
|
114
|
+
// child's memory to its parent.
|
|
115
|
+
expect(sampleTrees.mock.calls[0]![0]).toEqual([300, 200, 100]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("converts cpuPercent into cpu-seconds of one core over the pass", async () => {
|
|
119
|
+
sampleTrees.mockResolvedValue(result([tree(100, 0, 100, 1)], tree(0, 0, 50, 1)));
|
|
120
|
+
|
|
121
|
+
await sweep([100]);
|
|
122
|
+
|
|
123
|
+
// 100% of one core is at most the pass duration in seconds, and the floor on
|
|
124
|
+
// elapsed (1ms) keeps a sub-millisecond pass from producing a huge number.
|
|
125
|
+
const cpu = getSnapshot().agents.get(100)![0]![1].cpu_seconds;
|
|
126
|
+
expect(cpu).toBeGreaterThan(0);
|
|
127
|
+
expect(cpu).toBeLessThan(1);
|
|
128
|
+
// The unattributed rollup goes through the same conversion, at half the rate.
|
|
129
|
+
expect(getSnapshot().unattributed.cpu_seconds).toBeCloseTo(cpu / 2, 10);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps the last good snapshot when a sweep throws", async () => {
|
|
133
|
+
sampleTrees.mockResolvedValueOnce(result([tree(100, 4096, 0, 2)]));
|
|
134
|
+
await sweep([100]);
|
|
135
|
+
const width = getBucketSecs();
|
|
136
|
+
|
|
137
|
+
sampleTrees.mockRejectedValueOnce(new Error("/proc vanished"));
|
|
138
|
+
const after = await sweep([100]);
|
|
139
|
+
|
|
140
|
+
// Best-effort: a failed snapshot degrades to "no new data", never to a
|
|
141
|
+
// cleared window (which the console would render as the agent dying).
|
|
142
|
+
expect(after).toBe(width);
|
|
143
|
+
expect(getSnapshot().agents.get(100)).toHaveLength(1);
|
|
144
|
+
expect(getSnapshot().agents.get(100)![0]![1].rss).toBe(4096);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("falls back to the fast width when the very first sweep throws", async () => {
|
|
148
|
+
sampleTrees.mockRejectedValueOnce(new Error("boom"));
|
|
149
|
+
// Width was never chosen (state.bucketSecs === 0), so the catch path has no
|
|
150
|
+
// previous value to keep and must still return a usable cadence.
|
|
151
|
+
expect(await sweep([100])).toBe(60);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("picks the slow bucket width when the first pass is slow", async () => {
|
|
155
|
+
// The width is chosen ONCE, on the first timed pass, and then sticks for the
|
|
156
|
+
// life of the daemon — so this needs a module instance no earlier test has
|
|
157
|
+
// already driven to the fast width.
|
|
158
|
+
vi.resetModules();
|
|
159
|
+
const fresh = await import("./serveSampler.ts");
|
|
160
|
+
|
|
161
|
+
// sweep() times its own pass with Date.now(), so stub the clock to report a
|
|
162
|
+
// pass over the 1s threshold rather than actually sleeping for one.
|
|
163
|
+
let now = Date.now();
|
|
164
|
+
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
165
|
+
sampleTrees.mockImplementationOnce(async () => {
|
|
166
|
+
now += 1500; // pass >= 1s → 5m buckets
|
|
167
|
+
return result([tree(100, 1, 0, 1)]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
expect(await fresh.sweep([100])).toBe(300);
|
|
172
|
+
expect(fresh.getBucketSecs()).toBe(300);
|
|
173
|
+
// 5m buckets ⇒ a 12-slot window, so an hour of history still fits.
|
|
174
|
+
expect(fresh.windowLen(300)).toBe(12);
|
|
175
|
+
} finally {
|
|
176
|
+
nowSpy.mockRestore();
|
|
177
|
+
fresh.stopSampler();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("trims the rolling window to the bucket count and keeps the newest samples", async () => {
|
|
182
|
+
// Force the fast width (60 buckets) with a fast first pass, then overfill.
|
|
183
|
+
for (let i = 0; i < 63; i++) {
|
|
184
|
+
sampleTrees.mockResolvedValueOnce(result([tree(100, i, 0, 1)]));
|
|
185
|
+
await sweep([100]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const series = getSnapshot().agents.get(100)!;
|
|
189
|
+
expect(series).toHaveLength(windowLen(60));
|
|
190
|
+
// Oldest-first ordering, with the three oldest dropped — the console draws
|
|
191
|
+
// left-to-right, so a reversed window would render the heatmap backwards.
|
|
192
|
+
expect(series[0]![1].rss).toBe(3);
|
|
193
|
+
expect(series[series.length - 1]![1].rss).toBe(62);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("resField", () => {
|
|
198
|
+
it("is null for an agent the sampler has not recorded", async () => {
|
|
199
|
+
sampleTrees.mockResolvedValue(result([tree(100, 1, 0, 1)]));
|
|
200
|
+
await sweep([100]);
|
|
201
|
+
|
|
202
|
+
expect(resField(999)).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("transposes the window into the parallel arrays /api/ls expects", async () => {
|
|
206
|
+
sampleTrees.mockResolvedValueOnce(result([tree(100, 10, 0, 1)]));
|
|
207
|
+
await sweep([100]);
|
|
208
|
+
sampleTrees.mockResolvedValueOnce(result([tree(100, 20, 0, 2)]));
|
|
209
|
+
await sweep([100]);
|
|
210
|
+
|
|
211
|
+
const field = resField(100)!;
|
|
212
|
+
// Byte-compatible with the Rust /api/ls shape: one array per metric, all the
|
|
213
|
+
// same length, sharing the `t` index.
|
|
214
|
+
expect(field.bucket_secs).toBe(getSnapshot().bucketSecs);
|
|
215
|
+
expect(field.rss).toEqual([10, 20]);
|
|
216
|
+
expect(field.procs).toEqual([1, 2]);
|
|
217
|
+
expect(field.t).toHaveLength(2);
|
|
218
|
+
expect(field.cpu_seconds).toHaveLength(2);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("resourcesField", () => {
|
|
223
|
+
it("reports zeroes before the first sweep", () => {
|
|
224
|
+
const f = resourcesField();
|
|
225
|
+
expect(f.managed_rss).toBe(0);
|
|
226
|
+
expect(f.managed_procs).toBe(0);
|
|
227
|
+
expect(f.unattributed_rss).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("skips an agent whose window is empty rather than counting a missing bucket", async () => {
|
|
231
|
+
sampleTrees.mockResolvedValueOnce(result([tree(100, 1000, 0, 2)]));
|
|
232
|
+
await sweep([100]);
|
|
233
|
+
// An agent registered with no samples yet (its series trimmed to nothing) must
|
|
234
|
+
// contribute zero, not throw on the absent last bucket.
|
|
235
|
+
getSnapshot().agents.set(777, []);
|
|
236
|
+
|
|
237
|
+
const f = resourcesField();
|
|
238
|
+
expect(f.managed_rss).toBe(1000);
|
|
239
|
+
expect(f.managed_procs).toBe(2);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("sums only the LAST bucket of each agent against the unattributed rollup", async () => {
|
|
243
|
+
sampleTrees.mockResolvedValueOnce(
|
|
244
|
+
result([tree(100, 1000, 0, 1), tree(200, 2000, 0, 2)], tree(0, 500, 0, 5)),
|
|
245
|
+
);
|
|
246
|
+
await sweep([100, 200]);
|
|
247
|
+
// A second sweep must replace, not accumulate: summing every bucket in the
|
|
248
|
+
// window would report an hour of history as current memory use.
|
|
249
|
+
sampleTrees.mockResolvedValueOnce(
|
|
250
|
+
result([tree(100, 1500, 0, 3), tree(200, 2500, 0, 4)], tree(0, 700, 0, 6)),
|
|
251
|
+
);
|
|
252
|
+
await sweep([100, 200]);
|
|
253
|
+
|
|
254
|
+
const f = resourcesField();
|
|
255
|
+
expect(f.managed_rss).toBe(4000); // 1500 + 2500, not 1000+2000+1500+2500
|
|
256
|
+
expect(f.managed_procs).toBe(7); // 3 + 4
|
|
257
|
+
expect(f.unattributed_rss).toBe(700);
|
|
258
|
+
expect(f.unattributed_procs).toBe(6);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("startSampler", () => {
|
|
263
|
+
it("primes a sweep immediately and re-arms on the chosen width", async () => {
|
|
264
|
+
vi.useFakeTimers();
|
|
265
|
+
sampleTrees.mockResolvedValue(result([tree(100, 4096, 0, 1)]));
|
|
266
|
+
|
|
267
|
+
startSampler(async () => [100]);
|
|
268
|
+
await vi.advanceTimersByTimeAsync(0); // let the priming tick settle
|
|
269
|
+
|
|
270
|
+
// The first bucket must land before the console's first list poll, so the
|
|
271
|
+
// heatmap is never empty on a freshly started daemon.
|
|
272
|
+
expect(getSnapshot().agents.get(100)).toHaveLength(1);
|
|
273
|
+
|
|
274
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
275
|
+
expect(getSnapshot().agents.get(100)!.length).toBeGreaterThan(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("is idempotent — a second call does not start a second timer", async () => {
|
|
279
|
+
vi.useFakeTimers();
|
|
280
|
+
sampleTrees.mockResolvedValue(result([tree(100, 1, 0, 1)]));
|
|
281
|
+
|
|
282
|
+
startSampler(async () => [100]);
|
|
283
|
+
startSampler(async () => [100]);
|
|
284
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
285
|
+
|
|
286
|
+
// Two timers would double every bucket and halve the effective window.
|
|
287
|
+
expect(getSnapshot().agents.get(100)).toHaveLength(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("survives a getRoots that throws", async () => {
|
|
291
|
+
vi.useFakeTimers();
|
|
292
|
+
startSampler(async () => {
|
|
293
|
+
throw new Error("registry unreadable");
|
|
294
|
+
});
|
|
295
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
296
|
+
|
|
297
|
+
// Best-effort: the serve path must not fall over because the pid store was
|
|
298
|
+
// momentarily unreadable — the loop keeps its cadence and tries again.
|
|
299
|
+
expect(sampleTrees).not.toHaveBeenCalled();
|
|
300
|
+
// The rejection is swallowed and the loop re-arms, so draining the next tick
|
|
301
|
+
// must not surface an unhandled rejection.
|
|
302
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
303
|
+
expect(sampleTrees).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("does not re-arm when stopped during an in-flight sweep", async () => {
|
|
307
|
+
vi.useFakeTimers();
|
|
308
|
+
let release: (() => void) | undefined;
|
|
309
|
+
sampleTrees.mockImplementationOnce(
|
|
310
|
+
() =>
|
|
311
|
+
new Promise((resolve) => {
|
|
312
|
+
release = () => resolve(result([tree(100, 1, 0, 1)]));
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
startSampler(async () => [100]);
|
|
317
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
318
|
+
// Stop lands while the sweep is still awaiting: the tick must notice on
|
|
319
|
+
// resume and NOT schedule another timer, else a stopped daemon keeps walking
|
|
320
|
+
// /proc forever.
|
|
321
|
+
stopSampler();
|
|
322
|
+
release!();
|
|
323
|
+
await vi.advanceTimersByTimeAsync(600_000);
|
|
324
|
+
|
|
325
|
+
expect(sampleTrees).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("tolerates a timer handle with no unref (browser-shaped setTimeout)", async () => {
|
|
329
|
+
vi.useFakeTimers();
|
|
330
|
+
sampleTrees.mockResolvedValue(result([tree(100, 1, 0, 1)]));
|
|
331
|
+
// Node hands back a Timeout carrying .unref(); a bare numeric handle (jsdom,
|
|
332
|
+
// some bundlers) has none, and the optional call must simply skip it.
|
|
333
|
+
const raw = globalThis.setTimeout;
|
|
334
|
+
const stub = vi
|
|
335
|
+
.spyOn(globalThis, "setTimeout")
|
|
336
|
+
.mockImplementation(((fn: () => void, ms?: number) => {
|
|
337
|
+
const h = raw(fn, ms);
|
|
338
|
+
return { ...(h as object), unref: undefined } as unknown as ReturnType<typeof raw>;
|
|
339
|
+
}) as typeof raw);
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
startSampler(async () => [100]);
|
|
343
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
344
|
+
expect(getSnapshot().agents.get(100)).toHaveLength(1);
|
|
345
|
+
} finally {
|
|
346
|
+
stub.mockRestore();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("uses the fast width for its first re-arm before any sweep has timed one", async () => {
|
|
351
|
+
// A fresh instance has no chosen width yet, so the tick's own
|
|
352
|
+
// `state.bucketSecs || BUCKET_FAST_SECS` fallback is what schedules the
|
|
353
|
+
// second pass. Exercised here because the shared instance is always warm.
|
|
354
|
+
vi.resetModules();
|
|
355
|
+
const fresh = await import("./serveSampler.ts");
|
|
356
|
+
vi.useFakeTimers();
|
|
357
|
+
sampleTrees.mockResolvedValue(result([tree(100, 1, 0, 1)]));
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
fresh.startSampler(async () => [100]);
|
|
361
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
362
|
+
expect(sampleTrees).toHaveBeenCalledTimes(1);
|
|
363
|
+
|
|
364
|
+
// 60s is the fast cadence; re-arming any slower would leave the console
|
|
365
|
+
// without a second data point for five minutes.
|
|
366
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
367
|
+
expect(sampleTrees).toHaveBeenCalledTimes(2);
|
|
368
|
+
} finally {
|
|
369
|
+
fresh.stopSampler();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("stopSampler halts the loop and allows a later restart", async () => {
|
|
374
|
+
vi.useFakeTimers();
|
|
375
|
+
sampleTrees.mockResolvedValue(result([tree(100, 1, 0, 1)]));
|
|
376
|
+
|
|
377
|
+
startSampler(async () => [100]);
|
|
378
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
379
|
+
const afterPrime = getSnapshot().agents.get(100)!.length;
|
|
380
|
+
|
|
381
|
+
stopSampler();
|
|
382
|
+
await vi.advanceTimersByTimeAsync(300_000);
|
|
383
|
+
expect(getSnapshot().agents.get(100)!.length).toBe(afterPrime);
|
|
384
|
+
|
|
385
|
+
// `started` is cleared, so a restart primes again rather than no-opping.
|
|
386
|
+
startSampler(async () => [100]);
|
|
387
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
388
|
+
expect(getSnapshot().agents.get(100)!.length).toBeGreaterThan(afterPrime);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic per-agent resource sampler for the console heatmaps — the TS twin of
|
|
3
|
+
* `rs/src/serve/sampler.rs` (see there for the shared design). The Rust daemon
|
|
4
|
+
* ships this already; this port lets the TS `ay serve` daemon — which is what
|
|
5
|
+
* actually serves the browser console — expose the SAME `res` / `resources`
|
|
6
|
+
* fields, so the console renders identically whichever host backs it.
|
|
7
|
+
*
|
|
8
|
+
* Reuses `ts/procStats.ts` (the `ay ps` machinery) for one full process-tree
|
|
9
|
+
* snapshot + rollup per sweep, bucketed into a per-agent rolling window.
|
|
10
|
+
* Best-effort throughout — a failed snapshot, an agent exited mid-sweep, or a
|
|
11
|
+
* non-/proc platform all degrade to "no data" (transparent) rather than breaking
|
|
12
|
+
* the serve path.
|
|
13
|
+
*
|
|
14
|
+
* Adaptive granularity (same rule as Rust):
|
|
15
|
+
* pass time < 1s → bucket = 60s, window = 60 buckets (1h)
|
|
16
|
+
* pass time >= 1s → bucket = 300s, window = 12 buckets (1h)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { sampleTrees, type TreeStats } from "./procStats.ts";
|
|
20
|
+
|
|
21
|
+
const BUCKET_FAST_SECS = 60;
|
|
22
|
+
const BUCKET_SLOW_SECS = 300;
|
|
23
|
+
const WINDOW_FAST = 60; // 1h @ 1m buckets
|
|
24
|
+
const WINDOW_SLOW = 12; // 1h @ 5m buckets
|
|
25
|
+
const PASS_THRESHOLD_MS = 1000;
|
|
26
|
+
// CPU is a live delta; a short window keeps each sweep's sample "instantaneous"
|
|
27
|
+
// so we're not averaging a whole minute's CPU into one number.
|
|
28
|
+
const SWEEP_WINDOW_MS = 120;
|
|
29
|
+
|
|
30
|
+
/** One bucketed per-agent sample (one point on the heatmap). */
|
|
31
|
+
export interface Bucket {
|
|
32
|
+
/** CPU seconds of ONE core consumed over the sweep window (≈ instantaneous). */
|
|
33
|
+
cpu_seconds: number;
|
|
34
|
+
/** RSS (bytes) snapshot at bucket close. */
|
|
35
|
+
rss: number;
|
|
36
|
+
/** Live process count at bucket close. */
|
|
37
|
+
procs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Snapshot handed to /api/ls (`res`) and /api/host (`resources`). */
|
|
41
|
+
export interface ResSnapshot {
|
|
42
|
+
/** wrapper pid → resource window (oldest first), each (unix_ms, bucket). */
|
|
43
|
+
agents: Map<number, Array<[number, Bucket]>>;
|
|
44
|
+
/** The unattributed (non-agent-yes) rollup at the last sweep. */
|
|
45
|
+
unattributed: Bucket;
|
|
46
|
+
/** Bucket width actually in use (60 or 300), for the console's axis label. */
|
|
47
|
+
bucketSecs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface State {
|
|
51
|
+
snap: ResSnapshot;
|
|
52
|
+
bucketSecs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function windowLen(bucketSecs: number): number {
|
|
56
|
+
return bucketSecs <= BUCKET_FAST_SECS ? WINDOW_FAST : WINDOW_SLOW;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function bucketOf(t: TreeStats, elapsedSeconds: number): Bucket {
|
|
60
|
+
return {
|
|
61
|
+
// cpuPercent is percent of ONE core over the sample window → convert to
|
|
62
|
+
// seconds-of-one-core by ×(elapsed/100). (matches Rust's cpu_delta semantics)
|
|
63
|
+
cpu_seconds: (t.cpuPercent / 100) * elapsedSeconds,
|
|
64
|
+
rss: t.rss,
|
|
65
|
+
procs: t.procs,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const state: State = {
|
|
70
|
+
snap: { agents: new Map(), unattributed: { cpu_seconds: 0, rss: 0, procs: 0 }, bucketSecs: 0 },
|
|
71
|
+
bucketSecs: 0,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function getSnapshot(): ResSnapshot {
|
|
75
|
+
return state.snap;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The per-agent resource window as the console's `entry.res` field (matching the
|
|
80
|
+
* Rust `/api/ls` `res` shape exactly): { bucket_secs, t[], cpu_seconds[], rss[],
|
|
81
|
+
* procs[] }. Null when the sampler has not yet recorded this agent.
|
|
82
|
+
*/
|
|
83
|
+
function resField(pid: number): {
|
|
84
|
+
bucket_secs: number;
|
|
85
|
+
t: number[];
|
|
86
|
+
cpu_seconds: number[];
|
|
87
|
+
rss: number[];
|
|
88
|
+
procs: number[];
|
|
89
|
+
} | null {
|
|
90
|
+
const series = state.snap.agents.get(pid);
|
|
91
|
+
if (!series || series.length === 0) return null;
|
|
92
|
+
return {
|
|
93
|
+
bucket_secs: state.snap.bucketSecs,
|
|
94
|
+
t: series.map(([t]) => t),
|
|
95
|
+
cpu_seconds: series.map(([, b]) => b.cpu_seconds),
|
|
96
|
+
rss: series.map(([, b]) => b.rss),
|
|
97
|
+
procs: series.map(([, b]) => b.procs),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The managed/unattributed split for the room line, matching the Rust shape. */
|
|
102
|
+
function resourcesField(): {
|
|
103
|
+
bucket_secs: number;
|
|
104
|
+
managed_rss: number;
|
|
105
|
+
managed_procs: number;
|
|
106
|
+
unattributed_rss: number;
|
|
107
|
+
unattributed_procs: number;
|
|
108
|
+
} {
|
|
109
|
+
let managedRss = 0;
|
|
110
|
+
let managedProcs = 0;
|
|
111
|
+
for (const series of state.snap.agents.values()) {
|
|
112
|
+
const last = series[series.length - 1];
|
|
113
|
+
if (last) {
|
|
114
|
+
managedRss += last[1].rss;
|
|
115
|
+
managedProcs += last[1].procs;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
bucket_secs: state.snap.bucketSecs,
|
|
120
|
+
managed_rss: managedRss,
|
|
121
|
+
managed_procs: managedProcs,
|
|
122
|
+
unattributed_rss: state.snap.unattributed.rss,
|
|
123
|
+
unattributed_procs: state.snap.unattributed.procs,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getBucketSecs(): number {
|
|
128
|
+
return state.bucketSecs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run one sweep against the given live root pids and bucket the result. Exposed so
|
|
133
|
+
* `serve.ts` drives it with its own notion of the live fleet (it already has
|
|
134
|
+
* `listRecords`). Returns the chosen bucket width (after the adaptive first pass).
|
|
135
|
+
*/
|
|
136
|
+
async function sweep(roots: number[]): Promise<number> {
|
|
137
|
+
if (roots.length === 0) {
|
|
138
|
+
// Nothing to sample — still mark the width as chosen so the timer uses a
|
|
139
|
+
// sane cadence, and refresh the empty snapshot.
|
|
140
|
+
if (state.bucketSecs === 0) state.bucketSecs = BUCKET_FAST_SECS;
|
|
141
|
+
state.snap.bucketSecs = state.bucketSecs;
|
|
142
|
+
return state.bucketSecs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const start = Date.now();
|
|
146
|
+
let result: Awaited<ReturnType<typeof sampleTrees>>;
|
|
147
|
+
try {
|
|
148
|
+
// Deepest-first, like `ay ps`: a nested agent must claim its subtree before
|
|
149
|
+
// its parent walks through it (sampleTrees attributes first-come).
|
|
150
|
+
result = await sampleTrees([...roots].reverse(), { windowMs: SWEEP_WINDOW_MS });
|
|
151
|
+
} catch {
|
|
152
|
+
return state.bucketSecs || BUCKET_FAST_SECS; // keep last snapshot
|
|
153
|
+
}
|
|
154
|
+
const pass = Date.now() - start;
|
|
155
|
+
|
|
156
|
+
if (state.bucketSecs === 0) {
|
|
157
|
+
// First pass timed → pick the bucket width.
|
|
158
|
+
state.bucketSecs = pass < PASS_THRESHOLD_MS ? BUCKET_FAST_SECS : BUCKET_SLOW_SECS;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
const max = windowLen(state.bucketSecs);
|
|
163
|
+
const elapsedSeconds = Math.max(0.001, pass / 1000);
|
|
164
|
+
|
|
165
|
+
for (const [pid, stats] of result.trees) {
|
|
166
|
+
const series = state.snap.agents.get(pid) ?? [];
|
|
167
|
+
series.push([now, bucketOf(stats, elapsedSeconds)]);
|
|
168
|
+
if (series.length > max) series.splice(0, series.length - max);
|
|
169
|
+
state.snap.agents.set(pid, series);
|
|
170
|
+
}
|
|
171
|
+
const un = result.unattributed;
|
|
172
|
+
state.snap.unattributed = {
|
|
173
|
+
cpu_seconds: (un.cpuPercent / 100) * elapsedSeconds,
|
|
174
|
+
rss: un.rss,
|
|
175
|
+
procs: un.procs,
|
|
176
|
+
};
|
|
177
|
+
state.snap.bucketSecs = state.bucketSecs;
|
|
178
|
+
|
|
179
|
+
return state.bucketSecs;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let started = false;
|
|
183
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Start the background sampler. Idempotent. `serve.ts` calls this once on startup;
|
|
187
|
+
* `getRoots` returns the live wrapper pids (it already reads them via listRecords).
|
|
188
|
+
* Self-schedules with setTimeout so the adaptive bucket width takes effect on the
|
|
189
|
+
* very next tick without interval re-arm gymnastics.
|
|
190
|
+
*/
|
|
191
|
+
function startSampler(getRoots: () => Promise<number[]>): void {
|
|
192
|
+
if (started) return;
|
|
193
|
+
started = true;
|
|
194
|
+
|
|
195
|
+
const tick = async () => {
|
|
196
|
+
let width = state.bucketSecs || BUCKET_FAST_SECS;
|
|
197
|
+
try {
|
|
198
|
+
const roots = await getRoots();
|
|
199
|
+
width = (await sweep(roots)) || width;
|
|
200
|
+
} catch {
|
|
201
|
+
/* best-effort */
|
|
202
|
+
}
|
|
203
|
+
if (!started) return;
|
|
204
|
+
timer = setTimeout(tick, width * 1000);
|
|
205
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Prime immediately (first bucket lands before the first list poll), then loop.
|
|
209
|
+
void tick();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function stopSampler(): void {
|
|
213
|
+
started = false;
|
|
214
|
+
if (timer) clearTimeout(timer);
|
|
215
|
+
timer = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export { getSnapshot, getBucketSecs, resField, resourcesField, startSampler, stopSampler, sweep, windowLen };
|
package/dist/trayApp-BrAMlCY7.js
DELETED