tmux-ide 2.9.0-beta.20 → 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 +100 -8
- package/package.json +3 -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/server.js +7 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +185 -35
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- 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-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/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +314 -23
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- 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-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -0
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +9 -2
- 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-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MEMORY_KEYS,
|
|
3
|
+
RESOURCE_KEYS,
|
|
4
|
+
type DiagnosticsResult,
|
|
5
|
+
type DiagnosticsDelta,
|
|
6
|
+
} from "./soak-diagnostics.ts";
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* Pure analysis for the daemon soak harness (`packages/daemon/scripts/soak-daemon.mjs`).
|
|
3
10
|
*
|
|
@@ -8,6 +15,8 @@
|
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
17
|
export interface SoakSample {
|
|
18
|
+
readonly diagnostics?: DiagnosticsResult;
|
|
19
|
+
readonly diagnosticDelta?: DiagnosticsDelta | null;
|
|
11
20
|
/** Milliseconds since the epoch when the sample was taken. */
|
|
12
21
|
readonly at: number;
|
|
13
22
|
/** Seconds since the soak started (the linear-fit abscissa). */
|
|
@@ -40,6 +49,8 @@ export interface Percentiles {
|
|
|
40
49
|
readonly count: number;
|
|
41
50
|
readonly p50: number | null;
|
|
42
51
|
readonly max: number | null;
|
|
52
|
+
/** Logarithmic bucket index → count; p50 is an upper bound; ≤5% or 1ms error below overflow (which uses max). */
|
|
53
|
+
readonly buckets?: Readonly<Record<string, number>>;
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
export interface SoakThresholds {
|
|
@@ -117,12 +128,24 @@ export function percentile(values: readonly number[], fraction: number): number
|
|
|
117
128
|
return sorted[rank]!;
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
/** At most 228 buckets for probes capped at 60 seconds, plus an overflow bucket. */
|
|
120
132
|
export function percentiles(values: readonly number[]): Percentiles {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
const buckets: Record<string, number> = {};
|
|
134
|
+
let max: number | null = null;
|
|
135
|
+
for (const value of values) {
|
|
136
|
+
if (!Number.isFinite(value) || value < 0) continue;
|
|
137
|
+
max = Math.max(max ?? 0, value);
|
|
138
|
+
const index = value <= 1 ? 0 : Math.min(227, Math.ceil(Math.log(value) / Math.log(1.05)));
|
|
139
|
+
buckets[index] = (buckets[index] ?? 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
return mergePercentiles([
|
|
142
|
+
{
|
|
143
|
+
count: Object.values(buckets).reduce((a, b) => a + b, 0),
|
|
144
|
+
p50: null,
|
|
145
|
+
max,
|
|
146
|
+
buckets,
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
126
149
|
}
|
|
127
150
|
|
|
128
151
|
/**
|
|
@@ -151,6 +174,7 @@ export interface SoakCheck {
|
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
export interface SoakSummary {
|
|
177
|
+
readonly diagnostics: ReturnType<typeof summarizeDiagnostics>;
|
|
154
178
|
readonly samples: number;
|
|
155
179
|
readonly durationSeconds: number;
|
|
156
180
|
readonly rssStartMiB: number | null;
|
|
@@ -217,22 +241,130 @@ function maxValue(
|
|
|
217
241
|
return max;
|
|
218
242
|
}
|
|
219
243
|
|
|
220
|
-
/**
|
|
221
|
-
* Merge per-interval percentile records into a whole-run estimate. Exact
|
|
222
|
-
* per-sample series are not retained across intervals, so the p50 here is the
|
|
223
|
-
* count-weighted median of interval medians and the max is the true max.
|
|
224
|
-
*/
|
|
244
|
+
/** Merge bounded histograms, never a median of medians. Legacy p50 is unmeasured. */
|
|
225
245
|
export function mergePercentiles(records: readonly Percentiles[]): Percentiles {
|
|
226
|
-
const
|
|
227
|
-
let max: number | null = null;
|
|
246
|
+
const buckets: Record<string, number> = {};
|
|
228
247
|
let count = 0;
|
|
248
|
+
let max: number | null = null;
|
|
249
|
+
let missing = false;
|
|
229
250
|
for (const record of records) {
|
|
230
|
-
if (record.p50 === null || record.count === 0) continue;
|
|
231
251
|
count += record.count;
|
|
232
|
-
|
|
233
|
-
if (record.max !== null
|
|
252
|
+
if (record.count && !record.buckets) missing = true;
|
|
253
|
+
if (record.max !== null) max = Math.max(max ?? 0, record.max);
|
|
254
|
+
for (const [key, value] of Object.entries(record.buckets ?? {}))
|
|
255
|
+
buckets[key] = (buckets[key] ?? 0) + value;
|
|
256
|
+
}
|
|
257
|
+
let cumulative = 0;
|
|
258
|
+
let p50: number | null = null;
|
|
259
|
+
if (!missing && count) {
|
|
260
|
+
for (const key of Object.keys(buckets)
|
|
261
|
+
.map(Number)
|
|
262
|
+
.sort((a, b) => a - b)) {
|
|
263
|
+
cumulative += buckets[key]!;
|
|
264
|
+
if (cumulative >= Math.ceil(count / 2)) {
|
|
265
|
+
p50 = key === 227 ? max : Math.min(max ?? Infinity, 1.05 ** key);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
234
269
|
}
|
|
235
|
-
return { count, p50
|
|
270
|
+
return { count, p50, max, buckets };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Run-level evidence must be supplied explicitly; absent evidence cannot qualify. */
|
|
274
|
+
export interface SoakEvidence {
|
|
275
|
+
readonly requestedSeconds: number;
|
|
276
|
+
readonly observedSeconds: number;
|
|
277
|
+
readonly completed: boolean;
|
|
278
|
+
readonly failures: Readonly<Record<string, number>>;
|
|
279
|
+
readonly loadCompleted: boolean;
|
|
280
|
+
readonly telemetryComplete: boolean;
|
|
281
|
+
readonly logCoverageComplete: boolean;
|
|
282
|
+
readonly reconnectAttempts: number;
|
|
283
|
+
readonly reconnectAcknowledged: number;
|
|
284
|
+
readonly shutdownClean: boolean;
|
|
285
|
+
readonly recordRetired: boolean;
|
|
286
|
+
readonly cleanupComplete: boolean;
|
|
287
|
+
readonly resourceTrendPolicyComplete: boolean;
|
|
288
|
+
readonly warmupSeconds: number;
|
|
289
|
+
readonly trailingSeconds: number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function soakTrends(
|
|
293
|
+
samples: readonly SoakSample[],
|
|
294
|
+
warmupSeconds: number,
|
|
295
|
+
trailingSeconds: number,
|
|
296
|
+
) {
|
|
297
|
+
const end = samples.at(-1)?.elapsedSeconds ?? 0;
|
|
298
|
+
const fit = (window: readonly SoakSample[]) => ({
|
|
299
|
+
memoryMiBPerHour: Object.fromEntries(
|
|
300
|
+
MEMORY_KEYS.map((key) => [
|
|
301
|
+
key,
|
|
302
|
+
growthPerHour(window, (s) => {
|
|
303
|
+
const value = s.diagnostics?.sample?.memory[key];
|
|
304
|
+
return value === undefined ? null : value / 1024 ** 2;
|
|
305
|
+
}),
|
|
306
|
+
]),
|
|
307
|
+
),
|
|
308
|
+
activeResourcesPerHour: Object.fromEntries(
|
|
309
|
+
RESOURCE_KEYS.map((key) => [
|
|
310
|
+
key,
|
|
311
|
+
growthPerHour(window, (s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
|
|
312
|
+
]),
|
|
313
|
+
),
|
|
314
|
+
diagnosticCpuPercentPerHour: growthPerHour(window, (s) =>
|
|
315
|
+
s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null,
|
|
316
|
+
),
|
|
317
|
+
eventLoopUtilizationPerHour: growthPerHour(window, (s) =>
|
|
318
|
+
s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null,
|
|
319
|
+
),
|
|
320
|
+
rssMiBPerHour: growthPerHour(window, (s) => (s.rssKiB === null ? null : s.rssKiB / 1024)),
|
|
321
|
+
cpuPercentPerHour: growthPerHour(window, (s) =>
|
|
322
|
+
s.cpuDeltaSeconds === null || s.intervalSeconds <= 0
|
|
323
|
+
? null
|
|
324
|
+
: (100 * s.cpuDeltaSeconds) / s.intervalSeconds,
|
|
325
|
+
),
|
|
326
|
+
spawnsPerMinutePerHour: growthPerHour(window, (s) =>
|
|
327
|
+
s.intervalSeconds <= 0 ? null : (60 * s.tmuxSpawns) / s.intervalSeconds,
|
|
328
|
+
),
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
whole: fit(samples),
|
|
332
|
+
afterWarmup: fit(samples.filter((s) => s.elapsedSeconds >= warmupSeconds)),
|
|
333
|
+
trailing: fit(
|
|
334
|
+
samples.filter((s) => s.elapsedSeconds >= Math.max(warmupSeconds, end - trailingSeconds)),
|
|
335
|
+
),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Descriptive only: heap changes can reflect GC and do not prove retention. */
|
|
340
|
+
export function summarizeDiagnostics(samples: readonly SoakSample[]) {
|
|
341
|
+
const describe = (pick: (s: SoakSample) => number | null) => ({
|
|
342
|
+
start: firstValue(samples, pick),
|
|
343
|
+
end: lastValue(samples, pick),
|
|
344
|
+
max: maxValue(samples, pick),
|
|
345
|
+
measuredSamples: samples.filter((s) => pick(s) !== null).length,
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
validSamples: samples.filter((s) => s.diagnostics?.status === "ok").length,
|
|
349
|
+
unsupportedResourceSamples: samples.filter(
|
|
350
|
+
(s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources === null,
|
|
351
|
+
).length,
|
|
352
|
+
memoryBytes: Object.fromEntries(
|
|
353
|
+
MEMORY_KEYS.map((key) => [key, describe((s) => s.diagnostics?.sample?.memory[key] ?? null)]),
|
|
354
|
+
),
|
|
355
|
+
activeResources: Object.fromEntries(
|
|
356
|
+
RESOURCE_KEYS.map((key) => [
|
|
357
|
+
key,
|
|
358
|
+
describe((s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
|
|
359
|
+
]),
|
|
360
|
+
),
|
|
361
|
+
cpuPercent: describe((s) =>
|
|
362
|
+
s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null,
|
|
363
|
+
),
|
|
364
|
+
eventLoopUtilization: describe((s) =>
|
|
365
|
+
s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null,
|
|
366
|
+
),
|
|
367
|
+
};
|
|
236
368
|
}
|
|
237
369
|
|
|
238
370
|
export function summarizeSoak(samples: readonly SoakSample[]): SoakSummary {
|
|
@@ -260,6 +392,7 @@ export function summarizeSoak(samples: readonly SoakSample[]): SoakSummary {
|
|
|
260
392
|
disconnects += sample.unexpectedDisconnects;
|
|
261
393
|
}
|
|
262
394
|
return {
|
|
395
|
+
diagnostics: summarizeDiagnostics(samples),
|
|
263
396
|
samples: samples.length,
|
|
264
397
|
durationSeconds,
|
|
265
398
|
rssStartMiB: firstValue(samples, rss),
|
|
@@ -306,9 +439,14 @@ function boundCheck(
|
|
|
306
439
|
export function evaluateSoak(
|
|
307
440
|
samples: readonly SoakSample[],
|
|
308
441
|
thresholds: SoakThresholds = DEFAULT_SOAK_THRESHOLDS,
|
|
442
|
+
evidence?: SoakEvidence,
|
|
309
443
|
): SoakVerdict {
|
|
310
444
|
const summary = summarizeSoak(samples);
|
|
311
|
-
const enoughForFit =
|
|
445
|
+
const enoughForFit =
|
|
446
|
+
samples.length >= thresholds.minSamplesForFit &&
|
|
447
|
+
(!evidence ||
|
|
448
|
+
samples.filter((sample) => sample.elapsedSeconds >= evidence.warmupSeconds).length >=
|
|
449
|
+
thresholds.minSamplesForFit);
|
|
312
450
|
const checks: SoakCheck[] = [
|
|
313
451
|
enoughForFit
|
|
314
452
|
? boundCheck(
|
|
@@ -322,7 +460,7 @@ export function evaluateSoak(
|
|
|
322
460
|
ok: null,
|
|
323
461
|
observed: summary.rssGrowthMiBPerHour,
|
|
324
462
|
bound: thresholds.maxRssGrowthMiBPerHour,
|
|
325
|
-
detail: `needs ${thresholds.minSamplesForFit} samples, have ${samples.length}`,
|
|
463
|
+
detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
|
|
326
464
|
},
|
|
327
465
|
boundCheck(
|
|
328
466
|
"fd-drift",
|
|
@@ -343,19 +481,19 @@ export function evaluateSoak(
|
|
|
343
481
|
ok: null,
|
|
344
482
|
observed: summary.fdGrowthPerHour,
|
|
345
483
|
bound: thresholds.maxFdGrowthPerHour,
|
|
346
|
-
detail: `needs ${thresholds.minSamplesForFit} samples, have ${samples.length}`,
|
|
484
|
+
detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
|
|
347
485
|
},
|
|
348
486
|
boundCheck(
|
|
349
487
|
"ping-rtt-p50",
|
|
350
488
|
summary.pingRttMs.p50,
|
|
351
489
|
thresholds.maxPingRttP50Ms,
|
|
352
|
-
"
|
|
490
|
+
"WebSocket transport control RTT p50 upper bound ms (not semantic handler latency)",
|
|
353
491
|
),
|
|
354
492
|
boundCheck(
|
|
355
493
|
"receipt-latency-p50",
|
|
356
494
|
summary.receiptLatencyMs.p50,
|
|
357
495
|
thresholds.maxReceiptLatencyP50Ms,
|
|
358
|
-
"wait agent-status receipt latency p50 ms after the flip",
|
|
496
|
+
"wait agent-status receipt latency p50 upper bound ms after the flip",
|
|
359
497
|
),
|
|
360
498
|
boundCheck(
|
|
361
499
|
"observer-gaps",
|
|
@@ -382,6 +520,126 @@ export function evaluateSoak(
|
|
|
382
520
|
"events-client drops outside scheduled reconnects",
|
|
383
521
|
),
|
|
384
522
|
];
|
|
523
|
+
const coverage = (id: string, complete: boolean | undefined, detail: string) =>
|
|
524
|
+
checks.push({
|
|
525
|
+
id,
|
|
526
|
+
ok: complete === true ? true : null,
|
|
527
|
+
observed: complete === true ? 1 : null,
|
|
528
|
+
bound: 1,
|
|
529
|
+
detail,
|
|
530
|
+
});
|
|
531
|
+
coverage(
|
|
532
|
+
"duration-complete",
|
|
533
|
+
evidence?.completed && evidence.observedSeconds >= evidence.requestedSeconds,
|
|
534
|
+
"requested duration reached without interruption",
|
|
535
|
+
);
|
|
536
|
+
coverage(
|
|
537
|
+
"telemetry-complete",
|
|
538
|
+
evidence?.telemetryComplete &&
|
|
539
|
+
samples.every((s) =>
|
|
540
|
+
[s.rssKiB, s.cpuSeconds, s.openFds].every(
|
|
541
|
+
(value) => value !== null && Number.isFinite(value),
|
|
542
|
+
),
|
|
543
|
+
),
|
|
544
|
+
"all required samples and metrics present",
|
|
545
|
+
);
|
|
546
|
+
coverage(
|
|
547
|
+
"diagnostics-coverage",
|
|
548
|
+
samples.length > 0 &&
|
|
549
|
+
samples.every(
|
|
550
|
+
(s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources !== null,
|
|
551
|
+
),
|
|
552
|
+
"owner diagnostics and supported resource counts required at every sample; missing/malformed/404 remain inconclusive",
|
|
553
|
+
);
|
|
554
|
+
coverage(
|
|
555
|
+
"diagnostics-deltas",
|
|
556
|
+
samples.length > 1 &&
|
|
557
|
+
samples.every((s, i) =>
|
|
558
|
+
i === 0
|
|
559
|
+
? s.diagnosticDelta?.status === "missing-baseline"
|
|
560
|
+
: s.diagnosticDelta?.status === "ok" && s.diagnosticDelta.eventLoopUtilization !== null,
|
|
561
|
+
),
|
|
562
|
+
"adjacent monotonic cumulative counters required after first baseline",
|
|
563
|
+
);
|
|
564
|
+
checks.push(
|
|
565
|
+
boundCheck(
|
|
566
|
+
"diagnostics-identity",
|
|
567
|
+
samples.filter(
|
|
568
|
+
(s) =>
|
|
569
|
+
s.diagnostics?.status === "identity-mismatch" ||
|
|
570
|
+
s.diagnosticDelta?.status === "identity-mismatch",
|
|
571
|
+
).length,
|
|
572
|
+
0,
|
|
573
|
+
"diagnostics must match original daemon identity and PID",
|
|
574
|
+
),
|
|
575
|
+
);
|
|
576
|
+
coverage(
|
|
577
|
+
"load-complete",
|
|
578
|
+
evidence?.loadCompleted,
|
|
579
|
+
"each declared workload completed at least once",
|
|
580
|
+
);
|
|
581
|
+
coverage(
|
|
582
|
+
"log-coverage",
|
|
583
|
+
evidence?.logCoverageComplete,
|
|
584
|
+
"continuous log stream, bookmark, no gap or replay ambiguity",
|
|
585
|
+
);
|
|
586
|
+
coverage("run-evidence", evidence !== undefined, "explicit run and teardown evidence supplied");
|
|
587
|
+
coverage(
|
|
588
|
+
"resource-trend-policy",
|
|
589
|
+
evidence?.resourceTrendPolicyComplete,
|
|
590
|
+
"heap/resources and CPU/spawn trend acceptance policy awaiting calibration and review",
|
|
591
|
+
);
|
|
592
|
+
if (evidence) {
|
|
593
|
+
const requiredFailures = [
|
|
594
|
+
"health",
|
|
595
|
+
"flip",
|
|
596
|
+
"promotion",
|
|
597
|
+
"send",
|
|
598
|
+
"receipt",
|
|
599
|
+
"daemonMissing",
|
|
600
|
+
"daemonExit",
|
|
601
|
+
"ack",
|
|
602
|
+
"pingDeadline",
|
|
603
|
+
"loop",
|
|
604
|
+
"sample",
|
|
605
|
+
];
|
|
606
|
+
for (const name of new Set([...requiredFailures, ...Object.keys(evidence.failures)])) {
|
|
607
|
+
const count = evidence.failures[name];
|
|
608
|
+
checks.push(
|
|
609
|
+
boundCheck(
|
|
610
|
+
name,
|
|
611
|
+
typeof count === "number" && Number.isFinite(count) && count >= 0 ? count : null,
|
|
612
|
+
0,
|
|
613
|
+
"explicit run failure count",
|
|
614
|
+
),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
coverage(
|
|
618
|
+
"reconnect-coverage",
|
|
619
|
+
evidence.reconnectAttempts > 1 &&
|
|
620
|
+
evidence.reconnectAttempts === evidence.reconnectAcknowledged,
|
|
621
|
+
"initial subscription and at least one reconnect acknowledged",
|
|
622
|
+
);
|
|
623
|
+
for (const [id, ok] of [
|
|
624
|
+
["shutdown-clean", evidence.shutdownClean],
|
|
625
|
+
["record-retired", evidence.recordRetired],
|
|
626
|
+
["cleanup-complete", evidence.cleanupComplete],
|
|
627
|
+
] as const)
|
|
628
|
+
checks.push({ id, ok, observed: ok ? 0 : 1, bound: 0, detail: "observed teardown outcome" });
|
|
629
|
+
const trends = soakTrends(samples, evidence.warmupSeconds, evidence.trailingSeconds);
|
|
630
|
+
for (const [window, metrics] of Object.entries(trends)) {
|
|
631
|
+
if (window === "whole") continue;
|
|
632
|
+
const fit = metrics.rssMiBPerHour;
|
|
633
|
+
checks.push(
|
|
634
|
+
boundCheck(
|
|
635
|
+
`rss-${window}`,
|
|
636
|
+
fit && fit.points >= thresholds.minSamplesForFit ? fit.slope : null,
|
|
637
|
+
thresholds.maxRssGrowthMiBPerHour,
|
|
638
|
+
"predeclared window RSS slope MiB/h",
|
|
639
|
+
),
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
385
643
|
const failed = checks.some((check) => check.ok === false);
|
|
386
644
|
const unmeasured = checks.some((check) => check.ok === null);
|
|
387
645
|
return {
|
|
@@ -468,11 +726,23 @@ export function formatSoakReport(result: SoakVerdict): string {
|
|
|
468
726
|
["tmux spawns", `${summary.tmuxSpawnsTotal} total ${num(summary.tmuxSpawnsPerMinute, 2)}/min`],
|
|
469
727
|
[
|
|
470
728
|
"ping rtt ms",
|
|
471
|
-
`p50 ${num(summary.pingRttMs.p50, 2)} max ${num(summary.pingRttMs.max, 2)} n ${summary.pingRttMs.count}`,
|
|
729
|
+
`p50 <= ${num(summary.pingRttMs.p50, 2)} max ${num(summary.pingRttMs.max, 2)} n ${summary.pingRttMs.count}`,
|
|
472
730
|
],
|
|
473
731
|
[
|
|
474
732
|
"receipt ms",
|
|
475
|
-
`p50 ${num(summary.receiptLatencyMs.p50, 0)} max ${num(summary.receiptLatencyMs.max, 0)} n ${summary.receiptLatencyMs.count}`,
|
|
733
|
+
`p50 <= ${num(summary.receiptLatencyMs.p50, 0)} max ${num(summary.receiptLatencyMs.max, 0)} n ${summary.receiptLatencyMs.count}`,
|
|
734
|
+
],
|
|
735
|
+
[
|
|
736
|
+
"owner diagnostics",
|
|
737
|
+
`${summary.diagnostics.validSamples}/${summary.samples} valid; resources unsupported ${summary.diagnostics.unsupportedResourceSamples}`,
|
|
738
|
+
],
|
|
739
|
+
[
|
|
740
|
+
"heap used MiB",
|
|
741
|
+
`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)`,
|
|
742
|
+
],
|
|
743
|
+
[
|
|
744
|
+
"diagnostic CPU",
|
|
745
|
+
`${num(summary.diagnostics.cpuPercent.start)} → ${num(summary.diagnostics.cpuPercent.end)} % interval; policy pending`,
|
|
476
746
|
],
|
|
477
747
|
["observer gaps", String(summary.observerGapWarnings)],
|
|
478
748
|
["daemon restarts", String(summary.daemonRestarts)],
|
|
@@ -492,3 +762,24 @@ export function formatSoakReport(result: SoakVerdict): string {
|
|
|
492
762
|
lines.push(`verdict: ${result.verdict.toUpperCase()}`);
|
|
493
763
|
return lines.join("\n");
|
|
494
764
|
}
|
|
765
|
+
|
|
766
|
+
/** A revision acknowledges exactly the interest set sent in that subscribe. */
|
|
767
|
+
export function validSoakAck(frame: unknown, revision: number): boolean {
|
|
768
|
+
if (!frame || typeof frame !== "object") return false;
|
|
769
|
+
const ack = frame as Record<string, unknown>;
|
|
770
|
+
return (
|
|
771
|
+
ack.type === "resource.interests-ack" &&
|
|
772
|
+
ack.interestRevision === revision &&
|
|
773
|
+
Array.isArray(ack.unavailableInterests) &&
|
|
774
|
+
ack.unavailableInterests.length === 0
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Unsolicited/stale control payloads cannot discharge the current probe. */
|
|
779
|
+
export function correlatedPongRtt(
|
|
780
|
+
probe: { readonly id: string; readonly at: number },
|
|
781
|
+
payload: string,
|
|
782
|
+
now: number,
|
|
783
|
+
): number | null {
|
|
784
|
+
return payload === probe.id && Number.isFinite(now) && now >= probe.at ? now - probe.at : null;
|
|
785
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RendererNeutralColor } from "@tmux-ide/contracts";
|
|
2
|
+
import type { ResolvedThemeMode } from "./theme-mode.ts";
|
|
3
|
+
|
|
4
|
+
function color(red: number, green: number, blue: number): RendererNeutralColor {
|
|
5
|
+
return { space: "srgb", red, green, blue, alpha: 255 };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseTerminalHostColor(
|
|
9
|
+
value: string | null | undefined,
|
|
10
|
+
): RendererNeutralColor | null {
|
|
11
|
+
if (!value) return null;
|
|
12
|
+
const normalized = value.trim().toLowerCase();
|
|
13
|
+
const hex = /^#([\da-f]{3}|[\da-f]{6})$/u.exec(normalized)?.[1];
|
|
14
|
+
if (hex) {
|
|
15
|
+
const expanded =
|
|
16
|
+
hex.length === 3 ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` : hex;
|
|
17
|
+
return color(
|
|
18
|
+
Number.parseInt(expanded.slice(0, 2), 16),
|
|
19
|
+
Number.parseInt(expanded.slice(2, 4), 16),
|
|
20
|
+
Number.parseInt(expanded.slice(4, 6), 16),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
// OSC palette replies may use X11's rgb:RR/GG/BB form with one to four
|
|
24
|
+
// hexadecimal digits per channel. Scale each channel to a byte rather than
|
|
25
|
+
// truncating high-fidelity replies.
|
|
26
|
+
const x11 = /^rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})$/u.exec(normalized);
|
|
27
|
+
if (!x11) return null;
|
|
28
|
+
const channel = (part: string): number => {
|
|
29
|
+
const maximum = 16 ** part.length - 1;
|
|
30
|
+
return Math.round((Number.parseInt(part, 16) / maximum) * 255);
|
|
31
|
+
};
|
|
32
|
+
return color(channel(x11[1]!), channel(x11[2]!), channel(x11[3]!));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Only a reported default background determines host appearance. */
|
|
36
|
+
export function terminalHostMode(background: string | null | undefined): ResolvedThemeMode | null {
|
|
37
|
+
const value = parseTerminalHostColor(background);
|
|
38
|
+
return value
|
|
39
|
+
? 0.299 * value.red + 0.587 * value.green + 0.114 * value.blue > 127.5
|
|
40
|
+
? "light"
|
|
41
|
+
: "dark"
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** Quantized sRGB contrast; Oklab matrices: https://bottosson.github.io/posts/oklab/ . */
|
|
2
|
+
const LINEAR = Float64Array.from({ length: 256 }, (_, n) => {
|
|
3
|
+
const c = n / 255;
|
|
4
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
5
|
+
});
|
|
6
|
+
const luminance = (rgb: number): number =>
|
|
7
|
+
0.2126 * LINEAR[(rgb >>> 16) & 255]! +
|
|
8
|
+
0.7152 * LINEAR[(rgb >>> 8) & 255]! +
|
|
9
|
+
0.0722 * LINEAR[rgb & 255]!;
|
|
10
|
+
export function packedContrastRatio(a: number, b: number): number {
|
|
11
|
+
const x = luminance(a) + 0.05,
|
|
12
|
+
y = luminance(b) + 0.05;
|
|
13
|
+
return Math.max(x, y) / Math.min(x, y);
|
|
14
|
+
}
|
|
15
|
+
function byte(c: number): number {
|
|
16
|
+
return Math.round(
|
|
17
|
+
Math.max(0, Math.min(1, c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)) * 255,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
function candidate(L: number, a: number, b: number): number {
|
|
21
|
+
// Reduce chroma along the same hue ray until inside sRGB, then quantize.
|
|
22
|
+
let lo = 0,
|
|
23
|
+
hi = 1,
|
|
24
|
+
result = -1;
|
|
25
|
+
for (let i = 0; i < 10; i++) {
|
|
26
|
+
const scale = i === 0 ? 1 : (lo + hi) / 2;
|
|
27
|
+
const l = (L + 0.3963377774 * a * scale + 0.2158037573 * b * scale) ** 3;
|
|
28
|
+
const m = (L - 0.1055613458 * a * scale - 0.0638541728 * b * scale) ** 3;
|
|
29
|
+
const s = (L - 0.0894841775 * a * scale - 1.291485548 * b * scale) ** 3;
|
|
30
|
+
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
31
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
32
|
+
const blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
33
|
+
if (Math.min(r, g, blue) >= 0 && Math.max(r, g, blue) <= 1) {
|
|
34
|
+
result = (byte(r) << 16) | (byte(g) << 8) | byte(blue);
|
|
35
|
+
if (i === 0) return result;
|
|
36
|
+
lo = scale;
|
|
37
|
+
} else hi = scale;
|
|
38
|
+
}
|
|
39
|
+
return result < 0 ? byte(L ** 3) * 0x10101 : result;
|
|
40
|
+
}
|
|
41
|
+
/** Leave passing pairs exact. Search both lightness directions with a verified endpoint fallback. */
|
|
42
|
+
export function correctContrastForeground(fg: number, bg: number): number {
|
|
43
|
+
if (packedContrastRatio(fg, bg) >= 4.5) return fg;
|
|
44
|
+
const r = LINEAR[(fg >>> 16) & 255]!,
|
|
45
|
+
g = LINEAR[(fg >>> 8) & 255]!,
|
|
46
|
+
b = LINEAR[fg & 255]!;
|
|
47
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
48
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
49
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
50
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
51
|
+
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
52
|
+
const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
53
|
+
let best = 0,
|
|
54
|
+
distance = Infinity;
|
|
55
|
+
for (const end of [0, 1]) {
|
|
56
|
+
let passing = end,
|
|
57
|
+
failing = L;
|
|
58
|
+
let rgb = end === 0 ? 0 : 0xffffff;
|
|
59
|
+
if (packedContrastRatio(rgb, bg) < 4.5) continue;
|
|
60
|
+
for (let i = 0; i < 12; i++) {
|
|
61
|
+
const mid = (passing + failing) / 2;
|
|
62
|
+
const trial = candidate(mid, a, bb);
|
|
63
|
+
if (packedContrastRatio(trial, bg) >= 4.5) {
|
|
64
|
+
passing = mid;
|
|
65
|
+
rgb = trial;
|
|
66
|
+
} else failing = mid;
|
|
67
|
+
}
|
|
68
|
+
if (Math.abs(passing - L) < distance) {
|
|
69
|
+
best = rgb;
|
|
70
|
+
distance = Math.abs(passing - L);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return best;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Cheap high-color-churn fallback: linear-sRGB blend to the nearer passing endpoint. */
|
|
77
|
+
export function fastContrastForeground(fg: number, bg: number): number {
|
|
78
|
+
if (packedContrastRatio(fg, bg) >= 4.5) return fg;
|
|
79
|
+
const y = luminance(fg),
|
|
80
|
+
base = luminance(bg);
|
|
81
|
+
const darkTarget = (base + 0.05) / 4.5 - 0.05;
|
|
82
|
+
const lightTarget = (base + 0.05) * 4.5 - 0.05;
|
|
83
|
+
const darkMix = darkTarget >= 0 ? (y - darkTarget) / y : Infinity;
|
|
84
|
+
const lightMix = lightTarget <= 1 ? (lightTarget - y) / (1 - y) : Infinity;
|
|
85
|
+
const end = darkMix <= lightMix ? 0 : 1;
|
|
86
|
+
// A small quantization margin usually avoids endpoint fallback. Always verify.
|
|
87
|
+
const amount = Math.min(1, Math.max(0, Math.min(darkMix, lightMix)) + 0.012);
|
|
88
|
+
const r = LINEAR[(fg >>> 16) & 255]!,
|
|
89
|
+
g = LINEAR[(fg >>> 8) & 255]!,
|
|
90
|
+
b = LINEAR[fg & 255]!;
|
|
91
|
+
const result =
|
|
92
|
+
(byte(r + (end - r) * amount) << 16) |
|
|
93
|
+
(byte(g + (end - g) * amount) << 8) |
|
|
94
|
+
byte(b + (end - b) * amount);
|
|
95
|
+
return packedContrastRatio(result, bg) >= 4.5 ? result : end === 0 ? 0 : 0xffffff;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createContrastPairCache(limit = 4096) {
|
|
99
|
+
const pairs = new Map<number, number>();
|
|
100
|
+
let remaining = Infinity;
|
|
101
|
+
return {
|
|
102
|
+
beginFrame() {
|
|
103
|
+
remaining = 128;
|
|
104
|
+
},
|
|
105
|
+
get size() {
|
|
106
|
+
return pairs.size;
|
|
107
|
+
},
|
|
108
|
+
correct(fg: number, bg: number): number {
|
|
109
|
+
const key = fg * 0x1000000 + bg;
|
|
110
|
+
const cached = pairs.get(key);
|
|
111
|
+
if (cached !== undefined) return cached;
|
|
112
|
+
const corrected =
|
|
113
|
+
packedContrastRatio(fg, bg) >= 4.5
|
|
114
|
+
? fg
|
|
115
|
+
: remaining-- > 0
|
|
116
|
+
? correctContrastForeground(fg, bg)
|
|
117
|
+
: fastContrastForeground(fg, bg);
|
|
118
|
+
if (pairs.size >= limit) pairs.clear();
|
|
119
|
+
pairs.set(key, corrected);
|
|
120
|
+
return corrected;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ContrastBuffer {
|
|
126
|
+
readonly buffers: {
|
|
127
|
+
char: Uint32Array;
|
|
128
|
+
fg: Uint16Array;
|
|
129
|
+
bg: Uint16Array;
|
|
130
|
+
attributes: Uint32Array;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const read = (c: Uint16Array, i: number) =>
|
|
134
|
+
((c[i]! & 255) << 16) | ((c[i + 1]! & 255) << 8) | (c[i + 2]! & 255);
|
|
135
|
+
/** Final composed surface only: no canonical state or scheduling; allocation-free cache hits. */
|
|
136
|
+
export function createAutomaticContrastPass() {
|
|
137
|
+
const cache = createContrastPairCache();
|
|
138
|
+
return (buffer: ContrastBuffer): void => {
|
|
139
|
+
cache.beginFrame();
|
|
140
|
+
const { char, fg, bg, attributes } = buffer.buffers;
|
|
141
|
+
for (let cell = 0; cell < char.length; cell++) {
|
|
142
|
+
const cp = char[cell]!,
|
|
143
|
+
attr = attributes[cell]!,
|
|
144
|
+
i = cell * 4;
|
|
145
|
+
// Preserve concealment, whitespace and pixel-art coverage. Grapheme IDs and
|
|
146
|
+
// wide continuations are opaque and retained byte-for-byte.
|
|
147
|
+
if (
|
|
148
|
+
attr & 64 ||
|
|
149
|
+
cp === 0 ||
|
|
150
|
+
cp === 32 ||
|
|
151
|
+
cp === 160 ||
|
|
152
|
+
(cp >= 0x2580 && cp <= 0x259f) ||
|
|
153
|
+
(cp >= 0x2800 && cp <= 0x28ff)
|
|
154
|
+
)
|
|
155
|
+
continue;
|
|
156
|
+
if ((fg[i + 3]! & 255) !== 255 || (bg[i + 3]! & 255) !== 255) continue;
|
|
157
|
+
const foreground = attr & 32 ? bg : fg;
|
|
158
|
+
const background = attr & 32 ? fg : bg;
|
|
159
|
+
const original = read(foreground, i);
|
|
160
|
+
const base = read(background, i);
|
|
161
|
+
// DIM is emulator-dependent. Materialize a deterministic 50% sRGB blend
|
|
162
|
+
// before correction and clear only the final composed DIM bit.
|
|
163
|
+
let visible = original;
|
|
164
|
+
if (attr & 2) {
|
|
165
|
+
visible =
|
|
166
|
+
(Math.round(((original >>> 16) + (base >>> 16)) / 2) << 16) |
|
|
167
|
+
(Math.round((((original >>> 8) & 255) + ((base >>> 8) & 255)) / 2) << 8) |
|
|
168
|
+
Math.round(((original & 255) + (base & 255)) / 2);
|
|
169
|
+
attributes[cell] = attr & ~2;
|
|
170
|
+
}
|
|
171
|
+
const corrected = cache.correct(visible, base);
|
|
172
|
+
if (corrected === original && !(attr & 2)) continue;
|
|
173
|
+
// RGB intent is zero. Indexed/default metadata must not override correction.
|
|
174
|
+
foreground[i] = corrected >>> 16;
|
|
175
|
+
foreground[i + 1] = (corrected >>> 8) & 255;
|
|
176
|
+
foreground[i + 2] = corrected & 255;
|
|
177
|
+
foreground[i + 3] = 255;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|