tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21
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/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- package/packages/tmux-bridge/src/runner.ts +41 -2
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { MEMORY_KEYS, RESOURCE_KEYS, } from "./soak-diagnostics.js";
|
|
2
|
+
export const DEFAULT_SOAK_THRESHOLDS = {
|
|
3
|
+
maxRssGrowthMiBPerHour: 8,
|
|
4
|
+
maxFdDrift: 16,
|
|
5
|
+
maxFdGrowthPerHour: 32,
|
|
6
|
+
maxPingRttP50Ms: 50,
|
|
7
|
+
maxReceiptLatencyP50Ms: 3_000,
|
|
8
|
+
maxObserverGapWarnings: 0,
|
|
9
|
+
maxDaemonRestarts: 0,
|
|
10
|
+
maxReceiptFailures: 0,
|
|
11
|
+
maxUnexpectedDisconnects: 0,
|
|
12
|
+
minSamplesForFit: 3,
|
|
13
|
+
};
|
|
14
|
+
/** Ordinary least squares over `(x, y)` pairs; `null` with fewer than two distinct x. */
|
|
15
|
+
export function linearFit(points) {
|
|
16
|
+
const n = points.length;
|
|
17
|
+
if (n < 2)
|
|
18
|
+
return null;
|
|
19
|
+
let sumX = 0;
|
|
20
|
+
let sumY = 0;
|
|
21
|
+
for (const { x, y } of points) {
|
|
22
|
+
sumX += x;
|
|
23
|
+
sumY += y;
|
|
24
|
+
}
|
|
25
|
+
const meanX = sumX / n;
|
|
26
|
+
const meanY = sumY / n;
|
|
27
|
+
let sxx = 0;
|
|
28
|
+
let sxy = 0;
|
|
29
|
+
let syy = 0;
|
|
30
|
+
for (const { x, y } of points) {
|
|
31
|
+
const dx = x - meanX;
|
|
32
|
+
const dy = y - meanY;
|
|
33
|
+
sxx += dx * dx;
|
|
34
|
+
sxy += dx * dy;
|
|
35
|
+
syy += dy * dy;
|
|
36
|
+
}
|
|
37
|
+
if (sxx === 0)
|
|
38
|
+
return null;
|
|
39
|
+
const slope = sxy / sxx;
|
|
40
|
+
const intercept = meanY - slope * meanX;
|
|
41
|
+
const r2 = syy === 0 ? 0 : (sxy * sxy) / (sxx * syy);
|
|
42
|
+
return { slope, intercept, r2, points: n };
|
|
43
|
+
}
|
|
44
|
+
/** Nearest-rank percentile of a numeric series; `null` for an empty series. */
|
|
45
|
+
export function percentile(values, fraction) {
|
|
46
|
+
if (values.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
49
|
+
const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
|
|
50
|
+
return sorted[rank];
|
|
51
|
+
}
|
|
52
|
+
/** At most 228 buckets for probes capped at 60 seconds, plus an overflow bucket. */
|
|
53
|
+
export function percentiles(values) {
|
|
54
|
+
const buckets = {};
|
|
55
|
+
let max = null;
|
|
56
|
+
for (const value of values) {
|
|
57
|
+
if (!Number.isFinite(value) || value < 0)
|
|
58
|
+
continue;
|
|
59
|
+
max = Math.max(max ?? 0, value);
|
|
60
|
+
const index = value <= 1 ? 0 : Math.min(227, Math.ceil(Math.log(value) / Math.log(1.05)));
|
|
61
|
+
buckets[index] = (buckets[index] ?? 0) + 1;
|
|
62
|
+
}
|
|
63
|
+
return mergePercentiles([
|
|
64
|
+
{
|
|
65
|
+
count: Object.values(buckets).reduce((a, b) => a + b, 0),
|
|
66
|
+
p50: null,
|
|
67
|
+
max,
|
|
68
|
+
buckets,
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Fit a per-sample metric against elapsed time and report the slope per hour.
|
|
74
|
+
* Samples without the metric are skipped.
|
|
75
|
+
*/
|
|
76
|
+
export function growthPerHour(samples, pick) {
|
|
77
|
+
const points = [];
|
|
78
|
+
for (const sample of samples) {
|
|
79
|
+
const value = pick(sample);
|
|
80
|
+
if (value === null || !Number.isFinite(value))
|
|
81
|
+
continue;
|
|
82
|
+
points.push({ x: sample.elapsedSeconds / 3_600, y: value });
|
|
83
|
+
}
|
|
84
|
+
return linearFit(points);
|
|
85
|
+
}
|
|
86
|
+
const KIB_PER_MIB = 1024;
|
|
87
|
+
function firstValue(samples, pick) {
|
|
88
|
+
for (const sample of samples) {
|
|
89
|
+
const value = pick(sample);
|
|
90
|
+
if (value !== null)
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function lastValue(samples, pick) {
|
|
96
|
+
for (let index = samples.length - 1; index >= 0; index -= 1) {
|
|
97
|
+
const value = pick(samples[index]);
|
|
98
|
+
if (value !== null)
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function maxValue(samples, pick) {
|
|
104
|
+
let max = null;
|
|
105
|
+
for (const sample of samples) {
|
|
106
|
+
const value = pick(sample);
|
|
107
|
+
if (value === null)
|
|
108
|
+
continue;
|
|
109
|
+
if (max === null || value > max)
|
|
110
|
+
max = value;
|
|
111
|
+
}
|
|
112
|
+
return max;
|
|
113
|
+
}
|
|
114
|
+
/** Merge bounded histograms, never a median of medians. Legacy p50 is unmeasured. */
|
|
115
|
+
export function mergePercentiles(records) {
|
|
116
|
+
const buckets = {};
|
|
117
|
+
let count = 0;
|
|
118
|
+
let max = null;
|
|
119
|
+
let missing = false;
|
|
120
|
+
for (const record of records) {
|
|
121
|
+
count += record.count;
|
|
122
|
+
if (record.count && !record.buckets)
|
|
123
|
+
missing = true;
|
|
124
|
+
if (record.max !== null)
|
|
125
|
+
max = Math.max(max ?? 0, record.max);
|
|
126
|
+
for (const [key, value] of Object.entries(record.buckets ?? {}))
|
|
127
|
+
buckets[key] = (buckets[key] ?? 0) + value;
|
|
128
|
+
}
|
|
129
|
+
let cumulative = 0;
|
|
130
|
+
let p50 = null;
|
|
131
|
+
if (!missing && count) {
|
|
132
|
+
for (const key of Object.keys(buckets)
|
|
133
|
+
.map(Number)
|
|
134
|
+
.sort((a, b) => a - b)) {
|
|
135
|
+
cumulative += buckets[key];
|
|
136
|
+
if (cumulative >= Math.ceil(count / 2)) {
|
|
137
|
+
p50 = key === 227 ? max : Math.min(max ?? Infinity, 1.05 ** key);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { count, p50, max, buckets };
|
|
143
|
+
}
|
|
144
|
+
export function soakTrends(samples, warmupSeconds, trailingSeconds) {
|
|
145
|
+
const end = samples.at(-1)?.elapsedSeconds ?? 0;
|
|
146
|
+
const fit = (window) => ({
|
|
147
|
+
memoryMiBPerHour: Object.fromEntries(MEMORY_KEYS.map((key) => [
|
|
148
|
+
key,
|
|
149
|
+
growthPerHour(window, (s) => {
|
|
150
|
+
const value = s.diagnostics?.sample?.memory[key];
|
|
151
|
+
return value === undefined ? null : value / 1024 ** 2;
|
|
152
|
+
}),
|
|
153
|
+
])),
|
|
154
|
+
activeResourcesPerHour: Object.fromEntries(RESOURCE_KEYS.map((key) => [
|
|
155
|
+
key,
|
|
156
|
+
growthPerHour(window, (s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
|
|
157
|
+
])),
|
|
158
|
+
diagnosticCpuPercentPerHour: growthPerHour(window, (s) => s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null),
|
|
159
|
+
eventLoopUtilizationPerHour: growthPerHour(window, (s) => s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null),
|
|
160
|
+
rssMiBPerHour: growthPerHour(window, (s) => (s.rssKiB === null ? null : s.rssKiB / 1024)),
|
|
161
|
+
cpuPercentPerHour: growthPerHour(window, (s) => s.cpuDeltaSeconds === null || s.intervalSeconds <= 0
|
|
162
|
+
? null
|
|
163
|
+
: (100 * s.cpuDeltaSeconds) / s.intervalSeconds),
|
|
164
|
+
spawnsPerMinutePerHour: growthPerHour(window, (s) => s.intervalSeconds <= 0 ? null : (60 * s.tmuxSpawns) / s.intervalSeconds),
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
whole: fit(samples),
|
|
168
|
+
afterWarmup: fit(samples.filter((s) => s.elapsedSeconds >= warmupSeconds)),
|
|
169
|
+
trailing: fit(samples.filter((s) => s.elapsedSeconds >= Math.max(warmupSeconds, end - trailingSeconds))),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Descriptive only: heap changes can reflect GC and do not prove retention. */
|
|
173
|
+
export function summarizeDiagnostics(samples) {
|
|
174
|
+
const describe = (pick) => ({
|
|
175
|
+
start: firstValue(samples, pick),
|
|
176
|
+
end: lastValue(samples, pick),
|
|
177
|
+
max: maxValue(samples, pick),
|
|
178
|
+
measuredSamples: samples.filter((s) => pick(s) !== null).length,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
validSamples: samples.filter((s) => s.diagnostics?.status === "ok").length,
|
|
182
|
+
unsupportedResourceSamples: samples.filter((s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources === null).length,
|
|
183
|
+
memoryBytes: Object.fromEntries(MEMORY_KEYS.map((key) => [key, describe((s) => s.diagnostics?.sample?.memory[key] ?? null)])),
|
|
184
|
+
activeResources: Object.fromEntries(RESOURCE_KEYS.map((key) => [
|
|
185
|
+
key,
|
|
186
|
+
describe((s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
|
|
187
|
+
])),
|
|
188
|
+
cpuPercent: describe((s) => s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null),
|
|
189
|
+
eventLoopUtilization: describe((s) => s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export function summarizeSoak(samples) {
|
|
193
|
+
const rss = (sample) => sample.rssKiB === null ? null : sample.rssKiB / KIB_PER_MIB;
|
|
194
|
+
const fds = (sample) => sample.openFds;
|
|
195
|
+
const rssFit = growthPerHour(samples, rss);
|
|
196
|
+
const fdFit = growthPerHour(samples, fds);
|
|
197
|
+
const durationSeconds = samples.length === 0
|
|
198
|
+
? 0
|
|
199
|
+
: samples[samples.length - 1].elapsedSeconds - samples[0].elapsedSeconds;
|
|
200
|
+
let cpuTotal = null;
|
|
201
|
+
let intervalTotal = 0;
|
|
202
|
+
let spawnsTotal = 0;
|
|
203
|
+
let gaps = 0;
|
|
204
|
+
let receiptFailures = 0;
|
|
205
|
+
let disconnects = 0;
|
|
206
|
+
for (const sample of samples) {
|
|
207
|
+
if (sample.cpuDeltaSeconds !== null)
|
|
208
|
+
cpuTotal = (cpuTotal ?? 0) + sample.cpuDeltaSeconds;
|
|
209
|
+
intervalTotal += sample.intervalSeconds;
|
|
210
|
+
spawnsTotal += sample.tmuxSpawns;
|
|
211
|
+
gaps += sample.observerGapWarnings;
|
|
212
|
+
receiptFailures += sample.receiptFailures;
|
|
213
|
+
disconnects += sample.unexpectedDisconnects;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
diagnostics: summarizeDiagnostics(samples),
|
|
217
|
+
samples: samples.length,
|
|
218
|
+
durationSeconds,
|
|
219
|
+
rssStartMiB: firstValue(samples, rss),
|
|
220
|
+
rssEndMiB: lastValue(samples, rss),
|
|
221
|
+
rssMaxMiB: maxValue(samples, rss),
|
|
222
|
+
rssGrowthMiBPerHour: rssFit?.slope ?? null,
|
|
223
|
+
rssFitR2: rssFit?.r2 ?? null,
|
|
224
|
+
fdStart: firstValue(samples, fds),
|
|
225
|
+
fdEnd: lastValue(samples, fds),
|
|
226
|
+
fdMax: maxValue(samples, fds),
|
|
227
|
+
fdGrowthPerHour: fdFit?.slope ?? null,
|
|
228
|
+
cpuSecondsTotal: cpuTotal,
|
|
229
|
+
cpuPercentMean: cpuTotal === null || intervalTotal === 0 ? null : (cpuTotal / intervalTotal) * 100,
|
|
230
|
+
tmuxSpawnsTotal: spawnsTotal,
|
|
231
|
+
tmuxSpawnsPerMinute: intervalTotal === 0 ? null : spawnsTotal / (intervalTotal / 60),
|
|
232
|
+
pingRttMs: mergePercentiles(samples.map((sample) => sample.pingRttMs)),
|
|
233
|
+
receiptLatencyMs: mergePercentiles(samples.map((sample) => sample.receiptLatencyMs)),
|
|
234
|
+
observerGapWarnings: gaps,
|
|
235
|
+
daemonRestarts: lastValue(samples, (sample) => sample.daemonRestarts) ?? 0,
|
|
236
|
+
receiptFailures,
|
|
237
|
+
unexpectedDisconnects: disconnects,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function boundCheck(id, observed, bound, detail, options = {}) {
|
|
241
|
+
if (observed === null)
|
|
242
|
+
return { id, ok: null, observed, bound, detail: `${detail}: not measured` };
|
|
243
|
+
const value = options.absolute ? Math.abs(observed) : observed;
|
|
244
|
+
return { id, ok: value <= bound, observed, bound, detail };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Evaluate a sample series. A check is `ok: null` when the run could not
|
|
248
|
+
* measure it (too few samples for a fit, a metric never collected); the
|
|
249
|
+
* overall verdict is then `inconclusive` unless some other check failed.
|
|
250
|
+
*/
|
|
251
|
+
export function evaluateSoak(samples, thresholds = DEFAULT_SOAK_THRESHOLDS, evidence) {
|
|
252
|
+
const summary = summarizeSoak(samples);
|
|
253
|
+
const enoughForFit = samples.length >= thresholds.minSamplesForFit &&
|
|
254
|
+
(!evidence ||
|
|
255
|
+
samples.filter((sample) => sample.elapsedSeconds >= evidence.warmupSeconds).length >=
|
|
256
|
+
thresholds.minSamplesForFit);
|
|
257
|
+
const checks = [
|
|
258
|
+
enoughForFit
|
|
259
|
+
? boundCheck("rss-growth", summary.rssGrowthMiBPerHour, thresholds.maxRssGrowthMiBPerHour, `fitted RSS slope MiB/h over ${samples.length} samples (r2 ${summary.rssFitR2?.toFixed(2) ?? "n/a"})`)
|
|
260
|
+
: {
|
|
261
|
+
id: "rss-growth",
|
|
262
|
+
ok: null,
|
|
263
|
+
observed: summary.rssGrowthMiBPerHour,
|
|
264
|
+
bound: thresholds.maxRssGrowthMiBPerHour,
|
|
265
|
+
detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
|
|
266
|
+
},
|
|
267
|
+
boundCheck("fd-drift", summary.fdStart === null || summary.fdEnd === null ? null : summary.fdEnd - summary.fdStart, thresholds.maxFdDrift, "open fd count end minus start", { absolute: true }),
|
|
268
|
+
enoughForFit
|
|
269
|
+
? boundCheck("fd-growth", summary.fdGrowthPerHour, thresholds.maxFdGrowthPerHour, "fitted fd slope per hour")
|
|
270
|
+
: {
|
|
271
|
+
id: "fd-growth",
|
|
272
|
+
ok: null,
|
|
273
|
+
observed: summary.fdGrowthPerHour,
|
|
274
|
+
bound: thresholds.maxFdGrowthPerHour,
|
|
275
|
+
detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
|
|
276
|
+
},
|
|
277
|
+
boundCheck("ping-rtt-p50", summary.pingRttMs.p50, thresholds.maxPingRttP50Ms, "WebSocket transport control RTT p50 upper bound ms (not semantic handler latency)"),
|
|
278
|
+
boundCheck("receipt-latency-p50", summary.receiptLatencyMs.p50, thresholds.maxReceiptLatencyP50Ms, "wait agent-status receipt latency p50 upper bound ms after the flip"),
|
|
279
|
+
boundCheck("observer-gaps", summary.observerGapWarnings, thresholds.maxObserverGapWarnings, "interaction observer gap warnings"),
|
|
280
|
+
boundCheck("daemon-restarts", summary.daemonRestarts, thresholds.maxDaemonRestarts, "daemon pid or instance changes"),
|
|
281
|
+
boundCheck("receipt-failures", summary.receiptFailures, thresholds.maxReceiptFailures, "receipt waiters that failed or timed out"),
|
|
282
|
+
boundCheck("unexpected-disconnects", summary.unexpectedDisconnects, thresholds.maxUnexpectedDisconnects, "events-client drops outside scheduled reconnects"),
|
|
283
|
+
];
|
|
284
|
+
const coverage = (id, complete, detail) => checks.push({
|
|
285
|
+
id,
|
|
286
|
+
ok: complete === true ? true : null,
|
|
287
|
+
observed: complete === true ? 1 : null,
|
|
288
|
+
bound: 1,
|
|
289
|
+
detail,
|
|
290
|
+
});
|
|
291
|
+
coverage("duration-complete", evidence?.completed && evidence.observedSeconds >= evidence.requestedSeconds, "requested duration reached without interruption");
|
|
292
|
+
coverage("telemetry-complete", evidence?.telemetryComplete &&
|
|
293
|
+
samples.every((s) => [s.rssKiB, s.cpuSeconds, s.openFds].every((value) => value !== null && Number.isFinite(value))), "all required samples and metrics present");
|
|
294
|
+
coverage("diagnostics-coverage", samples.length > 0 &&
|
|
295
|
+
samples.every((s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources !== null), "owner diagnostics and supported resource counts required at every sample; missing/malformed/404 remain inconclusive");
|
|
296
|
+
coverage("diagnostics-deltas", samples.length > 1 &&
|
|
297
|
+
samples.every((s, i) => i === 0
|
|
298
|
+
? s.diagnosticDelta?.status === "missing-baseline"
|
|
299
|
+
: s.diagnosticDelta?.status === "ok" && s.diagnosticDelta.eventLoopUtilization !== null), "adjacent monotonic cumulative counters required after first baseline");
|
|
300
|
+
checks.push(boundCheck("diagnostics-identity", samples.filter((s) => s.diagnostics?.status === "identity-mismatch" ||
|
|
301
|
+
s.diagnosticDelta?.status === "identity-mismatch").length, 0, "diagnostics must match original daemon identity and PID"));
|
|
302
|
+
coverage("load-complete", evidence?.loadCompleted, "each declared workload completed at least once");
|
|
303
|
+
coverage("log-coverage", evidence?.logCoverageComplete, "continuous log stream, bookmark, no gap or replay ambiguity");
|
|
304
|
+
coverage("run-evidence", evidence !== undefined, "explicit run and teardown evidence supplied");
|
|
305
|
+
coverage("resource-trend-policy", evidence?.resourceTrendPolicyComplete, "heap/resources and CPU/spawn trend acceptance policy awaiting calibration and review");
|
|
306
|
+
if (evidence) {
|
|
307
|
+
const requiredFailures = [
|
|
308
|
+
"health",
|
|
309
|
+
"flip",
|
|
310
|
+
"promotion",
|
|
311
|
+
"send",
|
|
312
|
+
"receipt",
|
|
313
|
+
"daemonMissing",
|
|
314
|
+
"daemonExit",
|
|
315
|
+
"ack",
|
|
316
|
+
"pingDeadline",
|
|
317
|
+
"loop",
|
|
318
|
+
"sample",
|
|
319
|
+
];
|
|
320
|
+
for (const name of new Set([...requiredFailures, ...Object.keys(evidence.failures)])) {
|
|
321
|
+
const count = evidence.failures[name];
|
|
322
|
+
checks.push(boundCheck(name, typeof count === "number" && Number.isFinite(count) && count >= 0 ? count : null, 0, "explicit run failure count"));
|
|
323
|
+
}
|
|
324
|
+
coverage("reconnect-coverage", evidence.reconnectAttempts > 1 &&
|
|
325
|
+
evidence.reconnectAttempts === evidence.reconnectAcknowledged, "initial subscription and at least one reconnect acknowledged");
|
|
326
|
+
for (const [id, ok] of [
|
|
327
|
+
["shutdown-clean", evidence.shutdownClean],
|
|
328
|
+
["record-retired", evidence.recordRetired],
|
|
329
|
+
["cleanup-complete", evidence.cleanupComplete],
|
|
330
|
+
])
|
|
331
|
+
checks.push({ id, ok, observed: ok ? 0 : 1, bound: 0, detail: "observed teardown outcome" });
|
|
332
|
+
const trends = soakTrends(samples, evidence.warmupSeconds, evidence.trailingSeconds);
|
|
333
|
+
for (const [window, metrics] of Object.entries(trends)) {
|
|
334
|
+
if (window === "whole")
|
|
335
|
+
continue;
|
|
336
|
+
const fit = metrics.rssMiBPerHour;
|
|
337
|
+
checks.push(boundCheck(`rss-${window}`, fit && fit.points >= thresholds.minSamplesForFit ? fit.slope : null, thresholds.maxRssGrowthMiBPerHour, "predeclared window RSS slope MiB/h"));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const failed = checks.some((check) => check.ok === false);
|
|
341
|
+
const unmeasured = checks.some((check) => check.ok === null);
|
|
342
|
+
return {
|
|
343
|
+
verdict: failed ? "fail" : unmeasured ? "inconclusive" : "pass",
|
|
344
|
+
summary,
|
|
345
|
+
checks,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/** Parse `ps -o time` output (`mm:ss.cc`, `hh:mm:ss`, `dd-hh:mm:ss`) into seconds. */
|
|
349
|
+
export function parsePsTime(raw) {
|
|
350
|
+
const text = raw.trim();
|
|
351
|
+
if (!text)
|
|
352
|
+
return null;
|
|
353
|
+
let days = 0;
|
|
354
|
+
let rest = text;
|
|
355
|
+
const dayMatch = /^(\d+)-(.+)$/u.exec(rest);
|
|
356
|
+
if (dayMatch) {
|
|
357
|
+
days = Number(dayMatch[1]);
|
|
358
|
+
rest = dayMatch[2];
|
|
359
|
+
}
|
|
360
|
+
const parts = rest.split(":");
|
|
361
|
+
if (parts.length < 2 || parts.length > 3 || parts.some((part) => !/^\d+(?:\.\d+)?$/u.test(part)))
|
|
362
|
+
return null;
|
|
363
|
+
let seconds = 0;
|
|
364
|
+
for (const part of parts)
|
|
365
|
+
seconds = seconds * 60 + Number(part);
|
|
366
|
+
return days * 86_400 + seconds;
|
|
367
|
+
}
|
|
368
|
+
/** Parse a duration flag: `30m`, `24h`, `90s`, `1500ms`, or a bare millisecond count. */
|
|
369
|
+
export function parseDurationMs(raw) {
|
|
370
|
+
if (typeof raw === "number")
|
|
371
|
+
return raw;
|
|
372
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\s*$/u.exec(raw);
|
|
373
|
+
if (!match)
|
|
374
|
+
throw new Error(`Invalid duration: ${raw}`);
|
|
375
|
+
const value = Number(match[1]);
|
|
376
|
+
const unit = match[2] ?? "ms";
|
|
377
|
+
const factor = unit === "ms"
|
|
378
|
+
? 1
|
|
379
|
+
: unit === "s"
|
|
380
|
+
? 1_000
|
|
381
|
+
: unit === "m"
|
|
382
|
+
? 60_000
|
|
383
|
+
: unit === "h"
|
|
384
|
+
? 3_600_000
|
|
385
|
+
: 86_400_000;
|
|
386
|
+
return Math.round(value * factor);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Attribute counting-shim records (one per spawn, arguments joined by 0x01) to
|
|
390
|
+
* their first tmux command word, skipping socket/option prefixes.
|
|
391
|
+
*/
|
|
392
|
+
export function countSpawnsByCommand(records) {
|
|
393
|
+
const counts = {};
|
|
394
|
+
for (const line of records) {
|
|
395
|
+
if (!line)
|
|
396
|
+
continue;
|
|
397
|
+
const words = line.split("\u0001");
|
|
398
|
+
let index = 0;
|
|
399
|
+
while (index < words.length && words[index].startsWith("-")) {
|
|
400
|
+
index += words[index] === "-u" || words[index] === "-C" || words[index] === "-v" ? 1 : 2;
|
|
401
|
+
}
|
|
402
|
+
const command = words[index] || "(none)";
|
|
403
|
+
counts[command] = (counts[command] ?? 0) + 1;
|
|
404
|
+
}
|
|
405
|
+
return counts;
|
|
406
|
+
}
|
|
407
|
+
/** Fixed-width text table for the terminal summary. */
|
|
408
|
+
export function formatSoakReport(result) {
|
|
409
|
+
const { summary, checks } = result;
|
|
410
|
+
const num = (value, digits = 1) => value === null ? "n/a" : value.toFixed(digits);
|
|
411
|
+
const rows = [
|
|
412
|
+
["samples", `${summary.samples} over ${num(summary.durationSeconds / 60)} min`],
|
|
413
|
+
[
|
|
414
|
+
"rss MiB",
|
|
415
|
+
`start ${num(summary.rssStartMiB)} end ${num(summary.rssEndMiB)} max ${num(summary.rssMaxMiB)} slope ${num(summary.rssGrowthMiBPerHour, 2)}/h`,
|
|
416
|
+
],
|
|
417
|
+
[
|
|
418
|
+
"open fds",
|
|
419
|
+
`start ${num(summary.fdStart, 0)} end ${num(summary.fdEnd, 0)} max ${num(summary.fdMax, 0)} slope ${num(summary.fdGrowthPerHour, 2)}/h`,
|
|
420
|
+
],
|
|
421
|
+
["cpu", `${num(summary.cpuSecondsTotal)} s total ${num(summary.cpuPercentMean, 2)} % mean`],
|
|
422
|
+
["tmux spawns", `${summary.tmuxSpawnsTotal} total ${num(summary.tmuxSpawnsPerMinute, 2)}/min`],
|
|
423
|
+
[
|
|
424
|
+
"ping rtt ms",
|
|
425
|
+
`p50 <= ${num(summary.pingRttMs.p50, 2)} max ${num(summary.pingRttMs.max, 2)} n ${summary.pingRttMs.count}`,
|
|
426
|
+
],
|
|
427
|
+
[
|
|
428
|
+
"receipt ms",
|
|
429
|
+
`p50 <= ${num(summary.receiptLatencyMs.p50, 0)} max ${num(summary.receiptLatencyMs.max, 0)} n ${summary.receiptLatencyMs.count}`,
|
|
430
|
+
],
|
|
431
|
+
[
|
|
432
|
+
"owner diagnostics",
|
|
433
|
+
`${summary.diagnostics.validSamples}/${summary.samples} valid; resources unsupported ${summary.diagnostics.unsupportedResourceSamples}`,
|
|
434
|
+
],
|
|
435
|
+
[
|
|
436
|
+
"heap used MiB",
|
|
437
|
+
`start ${num(summary.diagnostics.memoryBytes.heapUsed?.start === null || summary.diagnostics.memoryBytes.heapUsed?.start === undefined ? null : summary.diagnostics.memoryBytes.heapUsed.start / 1024 ** 2)} end ${num(summary.diagnostics.memoryBytes.heapUsed?.end === null || summary.diagnostics.memoryBytes.heapUsed?.end === undefined ? null : summary.diagnostics.memoryBytes.heapUsed.end / 1024 ** 2)} (descriptive; GC-sensitive)`,
|
|
438
|
+
],
|
|
439
|
+
[
|
|
440
|
+
"diagnostic CPU",
|
|
441
|
+
`${num(summary.diagnostics.cpuPercent.start)} → ${num(summary.diagnostics.cpuPercent.end)} % interval; policy pending`,
|
|
442
|
+
],
|
|
443
|
+
["observer gaps", String(summary.observerGapWarnings)],
|
|
444
|
+
["daemon restarts", String(summary.daemonRestarts)],
|
|
445
|
+
["receipt failures", String(summary.receiptFailures)],
|
|
446
|
+
["unexpected disconnects", String(summary.unexpectedDisconnects)],
|
|
447
|
+
];
|
|
448
|
+
const width = Math.max(...rows.map(([label]) => label.length), ...checks.map((c) => c.id.length));
|
|
449
|
+
const lines = rows.map(([label, value]) => `${label.padEnd(width)} ${value}`);
|
|
450
|
+
lines.push("");
|
|
451
|
+
for (const check of checks) {
|
|
452
|
+
const mark = check.ok === null ? "----" : check.ok ? "ok " : "FAIL";
|
|
453
|
+
lines.push(`${mark} ${check.id.padEnd(width)} observed ${num(check.observed, 2)} bound ${check.bound} ${check.detail}`);
|
|
454
|
+
}
|
|
455
|
+
lines.push("");
|
|
456
|
+
lines.push(`verdict: ${result.verdict.toUpperCase()}`);
|
|
457
|
+
return lines.join("\n");
|
|
458
|
+
}
|
|
459
|
+
/** A revision acknowledges exactly the interest set sent in that subscribe. */
|
|
460
|
+
export function validSoakAck(frame, revision) {
|
|
461
|
+
if (!frame || typeof frame !== "object")
|
|
462
|
+
return false;
|
|
463
|
+
const ack = frame;
|
|
464
|
+
return (ack.type === "resource.interests-ack" &&
|
|
465
|
+
ack.interestRevision === revision &&
|
|
466
|
+
Array.isArray(ack.unavailableInterests) &&
|
|
467
|
+
ack.unavailableInterests.length === 0);
|
|
468
|
+
}
|
|
469
|
+
/** Unsolicited/stale control payloads cannot discharge the current probe. */
|
|
470
|
+
export function correlatedPongRtt(probe, payload, now) {
|
|
471
|
+
return payload === probe.id && Number.isFinite(now) && now >= probe.at ? now - probe.at : null;
|
|
472
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function color(red, green, blue) {
|
|
2
|
+
return { space: "srgb", red, green, blue, alpha: 255 };
|
|
3
|
+
}
|
|
4
|
+
export function parseTerminalHostColor(value) {
|
|
5
|
+
if (!value)
|
|
6
|
+
return null;
|
|
7
|
+
const normalized = value.trim().toLowerCase();
|
|
8
|
+
const hex = /^#([\da-f]{3}|[\da-f]{6})$/u.exec(normalized)?.[1];
|
|
9
|
+
if (hex) {
|
|
10
|
+
const expanded = hex.length === 3 ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` : hex;
|
|
11
|
+
return color(Number.parseInt(expanded.slice(0, 2), 16), Number.parseInt(expanded.slice(2, 4), 16), Number.parseInt(expanded.slice(4, 6), 16));
|
|
12
|
+
}
|
|
13
|
+
// OSC palette replies may use X11's rgb:RR/GG/BB form with one to four
|
|
14
|
+
// hexadecimal digits per channel. Scale each channel to a byte rather than
|
|
15
|
+
// truncating high-fidelity replies.
|
|
16
|
+
const x11 = /^rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})$/u.exec(normalized);
|
|
17
|
+
if (!x11)
|
|
18
|
+
return null;
|
|
19
|
+
const channel = (part) => {
|
|
20
|
+
const maximum = 16 ** part.length - 1;
|
|
21
|
+
return Math.round((Number.parseInt(part, 16) / maximum) * 255);
|
|
22
|
+
};
|
|
23
|
+
return color(channel(x11[1]), channel(x11[2]), channel(x11[3]));
|
|
24
|
+
}
|
|
25
|
+
/** Only a reported default background determines host appearance. */
|
|
26
|
+
export function terminalHostMode(background) {
|
|
27
|
+
const value = parseTerminalHostColor(background);
|
|
28
|
+
return value
|
|
29
|
+
? 0.299 * value.red + 0.587 * value.green + 0.114 * value.blue > 127.5
|
|
30
|
+
? "light"
|
|
31
|
+
: "dark"
|
|
32
|
+
: null;
|
|
33
|
+
}
|