@bridge4dev/runner 0.52.0 → 0.54.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.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * What this machine can say about its own load, read straight from `/proc`.
3
+ *
4
+ * Until this existed, an overloaded dev server was visible in exactly one
5
+ * place: `sar` on the machine itself. The product could not say «this machine
6
+ * is on its knees», so it said nothing, and the first symptom a user saw was a
7
+ * session that had gone quiet (plan `agent-sessions-host-resources.md` §5.3).
8
+ *
9
+ * Two files and nothing else. That is a security property, not an
10
+ * implementation detail: this runner reports to a server, so what it may read
11
+ * for telemetry is listed here in full — `/proc/loadavg` and `/proc/meminfo`,
12
+ * both world-readable, neither containing a path, a command line, an
13
+ * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
14
+ *
15
+ * Mirror of `packages/shared/src/schemas/host-load.ts` and the `HOST_LOAD_*`
16
+ * block of `packages/shared/src/constants/runner.ts` — the DevBridge side is
17
+ * the source of truth, exactly like `levels.ts` mirrors the level thresholds
18
+ * and `protocol.ts` mirrors the API's wire types. Copied rather than imported
19
+ * on purpose: this package is published to npm on its own and installed by
20
+ * users who have no DevBridge workspace, so a `@devbridge/shared` import would
21
+ * make the published tarball unresolvable (see `recipe-schema.ts`).
22
+ *
23
+ * CHECKED: `host-load.test.ts` reads the shared file and compares every number
24
+ * below against it. A threshold edited on one side only would make the runner
25
+ * send frames the API throws away — or go silent while a card waits for one.
26
+ */
27
+ /**
28
+ * How often the runner LOOKS at `/proc`. Not how often it sends.
29
+ *
30
+ * Fine enough that a card is never more than half a minute stale, coarse
31
+ * enough that reading two small files costs nothing measurable.
32
+ */
33
+ export declare const HOST_LOAD_SAMPLE_INTERVAL_MS = 30000;
34
+ /**
35
+ * The heartbeat: an unchanged machine still reports this often.
36
+ *
37
+ * Without it a quiet runner would go silent and the API's key would expire,
38
+ * turning «nothing is happening» into «we have no idea» on the card. With it,
39
+ * silence really does mean the runner stopped talking.
40
+ *
41
+ * Bound to the API's `HOST_LOAD_TTL_MS` (120 s), and the order is the design:
42
+ * a heartbeat LONGER than the TTL means a healthy quiet machine shows «no
43
+ * data» for the gap between them. The first draft had 5 minutes against a
44
+ * 2-minute TTL — three silent minutes out of every five.
45
+ */
46
+ export declare const HOST_LOAD_HEARTBEAT_MS = 60000;
47
+ /**
48
+ * How much load1 must move before an otherwise unchanged sample earns a frame.
49
+ *
50
+ * Same principle as `publishSlots`: a tick that would repeat what the server
51
+ * already knows is not sent at all. 0.5 is below the resolution at which a
52
+ * person reads the number and well above the noise of an idle machine.
53
+ */
54
+ export declare const HOST_LOAD_MIN_DELTA_LOAD1 = 0.5;
55
+ /** The same idea for memory, as a fraction — see `hostLoadChangedEnough`. */
56
+ export declare const HOST_LOAD_MIN_DELTA_MEM_RATIO = 0.05;
57
+ /**
58
+ * One measurement of this machine. Only the moving parts.
59
+ *
60
+ * `machine: {cpuCount, memTotalBytes, memAvailableBytes}` already travels in
61
+ * `hello` (`index.ts`), so nothing static is repeated here. `cpuCount` is the
62
+ * exception and it earns its place: load1 is meaningless without it, and a
63
+ * consumer that had to join two sources to answer «is 12.4 a lot?» would
64
+ * eventually paint one machine's load against another's core count.
65
+ */
66
+ export interface HostLoadFrame {
67
+ /** `/proc/loadavg`, the three windows as the kernel reports them. */
68
+ load1: number;
69
+ load5: number;
70
+ load15: number;
71
+ /** Processors, so load1 can be read as a ratio without a second lookup. */
72
+ cpuCount: number;
73
+ /**
74
+ * `MemAvailable`, not `MemFree` — the kernel's own estimate of what a new
75
+ * allocation could actually get. `MemFree` on a healthy Linux box is near
76
+ * zero by design (page cache), and a card drawn from it would cry wolf on
77
+ * every machine, every minute.
78
+ */
79
+ memAvailableBytes: number;
80
+ /** Zero on a machine with no swap; consumers must not divide blindly. */
81
+ swapTotalBytes: number;
82
+ swapFreeBytes: number;
83
+ /**
84
+ * When THIS MACHINE measured it, ISO.
85
+ *
86
+ * Load-bearing, same as in `agent_versions`: frames without a session id take
87
+ * the gateway's unordered fast path, so two of these can be handled out of
88
+ * order. The card dates the number by this field, never by arrival.
89
+ */
90
+ at: string;
91
+ }
92
+ /** Test seams. Production calls `readHostLoad()` with nothing. */
93
+ export interface HostLoadSources {
94
+ /**
95
+ * Where `/proc` is mounted. A seam so the parser can be driven by fixtures:
96
+ * a test that had to arrange the real kernel into a state would not be
97
+ * written, and the states worth covering (no swap, no `MemAvailable`, no
98
+ * file at all) cannot be arranged at all.
99
+ */
100
+ procDir?: string;
101
+ /** Processors. Seam for the same reason — a test cannot change its own. */
102
+ cpuCount?: number;
103
+ /** The clock behind `at`. */
104
+ now?: () => Date;
105
+ }
106
+ /**
107
+ * Measure this machine, or say honestly that we cannot.
108
+ *
109
+ * `null` is a first-class answer and every caller must treat it as «send
110
+ * nothing». There is deliberately no `process.platform` check in front of it:
111
+ * the absence of the files IS the check, and it is the wider one — it also
112
+ * covers a Linux container with `/proc` masked or mounted `hidepid`, which a
113
+ * platform test would sail straight past into an exception. The runner is
114
+ * Linux-only by its installer, but «only runs on Linux» must never mean
115
+ * «crashes anywhere else»: this is called from a timer, and an exception here
116
+ * would take the whole session supervisor with it.
117
+ */
118
+ export declare function readHostLoad(sources?: HostLoadSources): HostLoadFrame | null;
119
+ /**
120
+ * Is the heartbeat due, given how long ago the last frame actually went out?
121
+ *
122
+ * The second half of the answer is the whole reason this is a function: a
123
+ * backwards clock step makes «how long ago» NEGATIVE, and a machine whose load
124
+ * never moves would then say nothing for the entire offset — an hour of silence
125
+ * on a machine that may be on fire. A clock correction is due, not early.
126
+ *
127
+ * The API makes the same allowance for the same step, and has to: it drops
128
+ * frames older than the one it holds unless the step is larger than a
129
+ * measurement's lifetime (`HOST_LOAD_TTL_MS`, `runner-gateway.ts`). Half of this
130
+ * fix on one side of the socket is no fix at all.
131
+ */
132
+ export declare function hostLoadHeartbeatDue(sinceLastSentMs: number, heartbeatMs: number): boolean;
133
+ /**
134
+ * Is this sample worth a frame at all? The «a quiet runner says nothing» rule.
135
+ *
136
+ * Same shape as `publishSlots`: what the server already knows is not repeated.
137
+ * The alternative is 2 880 identical frames a day per machine, each one waking
138
+ * the gateway, a Redis write and every open dashboard socket.
139
+ *
140
+ * The memory threshold is 5 % of the machine's TOTAL memory, not 5 % of what
141
+ * is currently available. Two reasons, and both were the deciding one:
142
+ *
143
+ * - a fraction of `memAvailable` shrinks as the machine fills, so the frames
144
+ * would get chattiest exactly when the machine is least able to afford it —
145
+ * 5 % of the last 200 MB is a 10 MB trigger;
146
+ * - `memTotal` is fixed per machine, so «the card moves when a GB moves» reads
147
+ * the same on every server instead of meaning something different on each.
148
+ *
149
+ * Swap counts too, though the plan names only load and memory. It is one of the
150
+ * three verdicts behind «overloaded», and it is the only one with no other
151
+ * trigger: a machine that starts paging with its load and its `MemAvailable`
152
+ * both flat would otherwise wait up to five minutes for the heartbeat to say
153
+ * so, and paging is the state whose wall-clock cost is unbounded.
154
+ */
155
+ export declare function hostLoadChangedEnough(previous: HostLoadFrame | null, next: HostLoadFrame, memTotalBytes?: number): boolean;
156
+ //# sourceMappingURL=host-load.d.ts.map
@@ -0,0 +1,223 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /**
5
+ * What this machine can say about its own load, read straight from `/proc`.
6
+ *
7
+ * Until this existed, an overloaded dev server was visible in exactly one
8
+ * place: `sar` on the machine itself. The product could not say «this machine
9
+ * is on its knees», so it said nothing, and the first symptom a user saw was a
10
+ * session that had gone quiet (plan `agent-sessions-host-resources.md` §5.3).
11
+ *
12
+ * Two files and nothing else. That is a security property, not an
13
+ * implementation detail: this runner reports to a server, so what it may read
14
+ * for telemetry is listed here in full — `/proc/loadavg` and `/proc/meminfo`,
15
+ * both world-readable, neither containing a path, a command line, an
16
+ * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
17
+ *
18
+ * Mirror of `packages/shared/src/schemas/host-load.ts` and the `HOST_LOAD_*`
19
+ * block of `packages/shared/src/constants/runner.ts` — the DevBridge side is
20
+ * the source of truth, exactly like `levels.ts` mirrors the level thresholds
21
+ * and `protocol.ts` mirrors the API's wire types. Copied rather than imported
22
+ * on purpose: this package is published to npm on its own and installed by
23
+ * users who have no DevBridge workspace, so a `@devbridge/shared` import would
24
+ * make the published tarball unresolvable (see `recipe-schema.ts`).
25
+ *
26
+ * CHECKED: `host-load.test.ts` reads the shared file and compares every number
27
+ * below against it. A threshold edited on one side only would make the runner
28
+ * send frames the API throws away — or go silent while a card waits for one.
29
+ */
30
+ /**
31
+ * How often the runner LOOKS at `/proc`. Not how often it sends.
32
+ *
33
+ * Fine enough that a card is never more than half a minute stale, coarse
34
+ * enough that reading two small files costs nothing measurable.
35
+ */
36
+ export const HOST_LOAD_SAMPLE_INTERVAL_MS = 30_000;
37
+ /**
38
+ * The heartbeat: an unchanged machine still reports this often.
39
+ *
40
+ * Without it a quiet runner would go silent and the API's key would expire,
41
+ * turning «nothing is happening» into «we have no idea» on the card. With it,
42
+ * silence really does mean the runner stopped talking.
43
+ *
44
+ * Bound to the API's `HOST_LOAD_TTL_MS` (120 s), and the order is the design:
45
+ * a heartbeat LONGER than the TTL means a healthy quiet machine shows «no
46
+ * data» for the gap between them. The first draft had 5 minutes against a
47
+ * 2-minute TTL — three silent minutes out of every five.
48
+ */
49
+ export const HOST_LOAD_HEARTBEAT_MS = 60_000;
50
+ /**
51
+ * How much load1 must move before an otherwise unchanged sample earns a frame.
52
+ *
53
+ * Same principle as `publishSlots`: a tick that would repeat what the server
54
+ * already knows is not sent at all. 0.5 is below the resolution at which a
55
+ * person reads the number and well above the noise of an idle machine.
56
+ */
57
+ export const HOST_LOAD_MIN_DELTA_LOAD1 = 0.5;
58
+ /** The same idea for memory, as a fraction — see `hostLoadChangedEnough`. */
59
+ export const HOST_LOAD_MIN_DELTA_MEM_RATIO = 0.05;
60
+ const PROC_DIR = '/proc';
61
+ /** kB in `/proc/meminfo` means kibibytes, and has since the field was added. */
62
+ const MEMINFO_UNIT_BYTES = 1024;
63
+ /**
64
+ * `MemTotal: 16307180 kB` → `['MemTotal', 16307180 * 1024]`.
65
+ *
66
+ * The `kB` suffix is required rather than assumed. A handful of `/proc/meminfo`
67
+ * rows carry no unit at all (`HugePages_Total`), and multiplying one of those
68
+ * by 1024 because it happened to be named like the row we wanted would produce
69
+ * a number that is wrong by three orders of magnitude and looks perfectly
70
+ * plausible on a card.
71
+ */
72
+ const MEMINFO_LINE = /^([A-Za-z0-9_()]+):\s+(\d+)\s+kB$/;
73
+ function parseMeminfo(text) {
74
+ const values = new Map();
75
+ for (const line of text.split('\n')) {
76
+ const match = MEMINFO_LINE.exec(line.trim());
77
+ if (!match)
78
+ continue;
79
+ const [, key, amount] = match;
80
+ if (!key || amount === undefined)
81
+ continue;
82
+ const parsed = Number(amount);
83
+ if (!Number.isFinite(parsed))
84
+ continue;
85
+ values.set(key, parsed * MEMINFO_UNIT_BYTES);
86
+ }
87
+ return values;
88
+ }
89
+ /** `0.42 0.53 0.60 2/1234 56789` → the three windows, or `null` if it is not that. */
90
+ function parseLoadavg(text) {
91
+ const parts = text.trim().split(/\s+/);
92
+ const windows = parts.slice(0, 3).map(Number);
93
+ if (windows.length < 3)
94
+ return null;
95
+ for (const value of windows) {
96
+ if (!Number.isFinite(value) || value < 0)
97
+ return null;
98
+ }
99
+ const [load1, load5, load15] = windows;
100
+ return { load1, load5, load15 };
101
+ }
102
+ /**
103
+ * Measure this machine, or say honestly that we cannot.
104
+ *
105
+ * `null` is a first-class answer and every caller must treat it as «send
106
+ * nothing». There is deliberately no `process.platform` check in front of it:
107
+ * the absence of the files IS the check, and it is the wider one — it also
108
+ * covers a Linux container with `/proc` masked or mounted `hidepid`, which a
109
+ * platform test would sail straight past into an exception. The runner is
110
+ * Linux-only by its installer, but «only runs on Linux» must never mean
111
+ * «crashes anywhere else»: this is called from a timer, and an exception here
112
+ * would take the whole session supervisor with it.
113
+ */
114
+ export function readHostLoad(sources = {}) {
115
+ const procDir = sources.procDir ?? PROC_DIR;
116
+ try {
117
+ const load = parseLoadavg(fs.readFileSync(path.join(procDir, 'loadavg'), 'utf8'));
118
+ if (!load)
119
+ return null;
120
+ const meminfo = parseMeminfo(fs.readFileSync(path.join(procDir, 'meminfo'), 'utf8'));
121
+ const memAvailableBytes = meminfo.get('MemAvailable');
122
+ // No `MemAvailable` means a kernel older than 3.14 (2014). Reporting
123
+ // `MemFree` in its place would be worse than reporting nothing: on a
124
+ // healthy machine it is near zero by design, so every such card would
125
+ // permanently read «out of memory».
126
+ if (memAvailableBytes === undefined)
127
+ return null;
128
+ // Swap is reported only when BOTH halves are there. A kernel built without
129
+ // swap support has neither, and 0/0 is the truthful answer for it — but
130
+ // one half without the other is a shape we do not understand, and guessing
131
+ // `free = 0` for a real swap area would paint that machine as permanently
132
+ // paging, which is the one verdict this frame exists to make trustworthy.
133
+ const swapTotalRaw = meminfo.get('SwapTotal');
134
+ const swapFreeRaw = meminfo.get('SwapFree');
135
+ const swapKnown = swapTotalRaw !== undefined && swapFreeRaw !== undefined;
136
+ const swapTotalBytes = swapKnown ? swapTotalRaw : 0;
137
+ const swapFreeBytes = swapKnown ? swapFreeRaw : 0;
138
+ const cpuCount = sources.cpuCount ?? os.cpus().length;
139
+ // A frame the API's schema would reject is worse than no frame, and
140
+ // `os.cpus()` has been seen returning an empty array in containers. Nor may
141
+ // it be quietly replaced by 1: load1 is read as a ratio of this number, so
142
+ // a made-up core count would paint a working eight-core machine red.
143
+ if (!Number.isInteger(cpuCount) || cpuCount < 1)
144
+ return null;
145
+ const at = (sources.now?.() ?? new Date()).toISOString();
146
+ return {
147
+ ...load,
148
+ cpuCount,
149
+ memAvailableBytes,
150
+ swapTotalBytes,
151
+ swapFreeBytes,
152
+ at,
153
+ };
154
+ }
155
+ catch {
156
+ // ENOENT on a Mac, EACCES in a locked-down container, EIO on a machine
157
+ // that is already having a very bad day. All of them mean the same thing
158
+ // to the caller, and none of them is worth a log line every 30 seconds.
159
+ return null;
160
+ }
161
+ }
162
+ /**
163
+ * Is the heartbeat due, given how long ago the last frame actually went out?
164
+ *
165
+ * The second half of the answer is the whole reason this is a function: a
166
+ * backwards clock step makes «how long ago» NEGATIVE, and a machine whose load
167
+ * never moves would then say nothing for the entire offset — an hour of silence
168
+ * on a machine that may be on fire. A clock correction is due, not early.
169
+ *
170
+ * The API makes the same allowance for the same step, and has to: it drops
171
+ * frames older than the one it holds unless the step is larger than a
172
+ * measurement's lifetime (`HOST_LOAD_TTL_MS`, `runner-gateway.ts`). Half of this
173
+ * fix on one side of the socket is no fix at all.
174
+ */
175
+ export function hostLoadHeartbeatDue(sinceLastSentMs, heartbeatMs) {
176
+ return sinceLastSentMs >= heartbeatMs || sinceLastSentMs < 0;
177
+ }
178
+ /**
179
+ * Is this sample worth a frame at all? The «a quiet runner says nothing» rule.
180
+ *
181
+ * Same shape as `publishSlots`: what the server already knows is not repeated.
182
+ * The alternative is 2 880 identical frames a day per machine, each one waking
183
+ * the gateway, a Redis write and every open dashboard socket.
184
+ *
185
+ * The memory threshold is 5 % of the machine's TOTAL memory, not 5 % of what
186
+ * is currently available. Two reasons, and both were the deciding one:
187
+ *
188
+ * - a fraction of `memAvailable` shrinks as the machine fills, so the frames
189
+ * would get chattiest exactly when the machine is least able to afford it —
190
+ * 5 % of the last 200 MB is a 10 MB trigger;
191
+ * - `memTotal` is fixed per machine, so «the card moves when a GB moves» reads
192
+ * the same on every server instead of meaning something different on each.
193
+ *
194
+ * Swap counts too, though the plan names only load and memory. It is one of the
195
+ * three verdicts behind «overloaded», and it is the only one with no other
196
+ * trigger: a machine that starts paging with its load and its `MemAvailable`
197
+ * both flat would otherwise wait up to five minutes for the heartbeat to say
198
+ * so, and paging is the state whose wall-clock cost is unbounded.
199
+ */
200
+ export function hostLoadChangedEnough(previous, next, memTotalBytes = os.totalmem()) {
201
+ // Nothing said yet — the first measurement after a (re)connect is always news.
202
+ if (!previous)
203
+ return true;
204
+ if (Math.abs(next.load1 - previous.load1) >= HOST_LOAD_MIN_DELTA_LOAD1)
205
+ return true;
206
+ // A machine that cannot say how much memory it has still gets a working
207
+ // threshold: the sample's own availability is a poor denominator but a
208
+ // finite one, and the alternative is dividing by zero forever.
209
+ const memBase = memTotalBytes > 0 ? memTotalBytes : next.memAvailableBytes;
210
+ const memDelta = Math.abs(next.memAvailableBytes - previous.memAvailableBytes);
211
+ if (memBase > 0 && memDelta >= memBase * HOST_LOAD_MIN_DELTA_MEM_RATIO)
212
+ return true;
213
+ // Swap size itself can change (`swapon`), and that is news by the same rule.
214
+ if (next.swapTotalBytes !== previous.swapTotalBytes)
215
+ return true;
216
+ if (next.swapTotalBytes > 0) {
217
+ const swapDelta = Math.abs(next.swapFreeBytes - previous.swapFreeBytes);
218
+ if (swapDelta >= next.swapTotalBytes * HOST_LOAD_MIN_DELTA_MEM_RATIO)
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ //# sourceMappingURL=host-load.js.map