History

feat: add native resource telemetry

Replace scheduled process shell probes with a persistent native collector, Electron power/process telemetry, Effect services, diagnostics UI, attribution, and release packaging.

Julius Marminge committed 4 months ago

Commit dbd8a8e1 parent 79ad07162 files · +7741 −1957Browse files

Unified · Split

62 files
  1. .github/workflows/ci.yml +12 −0
  2. .github/workflows/release.yml +51 −0
  3. .gitignore +1 −0
  4. apps/desktop/src/app/DesktopAppIdentity.test.ts +1 −0
  5. apps/desktop/src/app/DesktopCloudAuth.test.ts +1 −0
  6. apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +36 −1
  7. apps/desktop/src/backend/DesktopBackendConfiguration.ts +47 −0
  8. apps/desktop/src/backend/DesktopBackendManager.test.ts +6 −0
  9. apps/desktop/src/backend/DesktopBackendManager.ts +8 −0
  10. apps/desktop/src/electron/ElectronApp.ts +2 −0
  11. apps/desktop/src/electron/ElectronPowerMonitor.ts +89 −0
  12. apps/desktop/src/main.ts +4 −0
  13. apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +122 −0
  14. apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +215 −0
  15. apps/desktop/src/window/DesktopApplicationMenu.test.ts +1 −0
  16. apps/server/src/background/HostPowerMonitor.test.ts +44 −0
  17. apps/server/src/background/HostPowerMonitor.ts +26 −150
  18. apps/server/src/cli/config.ts +4 −0
  19. apps/server/src/config.ts +4 −0
  20. apps/server/src/diagnostics/ProcessDiagnostics.test.ts +172 −206
  21. apps/server/src/diagnostics/ProcessDiagnostics.ts +137 −413
  22. apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +112 −211
  23. apps/server/src/diagnostics/ProcessResourceMonitor.ts +44 −271
  24. apps/server/src/observability/Layers/Observability.ts +10 −0
  25. apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +33 −0
  26. apps/server/src/provider/Layers/EventNdjsonLogger.ts +28 −1
  27. apps/server/src/provider/Layers/ProviderEventLoggers.ts +4 −0
  28. apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +279 −0
  29. apps/server/src/resourceTelemetry/Model.test.ts +393 −0
  30. apps/server/src/resourceTelemetry/Model.ts +542 −0
  31. apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +656 −0
  32. apps/server/src/resourceTelemetry/ResourceAttribution.ts +73 −0
  33. apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +80 −0
  34. apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +193 −0
  35. apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts +302 −0
  36. apps/server/src/resourceTelemetry/ResourceTelemetry.ts +372 −0
  37. apps/server/src/resourceTelemetry/ResourceTelemetryStore.ts +253 −0
  38. apps/server/src/server.test.ts +16 −0
  39. apps/server/src/server.ts +31 −4
  40. apps/server/src/ws.ts +31 −0
  41. apps/web/src/components/settings/DiagnosticsSettings.tsx +4 −696
  42. apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +1151 −0
  43. apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +4 −0
  44. apps/web/src/lib/resourceTelemetryState.ts +228 −0
  45. apps/web/src/localApi.ts +12 −0
  46. docs/architecture/overview.md +4 −0
  47. docs/architecture/resource-telemetry.md +335 −0
  48. native/resource-monitor/Cargo.lock +343 −0
  49. native/resource-monitor/Cargo.toml +17 −0
  50. native/resource-monitor/src/main.rs +560 −0
  51. package.json +2 −0
  52. packages/client-runtime/src/wsRpcClient.ts +24 −0
  53. packages/contracts/src/desktopBootstrap.ts +3 −1
  54. packages/contracts/src/index.ts +1 −0
  55. packages/contracts/src/ipc.ts +11 −0
  56. packages/contracts/src/resourceTelemetry.ts +365 −0
  57. packages/contracts/src/rpc.ts +42 −0
  58. packages/contracts/src/server.ts +2 −0
  59. packages/shared/src/observability.test.ts +30 −0
  60. packages/shared/src/observability.ts +38 −3
  61. scripts/build-desktop-artifact.test.ts +24 −0
  62. scripts/build-desktop-artifact.ts +106 −0
.github/workflows/ci.yml+12−0
@@ -22,6 +22,9 @@ jobs: · 21 unmodified lines
2222 cache: true
2323 run-install: true
2424
25+ - name: Setup Rust
26+ uses: dtolnay/rust-toolchain@stable
27+
2528 - name: Ensure Electron runtime is installed
2629 run: vp run --filter @t3tools/desktop ensure:electron
2730
@@ -31,6 +34,9 @@ jobs: · 3 unmodified lines
3134 - name: Typecheck
3235 run: vpr typecheck
3336
37+ - name: Check resource monitor formatting
38+ run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check
39+
3440 - name: Build desktop pipeline
3541 run: vp run build:desktop
3642
@@ -54,12 +60,18 @@ jobs: · 17 unmodified lines
5460 cache: true
5561 run-install: true
5662
63+ - name: Setup Rust
64+ uses: dtolnay/rust-toolchain@stable
65+
5766 - name: Ensure Electron runtime is installed
5867 run: vp run --filter @t3tools/desktop ensure:electron
5968
6069 - name: Test
6170 run: vp run test
6271
72+ - name: Test resource monitor
73+ run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml
74+
6375 test_browser:
6476 name: Test Browser
6577 runs-on: blacksmith-8vcpu-ubuntu-2404
.github/workflows/release.yml+51−0
@@ -272,21 +272,29 @@ jobs: · 271 unmodified lines
272272 platform: mac
273273 target: dmg
274274 arch: arm64
275+ rust_target: aarch64-apple-darwin
276+ resource_key: darwin-arm64
275277 - label: macOS x64
276278 runner: blacksmith-12vcpu-macos-26
277279 platform: mac
278280 target: dmg
279281 arch: x64
282+ rust_target: x86_64-apple-darwin
283+ resource_key: darwin-x64
280284 - label: Linux x64
281285 runner: blacksmith-32vcpu-ubuntu-2404
282286 platform: linux
283287 target: AppImage
284288 arch: x64
289+ rust_target: x86_64-unknown-linux-gnu
290+ resource_key: linux-x64
285291 - label: Windows x64
286292 runner: blacksmith-32vcpu-windows-2025
287293 platform: win
288294 target: nsis
289295 arch: x64
296+ rust_target: x86_64-pc-windows-msvc
297+ resource_key: win32-x64
290298 # - label: Windows arm64
291299 # runner: windows-11-arm
292300 # platform: win
@@ -306,6 +314,11 @@ jobs: · 13 unmodified lines
306314 cache: true
307315 run-install: true
308316
317+ - name: Setup Rust
318+ uses: dtolnay/rust-toolchain@stable
319+ with:
320+ targets: ${{ matrix.rust_target }}
321+
309322 - name: Download relay client tracing config
310323 uses: actions/download-artifact@v8
311324 with:
@@ -518,6 +531,19 @@ jobs: · 206 unmodified lines
518531 # done
519532 # fi
520533
534+ - name: Collect resource monitor
535+ shell: bash
536+ run: |
537+ set -euo pipefail
538+ binary_name="t3-resource-monitor"
539+ if [[ "${{ matrix.platform }}" == "win" ]]; then
540+ binary_name="${binary_name}.exe"
541+ fi
542+ source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}"
543+ target_dir="resource-monitor-publish/${{ matrix.resource_key }}"
544+ mkdir -p "$target_dir"
545+ cp "$source_path" "$target_dir/$binary_name"
546+
521547 - name: Upload build artifacts
522548 uses: actions/upload-artifact@v7
523549 with:
@@ -525,6 +551,13 @@ jobs: · 1 unmodified line
525551 path: release-publish/*
526552 if-no-files-found: error
527553
554+ - name: Upload resource monitor
555+ uses: actions/upload-artifact@v7
556+ with:
557+ name: resource-monitor-${{ matrix.resource_key }}
558+ path: resource-monitor-publish/${{ matrix.resource_key }}/*
559+ if-no-files-found: error
560+
528561 publish_cli:
529562 name: Publish CLI to npm
530563 needs: [preflight, relay_public_config, build]
@@ -579,6 +612,24 @@ jobs: · 48 unmodified lines
579612 - name: Build CLI package
580613 run: vp run --filter t3 build
581614
615+ - name: Download resource monitors
616+ uses: actions/download-artifact@v8
617+ with:
618+ pattern: resource-monitor-*
619+ path: ${{ runner.temp }}/resource-monitors
620+
621+ - name: Bundle resource monitors into CLI package
622+ shell: bash
623+ run: |
624+ set -euo pipefail
625+ for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do
626+ resource_key="${artifact_dir##*/resource-monitor-}"
627+ target_dir="apps/server/dist/resource-monitor/${resource_key}"
628+ mkdir -p "$target_dir"
629+ cp "$artifact_dir"/t3-resource-monitor* "$target_dir/"
630+ chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true
631+ done
632+
582633 - name: Publish CLI package
583634 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
584635
.gitignore+1−0
@@ -27,6 +27,7 @@ squashfs-root/ · 26 unmodified lines
2727 .gstack/
2828 dist-electron/
2929 .electron-runtime/
30+native/**/target/
3031 node_modules/
3132 .alchemy/
3233 *.log
apps/desktop/src/app/DesktopAppIdentity.test.ts+1−0
@@ -54,6 +54,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => · 53 unmodified lines
5454 }),
5555 setAppUserModelId: () => Effect.void,
5656 requestSingleInstanceLock: Effect.succeed(true),
57+ getAppMetrics: Effect.succeed([]),
5758 isDefaultProtocolClient: () => Effect.succeed(false),
5859 setAsDefaultProtocolClient: () => Effect.succeed(true),
5960 setDesktopName: () => Effect.void,
apps/desktop/src/app/DesktopCloudAuth.test.ts+1−0
@@ -54,6 +54,7 @@ function makeHarness(input: { readonly isDevelopment: boolean }): CloudAuthHarne · 53 unmodified lines
5454 setAboutPanelOptions: () => Effect.void,
5555 setAppUserModelId: () => Effect.void,
5656 requestSingleInstanceLock: Effect.succeed(true),
57+ getAppMetrics: Effect.succeed([]),
5758 isDefaultProtocolClient: () => Effect.succeed(false),
5859 setAsDefaultProtocolClient: (protocol, path, args) =>
5960 Effect.sync(() => {
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+36−1
@@ -41,6 +41,7 @@ function makeEnvironmentLayer( · 40 unmodified lines
4141 options?: {
4242 readonly isPackaged?: boolean;
4343 readonly devServerUrl?: string;
44+ readonly resourcesPath?: string;
4445 },
4546 ) {
4647 return DesktopEnvironment.layer({
@@ -51,7 +52,7 @@ function makeEnvironmentLayer( · 4 unmodified lines
5152 appVersion: "1.2.3",
5253 appPath: "/repo",
5354 isPackaged: options?.isPackaged ?? true,
54− resourcesPath: "/missing/resources",
55+ resourcesPath: options?.resourcesPath ?? "/missing/resources",
5556 runningUnderArm64Translation: false,
5657 }).pipe(
5758 Layer.provide(
@@ -192,4 +193,38 @@ describe("DesktopBackendConfiguration", () => { · 134 unmodified lines
192193 );
193194 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
194195 );
196+
197+ it.effect("passes the packaged resource monitor path to the backend", () =>
198+ Effect.gen(function* () {
199+ const fileSystem = yield* FileSystem.FileSystem;
200+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
201+ prefix: "t3-desktop-backend-config-test-",
202+ });
203+ const resourcesPath = `${baseDir}/resources`;
204+ const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`;
205+ yield* fileSystem.makeDirectory(`${resourcesPath}/resource-monitor`, {
206+ recursive: true,
207+ });
208+ yield* fileSystem.writeFileString(monitorPath, "binary");
209+ yield* fileSystem.chmod(monitorPath, 0o755);
210+
211+ yield* Effect.gen(function* () {
212+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
213+ const config = yield* configuration.resolve;
214+ assert.equal(config.bootstrap.resourceMonitorPath, monitorPath);
215+ assert.equal(config.bootstrap.desktopTelemetryFd, 4);
216+ }).pipe(
217+ Effect.provide(
218+ DesktopBackendConfiguration.layer.pipe(
219+ Layer.provideMerge(serverExposureLayer),
220+ Layer.provideMerge(
221+ makeEnvironmentLayer(baseDir, {
222+ resourcesPath,
223+ }),
224+ ),
225+ ),
226+ ),
227+ );
228+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
229+ );
195230 });
apps/desktop/src/backend/DesktopBackendConfiguration.ts+47−0
@@ -56,6 +56,42 @@ const { logWarning: logBackendConfigurationWarning } = DesktopObservability.make · 55 unmodified lines
5656 "desktop-backend-configuration",
5757 );
5858
59+function resourceMonitorBinaryName(platform: NodeJS.Platform): string {
60+ return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
61+}
62+
63+const resolveResourceMonitorPath = Effect.fn(
64+ "desktop.backendConfiguration.resolveResourceMonitorPath",
65+)(function* () {
66+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
67+ const fileSystem = yield* FileSystem.FileSystem;
68+ const binaryName = resourceMonitorBinaryName(environment.platform);
69+ const candidates = environment.isDevelopment
70+ ? [
71+ environment.path.join(
72+ environment.rootDir,
73+ "native/resource-monitor/target/debug",
74+ binaryName,
75+ ),
76+ environment.path.join(
77+ environment.rootDir,
78+ "native/resource-monitor/target/release",
79+ binaryName,
80+ ),
81+ ]
82+ : environment.resolveResourcePathCandidates(
83+ environment.path.join("resource-monitor", binaryName),
84+ );
85+
86+ for (const candidate of candidates) {
87+ if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
88+ return Option.some(candidate);
89+ }
90+ }
91+
92+ return Option.none<string>();
93+});
94+
5995 const readPersistedBackendObservabilitySettings: Effect.Effect<
6096 BackendObservabilitySettings,
6197 never,
@@ -89,6 +125,7 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 27 unmodified lines
89125 function* (input: {
90126 readonly bootstrapToken: string;
91127 readonly observabilitySettings: BackendObservabilitySettings;
128+ readonly resourceMonitorPath: Option.Option<string>;
92129 }): Effect.fn.Return<
93130 DesktopBackendManager.DesktopBackendStartConfig,
94131 never,
@@ -115,6 +152,11 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 20 unmodified lines
115152 desktopBootstrapToken: input.bootstrapToken,
116153 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
117154 tailscaleServePort: backendExposure.tailscaleServePort,
155+ desktopTelemetryFd: 4,
156+ ...Option.match(input.resourceMonitorPath, {
157+ onNone: () => ({}),
158+ onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
159+ }),
118160 ...Option.match(input.observabilitySettings.otlpTracesUrl, {
119161 onNone: () => ({}),
120162 onSome: (otlpTracesUrl) => ({ otlpTracesUrl }),
@@ -156,9 +198,14 @@ export const layer = Layer.effect( · 35 unmodified lines
156198 Effect.provideService(FileSystem.FileSystem, fileSystem),
157199 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
158200 );
201+ const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe(
202+ Effect.provideService(FileSystem.FileSystem, fileSystem),
203+ Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
204+ );
159205 return yield* resolveBackendStartConfig({
160206 bootstrapToken,
161207 observabilitySettings,
208+ resourceMonitorPath,
162209 }).pipe(
163210 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
164211 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
apps/desktop/src/backend/DesktopBackendManager.test.ts+6−0
@@ -23,6 +23,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts"; · 22 unmodified lines
2323 import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
2424 import * as DesktopObservability from "../app/DesktopObservability.ts";
2525 import * as DesktopState from "../app/DesktopState.ts";
26+import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
2627 import * as DesktopWindow from "../window/DesktopWindow.ts";
2728
2829 const decodeDesktopBackendBootstrap = Schema.decodeEffect(
@@ -120,6 +121,11 @@ function makeManagerLayer(input: { · 91 unmodified lines
120121 }),
121122 input.spawnerLayer,
122123 input.httpClientLayer ?? healthyHttpClientLayer,
124+ Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {
125+ latest: Effect.succeed(Option.none()),
126+ changes: Stream.empty,
127+ encoded: Stream.empty,
128+ }),
123129 input.desktopState
124130 ? Layer.succeed(DesktopState.DesktopState, input.desktopState)
125131 : DesktopState.layer,
apps/desktop/src/backend/DesktopBackendManager.ts+8−0
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
2727 import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
2828 import * as DesktopObservability from "../app/DesktopObservability.ts";
2929 import * as DesktopState from "../app/DesktopState.ts";
30+import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
3031 import * as DesktopWindow from "../window/DesktopWindow.ts";
3132
3233 const INITIAL_RESTART_DELAY = Duration.millis(500);
@@ -88,6 +89,7 @@ class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnErro · 55 unmodified lines
8889 type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
8990
9091 interface RunBackendProcessOptions extends DesktopBackendStartConfig {
92+ readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
9193 readonly readinessTimeout?: Duration.Duration;
9294 readonly onStarted?: (pid: number) => Effect.Effect<void>;
9395 readonly onReady?: () => Effect.Effect<void>;
@@ -252,6 +254,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 158 unmodified lines
252254 type: "input",
253255 stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)),
254256 },
257+ fd4: {
258+ type: "input",
259+ stream: options.desktopTelemetryStream,
260+ },
255261 },
256262 },
257263 );
@@ -283,6 +289,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 25 unmodified lines
283289 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
284290 const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog;
285291 const desktopState = yield* DesktopState.DesktopState;
292+ const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
286293 const desktopWindow = yield* DesktopWindow.DesktopWindow;
287294 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
288295 const httpClient = yield* HttpClient.HttpClient;
@@ -436,6 +443,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 147 unmodified lines
436443
437444 const program = runBackendProcess({
438445 ...config.value,
446+ desktopTelemetryStream: desktopTelemetryPublisher.encoded,
439447 onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) {
440448 yield* updateActiveRun(runId, (run) => ({
441449 ...run,
apps/desktop/src/electron/ElectronApp.ts+2−0
@@ -30,6 +30,7 @@ export interface ElectronAppShape { · 29 unmodified lines
3030 ) => Effect.Effect<void>;
3131 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
3232 readonly requestSingleInstanceLock: Effect.Effect<boolean>;
33+ readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;
3334 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
3435 readonly setAsDefaultProtocolClient: (
3536 protocol: string,
@@ -101,6 +102,7 @@ const make = ElectronApp.of({ · 65 unmodified lines
101102 Electron.app.setAppUserModelId(id);
102103 }),
103104 requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),
105+ getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
104106 isDefaultProtocolClient: (protocol) =>
105107 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
106108 setAsDefaultProtocolClient: (protocol, path, args) =>
apps/desktop/src/electron/ElectronPowerMonitor.ts+89−0
@@ -0,0 +1,89 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as Scope from "effect/Scope";
5+
6+import * as Electron from "electron";
7+
8+export type ElectronThermalState = ReturnType<Electron.PowerMonitor["getCurrentThermalState"]>;
9+export type ElectronIdleState = ReturnType<Electron.PowerMonitor["getSystemIdleState"]>;
10+
11+export interface ElectronPowerMonitorShape {
12+ readonly isOnBatteryPower: Effect.Effect<boolean>;
13+ readonly getSystemIdleTime: Effect.Effect<number>;
14+ readonly getSystemIdleState: (idleThresholdSeconds: number) => Effect.Effect<ElectronIdleState>;
15+ readonly getCurrentThermalState: Effect.Effect<ElectronThermalState>;
16+ readonly onSimpleEvent: (
17+ eventName: "lock-screen" | "unlock-screen" | "on-ac" | "on-battery" | "suspend" | "resume",
18+ listener: () => void,
19+ ) => Effect.Effect<void, never, Scope.Scope>;
20+ readonly onThermalStateChange: (
21+ listener: (state: ElectronThermalState) => void,
22+ ) => Effect.Effect<void, never, Scope.Scope>;
23+ readonly onSpeedLimitChange: (
24+ listener: (limit: number) => void,
25+ ) => Effect.Effect<void, never, Scope.Scope>;
26+}
27+
28+export class ElectronPowerMonitor extends Context.Service<
29+ ElectronPowerMonitor,
30+ ElectronPowerMonitorShape
31+>()("@t3tools/desktop/electron/ElectronPowerMonitor") {}
32+
33+const onSimpleEvent: ElectronPowerMonitorShape["onSimpleEvent"] = (eventName, listener) =>
34+ Effect.acquireRelease(
35+ Effect.sync(() => {
36+ Electron.powerMonitor.on(eventName as any, listener as any);
37+ }),
38+ () =>
39+ Effect.sync(() => {
40+ Electron.powerMonitor.removeListener(eventName as any, listener as any);
41+ }),
42+ ).pipe(Effect.asVoid);
43+
44+const onThermalStateChange: ElectronPowerMonitorShape["onThermalStateChange"] = (listener) => {
45+ const wrapped = (
46+ event: Electron.Event<Electron.PowerMonitorThermalStateChangeEventParams>,
47+ ): void => {
48+ listener(event.state);
49+ };
50+ return Effect.acquireRelease(
51+ Effect.sync(() => {
52+ Electron.powerMonitor.on("thermal-state-change", wrapped);
53+ }),
54+ () =>
55+ Effect.sync(() => {
56+ Electron.powerMonitor.removeListener("thermal-state-change", wrapped);
57+ }),
58+ ).pipe(Effect.asVoid);
59+};
60+
61+const onSpeedLimitChange: ElectronPowerMonitorShape["onSpeedLimitChange"] = (listener) => {
62+ const wrapped = (
63+ event: Electron.Event<Electron.PowerMonitorSpeedLimitChangeEventParams>,
64+ ): void => {
65+ listener(event.limit);
66+ };
67+ return Effect.acquireRelease(
68+ Effect.sync(() => {
69+ Electron.powerMonitor.on("speed-limit-change", wrapped);
70+ }),
71+ () =>
72+ Effect.sync(() => {
73+ Electron.powerMonitor.removeListener("speed-limit-change", wrapped);
74+ }),
75+ ).pipe(Effect.asVoid);
76+};
77+
78+export const make = ElectronPowerMonitor.of({
79+ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()),
80+ getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()),
81+ getSystemIdleState: (idleThresholdSeconds) =>
82+ Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)),
83+ getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()),
84+ onSimpleEvent,
85+ onThermalStateChange,
86+ onSpeedLimitChange,
87+});
88+
89+export const layer = Layer.succeed(ElectronPowerMonitor, make);
apps/desktop/src/main.ts+4−0
@@ -19,6 +19,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; · 18 unmodified lines
1919 import * as ElectronApp from "./electron/ElectronApp.ts";
2020 import * as ElectronDialog from "./electron/ElectronDialog.ts";
2121 import * as ElectronMenu from "./electron/ElectronMenu.ts";
22+import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts";
2223 import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
2324 import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts";
2425 import * as ElectronShell from "./electron/ElectronShell.ts";
@@ -44,6 +45,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; · 19 unmodified lines
4445 import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
4546 import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
4647 import * as DesktopState from "./app/DesktopState.ts";
48+import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
4749 import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
4850 import * as PreviewBrowserSession from "./preview/BrowserSession.ts";
4951 import * as PreviewManager from "./preview/Manager.ts";
@@ -103,6 +105,7 @@ const electronLayer = Layer.mergeAll( · 53 unmodified lines
103105 ElectronApp.layer,
104106 ElectronDialog.layer,
105107 ElectronMenu.layer,
108+ ElectronPowerMonitor.layer,
106109 ElectronProtocol.layer,
107110 DesktopSecretStorage.layer,
108111 ElectronShell.layer,
@@ -145,6 +148,7 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( · 36 unmodified lines
145148 const desktopBackendLayer = DesktopBackendManager.layer.pipe(
146149 Layer.provideMerge(DesktopAppIdentity.layer),
147150 Layer.provideMerge(DesktopBackendConfiguration.layer),
151+ Layer.provideMerge(DesktopTelemetryPublisher.layer),
148152 Layer.provideMerge(desktopWindowLayer),
149153 );
150154
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+122−0
@@ -0,0 +1,122 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Duration from "effect/Duration";
3+import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import * as Ref from "effect/Ref";
8+import * as Stream from "effect/Stream";
9+import * as TestClock from "effect/testing/TestClock";
10+
11+import type * as Electron from "electron";
12+
13+import * as ElectronApp from "../electron/ElectronApp.ts";
14+import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
15+import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts";
16+
17+function makeElectronAppLayer(metrics: ReadonlyArray<Electron.ProcessMetric>) {
18+ return Layer.succeed(ElectronApp.ElectronApp, {
19+ metadata: Effect.die("unexpected metadata read"),
20+ name: Effect.succeed("T3 Code"),
21+ whenReady: Effect.void,
22+ quit: Effect.void,
23+ exit: () => Effect.void,
24+ relaunch: () => Effect.void,
25+ setPath: () => Effect.void,
26+ setName: () => Effect.void,
27+ setAboutPanelOptions: () => Effect.void,
28+ setAppUserModelId: () => Effect.void,
29+ requestSingleInstanceLock: Effect.succeed(true),
30+ getAppMetrics: Effect.succeed(metrics),
31+ isDefaultProtocolClient: () => Effect.succeed(false),
32+ setAsDefaultProtocolClient: () => Effect.succeed(true),
33+ setDesktopName: () => Effect.void,
34+ setDockIcon: () => Effect.void,
35+ appendCommandLineSwitch: () => Effect.void,
36+ on: () => Effect.void,
37+ } satisfies ElectronApp.ElectronAppShape);
38+}
39+
40+describe("DesktopTelemetryPublisher", () => {
41+ it.effect("publishes Electron metrics and event-driven power state over NDJSON", () =>
42+ Effect.gen(function* () {
43+ const onBattery = yield* Ref.make(false);
44+ const simpleListeners = new Map<string, () => void>();
45+ let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null =
46+ null;
47+ let speedLimitListener: ((limit: number) => void) | null = null;
48+ const metrics = [
49+ {
50+ pid: 4_242,
51+ type: "Browser",
52+ creationTime: 1_000,
53+ name: "electron",
54+ cpu: {
55+ percentCPUUsage: 12.5,
56+ cumulativeCPUUsage: 3.25,
57+ idleWakeupsPerSecond: 7,
58+ },
59+ memory: {
60+ workingSetSize: 2_048,
61+ peakWorkingSetSize: 4_096,
62+ },
63+ } as Electron.ProcessMetric,
64+ ];
65+ const powerLayer = Layer.succeed(
66+ ElectronPowerMonitor.ElectronPowerMonitor,
67+ ElectronPowerMonitor.ElectronPowerMonitor.of({
68+ isOnBatteryPower: Ref.get(onBattery),
69+ getSystemIdleTime: Effect.succeed(5),
70+ getSystemIdleState: () => Effect.succeed("active"),
71+ getCurrentThermalState: Effect.succeed("nominal"),
72+ onSimpleEvent: (eventName, listener) =>
73+ Effect.sync(() => {
74+ simpleListeners.set(eventName, listener);
75+ }),
76+ onThermalStateChange: (listener) =>
77+ Effect.sync(() => {
78+ thermalListener = listener;
79+ }),
80+ onSpeedLimitChange: (listener) =>
81+ Effect.sync(() => {
82+ speedLimitListener = listener;
83+ }),
84+ }),
85+ );
86+ const layer = DesktopTelemetryPublisher.layer.pipe(
87+ Layer.provide(Layer.mergeAll(makeElectronAppLayer(metrics), powerLayer)),
88+ );
89+
90+ yield* Effect.gen(function* () {
91+ const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
92+ const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect);
93+ const decoder = new TextDecoder();
94+ const messages = Array.from(encoded, (bytes) => JSON.parse(decoder.decode(bytes).trim()));
95+
96+ assert.equal(messages[0]?.type, "desktopTelemetryHello");
97+ assert.equal(messages[0]?.electronPid, process.pid);
98+ assert.equal(messages[1]?.type, "desktopTelemetry");
99+ assert.equal(messages[1]?.electronProcesses[0]?.pid, 4_242);
100+ assert.equal(messages[1]?.electronProcesses[0]?.cpuPercent, 12.5);
101+ assert.equal(messages[1]?.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024);
102+
103+ const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);
104+ yield* Effect.yieldNow;
105+ yield* Ref.set(onBattery, true);
106+ simpleListeners.get("lock-screen")?.();
107+ simpleListeners.get("suspend")?.();
108+ thermalListener?.("serious");
109+ speedLimitListener?.(65);
110+ yield* Effect.yieldNow;
111+ yield* TestClock.adjust(Duration.seconds(1));
112+
113+ const nextSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber));
114+ assert.equal(nextSnapshot.power.locked, "true");
115+ assert.equal(nextSnapshot.power.suspended, true);
116+ assert.equal(nextSnapshot.power.onBattery, "true");
117+ assert.equal(nextSnapshot.power.thermalState, "serious");
118+ assert.equal(Option.getOrNull(nextSnapshot.speedLimitPercent), 65);
119+ }).pipe(Effect.provide(layer));
120+ }),
121+ );
122+});
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts+215−0
@@ -0,0 +1,215 @@
1+import {
2+ DesktopHostTelemetryMessage,
3+ type DesktopHostTelemetrySnapshot,
4+ type HostPowerSnapshot,
5+} from "@t3tools/contracts";
6+import * as Context from "effect/Context";
7+import * as DateTime from "effect/DateTime";
8+import * as Duration from "effect/Duration";
9+import * as Effect from "effect/Effect";
10+import * as Layer from "effect/Layer";
11+import * as Option from "effect/Option";
12+import * as PubSub from "effect/PubSub";
13+import * as Queue from "effect/Queue";
14+import * as Ref from "effect/Ref";
15+import * as Schema from "effect/Schema";
16+import * as Stream from "effect/Stream";
17+
18+import * as ElectronApp from "../electron/ElectronApp.ts";
19+import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
20+
21+const SAMPLE_INTERVAL = Duration.seconds(1);
22+const IDLE_THRESHOLD_SECONDS = 60;
23+const encodeMessage = Schema.encodeSync(Schema.fromJsonString(DesktopHostTelemetryMessage));
24+const textEncoder = new TextEncoder();
25+
26+type PowerEvent =
27+ | { readonly type: "locked"; readonly value: boolean }
28+ | { readonly type: "suspended"; readonly value: boolean }
29+ | { readonly type: "onBattery"; readonly value: boolean }
30+ | { readonly type: "thermal"; readonly value: HostPowerSnapshot["thermalState"] }
31+ | { readonly type: "speedLimit"; readonly value: number };
32+
33+interface PowerState {
34+ readonly locked: HostPowerSnapshot["locked"];
35+ readonly suspended: boolean;
36+ readonly onBattery: HostPowerSnapshot["onBattery"];
37+ readonly thermalState: HostPowerSnapshot["thermalState"];
38+ readonly speedLimitPercent: Option.Option<number>;
39+}
40+
41+export interface DesktopTelemetryPublisherShape {
42+ readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;
43+ readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
44+ readonly encoded: Stream.Stream<Uint8Array>;
45+}
46+
47+export class DesktopTelemetryPublisher extends Context.Service<
48+ DesktopTelemetryPublisher,
49+ DesktopTelemetryPublisherShape
50+>()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {}
51+
52+function booleanState(value: boolean): HostPowerSnapshot["onBattery"] {
53+ return value ? "true" : "false";
54+}
55+
56+function idleState(value: ElectronPowerMonitor.ElectronIdleState): HostPowerSnapshot["idle"] {
57+ switch (value) {
58+ case "active":
59+ return "false";
60+ case "idle":
61+ case "locked":
62+ return "true";
63+ case "unknown":
64+ return "unknown";
65+ }
66+}
67+
68+function updatePowerState(state: PowerState, event: PowerEvent): PowerState {
69+ switch (event.type) {
70+ case "locked":
71+ return { ...state, locked: booleanState(event.value) };
72+ case "suspended":
73+ return { ...state, suspended: event.value };
74+ case "onBattery":
75+ return { ...state, onBattery: booleanState(event.value) };
76+ case "thermal":
77+ return { ...state, thermalState: event.value };
78+ case "speedLimit":
79+ return { ...state, speedLimitPercent: Option.some(event.value) };
80+ }
81+}
82+
83+export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
84+ const electronApp = yield* ElectronApp.ElectronApp;
85+ const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor;
86+ yield* electronApp.whenReady;
87+
88+ const initialPowerState: PowerState = {
89+ locked: "unknown",
90+ suspended: false,
91+ onBattery: booleanState(yield* powerMonitor.isOnBatteryPower),
92+ thermalState: yield* powerMonitor.getCurrentThermalState,
93+ speedLimitPercent: Option.none(),
94+ };
95+ const powerState = yield* Ref.make(initialPowerState);
96+ const powerEvents = yield* Queue.unbounded<PowerEvent>();
97+ const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>());
98+ const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
99+ const sequence = yield* Ref.make(0);
100+
101+ const offer = (event: PowerEvent): void => {
102+ Queue.offerUnsafe(powerEvents, event);
103+ };
104+ yield* Effect.all(
105+ [
106+ powerMonitor.onSimpleEvent("lock-screen", () => offer({ type: "locked", value: true })),
107+ powerMonitor.onSimpleEvent("unlock-screen", () => offer({ type: "locked", value: false })),
108+ powerMonitor.onSimpleEvent("suspend", () => offer({ type: "suspended", value: true })),
109+ powerMonitor.onSimpleEvent("resume", () => offer({ type: "suspended", value: false })),
110+ powerMonitor.onSimpleEvent("on-battery", () => offer({ type: "onBattery", value: true })),
111+ powerMonitor.onSimpleEvent("on-ac", () => offer({ type: "onBattery", value: false })),
112+ powerMonitor.onThermalStateChange((value) => offer({ type: "thermal", value })),
113+ powerMonitor.onSpeedLimitChange((value) => offer({ type: "speedLimit", value })),
114+ ],
115+ { concurrency: "unbounded" },
116+ );
117+ yield* Effect.forever(
118+ Queue.take(powerEvents).pipe(
119+ Effect.flatMap((event) => Ref.update(powerState, (state) => updatePowerState(state, event))),
120+ ),
121+ ).pipe(Effect.forkScoped);
122+
123+ const sampleOnce = Effect.gen(function* () {
124+ const sampledAt = yield* DateTime.now;
125+ const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt);
126+ const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all(
127+ [
128+ Ref.get(powerState),
129+ powerMonitor.getSystemIdleTime,
130+ powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS),
131+ powerMonitor.isOnBatteryPower,
132+ electronApp.getAppMetrics,
133+ ],
134+ { concurrency: "unbounded" },
135+ );
136+ const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]);
137+ const locked = systemIdleState === "locked" ? "true" : currentPower.locked;
138+ const snapshot: DesktopHostTelemetrySnapshot = {
139+ version: 1,
140+ type: "desktopTelemetry",
141+ sequence: nextSequence,
142+ sampledAtUnixMs,
143+ power: {
144+ source: "electron-main",
145+ idle: idleState(systemIdleState),
146+ idleSeconds,
147+ locked,
148+ suspended: currentPower.suspended,
149+ onBattery: booleanState(onBattery),
150+ lowPowerMode: "unknown",
151+ thermalState: currentPower.thermalState,
152+ stale: false,
153+ updatedAt: sampledAt,
154+ },
155+ speedLimitPercent: currentPower.speedLimitPercent,
156+ electronProcesses: metrics.map((metric) => ({
157+ pid: metric.pid,
158+ creationTimeMs: metric.creationTime,
159+ type: metric.type,
160+ ...(metric.name === undefined ? {} : { name: metric.name }),
161+ ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }),
162+ cpuPercent: metric.cpu.percentCPUUsage,
163+ ...(metric.cpu.cumulativeCPUUsage === undefined
164+ ? {}
165+ : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }),
166+ idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond,
167+ workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)),
168+ peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)),
169+ })),
170+ };
171+
172+ yield* Ref.set(latest, Option.some(snapshot));
173+ yield* PubSub.publish(changes, snapshot);
174+ }).pipe(
175+ Effect.catchCause((cause) =>
176+ Effect.logWarning("Failed to sample Electron telemetry", {
177+ cause: String(cause),
178+ }),
179+ ),
180+ );
181+
182+ yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL)))).pipe(
183+ Effect.forkScoped,
184+ );
185+
186+ const snapshots = Stream.concat(
187+ Stream.unwrap(
188+ Ref.get(latest).pipe(
189+ Effect.map(
190+ Option.match({
191+ onNone: () => Stream.empty,
192+ onSome: Stream.make,
193+ }),
194+ ),
195+ ),
196+ ),
197+ Stream.fromPubSub(changes),
198+ );
199+ const encoded = Stream.concat(
200+ Stream.make({
201+ version: 1,
202+ type: "desktopTelemetryHello",
203+ electronPid: process.pid,
204+ } as const),
205+ snapshots,
206+ ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`)));
207+
208+ return DesktopTelemetryPublisher.of({
209+ latest: Ref.get(latest),
210+ changes: Stream.fromPubSub(changes),
211+ encoded,
212+ });
213+});
214+
215+export const layer = Layer.effect(DesktopTelemetryPublisher, make());
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1−0
@@ -40,6 +40,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { · 39 unmodified lines
4040 setAboutPanelOptions: () => Effect.void,
4141 setAppUserModelId: () => Effect.void,
4242 requestSingleInstanceLock: Effect.succeed(true),
43+ getAppMetrics: Effect.succeed([]),
4344 isDefaultProtocolClient: () => Effect.succeed(false),
4445 setAsDefaultProtocolClient: () => Effect.succeed(true),
4546 setDesktopName: () => Effect.void,
apps/server/src/background/HostPowerMonitor.test.ts+44−0
@@ -0,0 +1,44 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as DateTime from "effect/DateTime";
3+import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
5+import * as Option from "effect/Option";
6+import * as Stream from "effect/Stream";
7+
8+import * as HostPowerMonitor from "./HostPowerMonitor.ts";
9+
10+describe("HostPowerMonitor", () => {
11+ it.effect("publishes semantic power changes without idle-time heartbeat churn", () =>
12+ Effect.gen(function* () {
13+ const monitor = yield* HostPowerMonitor.make();
14+ const initial = {
15+ source: "electron-main",
16+ idle: "false",
17+ idleSeconds: 0,
18+ locked: "false",
19+ suspended: false,
20+ onBattery: "false",
21+ lowPowerMode: "unknown",
22+ thermalState: "nominal",
23+ stale: false,
24+ updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"),
25+ } as const;
26+ yield* monitor.report(initial);
27+
28+ const nextChange = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild);
29+ yield* Effect.yieldNow;
30+ yield* monitor.report({
31+ ...initial,
32+ idleSeconds: 1,
33+ updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"),
34+ });
35+ yield* monitor.report({
36+ ...initial,
37+ locked: "true",
38+ updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:02.000Z"),
39+ });
40+
41+ expect(Option.getOrThrow(yield* Fiber.join(nextChange)).locked).toBe("true");
42+ }),
43+ );
44+});
apps/server/src/background/HostPowerMonitor.ts+26−150
@@ -1,24 +1,13 @@
1−import {
2− type BackgroundBooleanState,
3− type HostPowerSnapshot,
4− type HostPowerThermalState,
5−} from "@t3tools/contracts";
6−import {
7− getBackgroundActivityPresetSettings,
8− resolveServerBackgroundActivitySettings,
9−} from "@t3tools/shared/backgroundActivitySettings";
10−import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1+import type { HostPowerSnapshot } from "@t3tools/contracts";
112 import * as Context from "effect/Context";
123 import * as DateTime from "effect/DateTime";
13−import * as Duration from "effect/Duration";
144 import * as Effect from "effect/Effect";
155 import * as Layer from "effect/Layer";
166 import * as PubSub from "effect/PubSub";
177 import * as Ref from "effect/Ref";
188 import * as Stream from "effect/Stream";
199
20−import * as ProcessRunner from "../processRunner.ts";
21−import { ServerSettingsService } from "../serverSettings.ts";
10+import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
2211
2312 export interface HostPowerMonitorShape {
2413 readonly snapshot: Effect.Effect<HostPowerSnapshot>;
@@ -31,8 +20,6 @@ export class HostPowerMonitor extends Context.Service<HostPowerMonitor, HostPowe · 6 unmodified lines
3120 "t3/background/HostPowerMonitor",
3221 ) {}
3322
34−const COMMAND_TIMEOUT = Duration.seconds(3);
35−
3623 export const makeUnknownSnapshot = (
3724 source: HostPowerSnapshot["source"],
3825 updatedAt: HostPowerSnapshot["updatedAt"],
@@ -49,163 +36,52 @@ export const makeUnknownSnapshot = ( · 10 unmodified lines
4936 updatedAt,
5037 });
5138
52−function boolState(value: boolean | null): BackgroundBooleanState {
53− if (value === null) return "unknown";
54− return value ? "true" : "false";
55−}
56−
57−function parseIdleSeconds(ioregOutput: string): number | null {
58− const match = /"HIDIdleTime"\s*=\s*(\d+)/u.exec(ioregOutput);
59− if (!match) return null;
60− const nanoseconds = Number(match[1]);
61− return Number.isFinite(nanoseconds) ? Math.floor(nanoseconds / 1_000_000_000) : null;
62−}
63−
64−function parseOnBattery(pmsetBatteryOutput: string): boolean | null {
65− if (/Now drawing from 'Battery Power'/iu.test(pmsetBatteryOutput)) return true;
66− if (/Now drawing from 'AC Power'/iu.test(pmsetBatteryOutput)) return false;
67− return null;
68−}
69−
70−function parseLowPowerMode(pmsetOutput: string): boolean | null {
71− const match = /^\s*lowpowermode\s+([01])\s*$/imu.exec(pmsetOutput);
72− if (!match) return null;
73− return match[1] === "1";
74−}
75−
76−function parseThermalState(_output: string): HostPowerThermalState {
77− // The stable shell adapter intentionally does not parse `pmset thermlog`;
78− // native adapters can provide this without depending on human-formatted text.
79− return "unknown";
80−}
81−
82−function runOptional(
83− runner: ProcessRunner.ProcessRunnerShape,
84− command: string,
85− args: ReadonlyArray<string>,
86−) {
87− return runner
88− .run({
89− command,
90− args,
91− timeout: COMMAND_TIMEOUT,
92− timeoutBehavior: "timedOutResult",
93− outputMode: "truncate",
94− maxOutputBytes: 32_000,
95− })
96− .pipe(Effect.option);
97−}
98−
99−const readMacShellSnapshot = Effect.fn("background.hostPower.readMacShellSnapshot")(function* () {
100− const runner = yield* ProcessRunner.ProcessRunner;
101− const updatedAt = yield* DateTime.now;
102− const [idleOutput, batteryOutput, pmsetOutput] = yield* Effect.all(
103− [
104− runOptional(runner, "ioreg", ["-c", "IOHIDSystem"]),
105− runOptional(runner, "pmset", ["-g", "batt"]),
106− runOptional(runner, "pmset", ["-g"]),
107− ],
108− { concurrency: "unbounded" },
39+function samePowerState(left: HostPowerSnapshot, right: HostPowerSnapshot): boolean {
40+ return (
41+ left.source === right.source &&
42+ left.idle === right.idle &&
43+ left.locked === right.locked &&
44+ left.suspended === right.suspended &&
45+ left.onBattery === right.onBattery &&
46+ left.lowPowerMode === right.lowPowerMode &&
47+ left.thermalState === right.thermalState &&
48+ left.stale === right.stale
10949 );
110−
111− const idleSeconds = idleOutput._tag === "Some" ? parseIdleSeconds(idleOutput.value.stdout) : null;
112− const onBattery =
113− batteryOutput._tag === "Some" ? parseOnBattery(batteryOutput.value.stdout) : null;
114− const lowPowerMode =
115− pmsetOutput._tag === "Some" ? parseLowPowerMode(pmsetOutput.value.stdout) : null;
116−
117− return {
118− source: "node-macos-shell",
119− idle: boolState(idleSeconds === null ? null : idleSeconds >= 60),
120− idleSeconds,
121− locked: "unknown",
122− suspended: false,
123− onBattery: boolState(onBattery),
124− lowPowerMode: boolState(lowPowerMode),
125− thermalState: parseThermalState(""),
126− stale: false,
127− updatedAt,
128− } satisfies HostPowerSnapshot;
129−});
50+}
13051
13152 export const make = Effect.fn("background.hostPower.make")(function* (
13253 initialSource: HostPowerSnapshot["source"] = "unknown",
13354 ) {
13455 const initial = makeUnknownSnapshot(initialSource, yield* DateTime.now);
13556 const latestRef = yield* Ref.make(initial);
136− const demandActiveRef = yield* Ref.make(false);
13757 const changes = yield* PubSub.sliding<HostPowerSnapshot>(1);
13858
13959 const report: HostPowerMonitorShape["report"] = (snapshot) =>
140− Ref.set(latestRef, snapshot).pipe(
141− Effect.andThen(PubSub.publish(changes, snapshot)),
60+ Ref.modify(latestRef, (current) => [!samePowerState(current, snapshot), snapshot]).pipe(
61+ Effect.flatMap((changed) => (changed ? PubSub.publish(changes, snapshot) : Effect.void)),
14262 Effect.asVoid,
14363 );
14464
14565 return HostPowerMonitor.of({
14666 snapshot: Ref.get(latestRef),
14767 report,
148− setDemandActive: (active) => Ref.set(demandActiveRef, active),
68+ setDemandActive: () => Effect.void,
14969 streamChanges: Stream.fromPubSub(changes),
15070 });
15171 });
15272
153−const unknownLayer = Layer.effect(HostPowerMonitor, make("unknown"));
154−const linuxLayer = Layer.effect(HostPowerMonitor, make("node-linux"));
155−const windowsLayer = Layer.effect(HostPowerMonitor, make("node-windows"));
156−
157−const macShellLayer = Layer.effect(
73+export const layer = Layer.effect(
15874 HostPowerMonitor,
15975 Effect.gen(function* () {
160− const serverSettings = yield* ServerSettingsService;
161− const monitor = yield* make("node-macos-shell");
162− const demandActiveRef = yield* Ref.make(true);
163− const setDemandActive: HostPowerMonitorShape["setDemandActive"] = (active) =>
164− Ref.set(demandActiveRef, active);
165− const getPollInterval = Effect.gen(function* () {
166− const demandActive = yield* Ref.get(demandActiveRef);
167− const settings = yield* serverSettings.getSettings.pipe(
168− Effect.map(resolveServerBackgroundActivitySettings),
169− Effect.catch(() => Effect.succeed(getBackgroundActivityPresetSettings("balanced"))),
170− );
171− return demandActive
172− ? settings.hostPowerMonitorActiveInterval
173− : settings.hostPowerMonitorIdleInterval;
174− });
175− const adaptiveMonitor = HostPowerMonitor.of({
176− snapshot: monitor.snapshot,
177− report: monitor.report,
178− setDemandActive,
179− streamChanges: monitor.streamChanges,
180− });
181− yield* readMacShellSnapshot().pipe(
182− Effect.flatMap(adaptiveMonitor.report),
183− Effect.ignoreCause({ log: true }),
76+ const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
77+ const initial = yield* telemetry.latest;
78+ const monitor = yield* make(initial.power.source);
79+ yield* monitor.report(initial.power);
80+ yield* telemetry.changes.pipe(
81+ Stream.map((snapshot) => snapshot.power),
82+ Stream.runForEach(monitor.report),
83+ Effect.forkScoped,
18484 );
185− yield* Effect.forever(
186− getPollInterval.pipe(
187− Effect.flatMap((interval) => Effect.sleep(Duration.max(interval, Duration.seconds(5)))),
188− Effect.andThen(readMacShellSnapshot()),
189− Effect.flatMap(adaptiveMonitor.report),
190− Effect.ignoreCause({ log: true }),
191− ),
192− ).pipe(Effect.forkScoped);
193− return adaptiveMonitor;
194− }),
195−).pipe(Layer.provide(ProcessRunner.layer));
196−
197−export const layer = Layer.unwrap(
198− Effect.gen(function* () {
199− const platform = yield* HostProcessPlatform;
200− switch (platform) {
201− case "darwin":
202− return macShellLayer;
203− case "linux":
204− return linuxLayer;
205− case "win32":
206− return windowsLayer;
207− default:
208− return unknownLayer;
209− }
85+ return monitor;
21086 }),
21187 );
apps/server/src/cli/config.ts+4−0
@@ -298,6 +298,8 @@ export const resolveServerConfig = ( · 297 unmodified lines
298298 () => mode === "desktop",
299299 );
300300 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
301+ const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
302+ const resourceMonitorPath = bootstrap?.resourceMonitorPath;
301303 const autoBootstrapProjectFromCwd = Option.getOrElse(
302304 resolveOptionPrecedence(
303305 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
@@ -370,6 +372,8 @@ export const resolveServerConfig = ( · 66 unmodified lines
370372 noBrowser,
371373 startupPresentation,
372374 desktopBootstrapToken,
375+ desktopTelemetryFd,
376+ resourceMonitorPath,
373377 autoBootstrapProjectFromCwd,
374378 logWebSocketEvents,
375379 tailscaleServeEnabled,
apps/server/src/config.ts+4−0
@@ -69,6 +69,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { · 68 unmodified lines
6969 readonly noBrowser: boolean;
7070 readonly startupPresentation: StartupPresentation;
7171 readonly desktopBootstrapToken: string | undefined;
72+ readonly desktopTelemetryFd?: number | undefined;
73+ readonly resourceMonitorPath?: string | undefined;
7274 readonly autoBootstrapProjectFromCwd: boolean;
7375 readonly logWebSocketEvents: boolean;
7476 readonly tailscaleServeEnabled: boolean;
@@ -171,6 +173,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap · 96 unmodified lines
171173 port: 0,
172174 host: undefined,
173175 desktopBootstrapToken: undefined,
176+ desktopTelemetryFd: undefined,
177+ resourceMonitorPath: undefined,
174178 staticDir: undefined,
175179 devUrl,
176180 noBrowser: false,
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+172−206
@@ -1,251 +1,217 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import type {
3+ DesktopHostTelemetrySnapshot,
4+ ResourceMonitorSnapshotEvent,
5+} from "@t3tools/contracts";
26 import * as DateTime from "effect/DateTime";
37 import * as Effect from "effect/Effect";
48 import * as Layer from "effect/Layer";
59 import * as Option from "effect/Option";
6−import * as Sink from "effect/Sink";
7−import * as Stream from "effect/Stream";
8−import { ChildProcessSpawner } from "effect/unstable/process";
910
11+import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
12+import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
13+import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts";
14+import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
1015 import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
1116
12−const encoder = new TextEncoder();
17+function makeNativeSnapshot(
18+ processes: ResourceMonitorSnapshotEvent["processes"],
19+): ResourceMonitorSnapshotEvent {
20+ return {
21+ version: 1,
22+ type: "snapshot",
23+ sequence: 1,
24+ sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")),
25+ collectionDurationMicros: 250,
26+ scannedProcessCount: processes.length,
27+ retainedProcessCount: processes.length,
28+ inaccessibleProcessCount: 0,
29+ processes,
30+ };
31+}
1332
14−function mockHandle(result: {
15− readonly stdout?: string;
16− readonly stderr?: string;
17− readonly code?: number;
18−}) {
19− return ChildProcessSpawner.makeHandle({
20− pid: ChildProcessSpawner.ProcessId(1),
21− exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),
22− isRunning: Effect.succeed(false),
23− kill: () => Effect.void,
24− unref: Effect.succeed(Effect.void),
25− stdin: Sink.drain,
26− stdout: Stream.make(encoder.encode(result.stdout ?? "")),
27− stderr: Stream.make(encoder.encode(result.stderr ?? "")),
28− all: Stream.empty,
29− getInputFd: () => Sink.drain,
30− getOutputFd: () => Stream.empty,
33+function makeTelemetryLayer(
34+ snapshot: ResourceMonitorSnapshotEvent,
35+ desktopSnapshot?: DesktopHostTelemetrySnapshot,
36+) {
37+ const nativeLayer = NativeTelemetryClient.layerTest({
38+ sampleNow: Effect.succeed(snapshot),
39+ health: Effect.succeed({
40+ status: "healthy",
41+ hello: Option.none(),
42+ lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)),
43+ lastError: Option.none(),
44+ restartCount: 0,
45+ }),
3146 });
47+ const desktopLayer = desktopSnapshot
48+ ? DesktopTelemetryReceiver.layerTest({
49+ latest: Effect.succeedSome(desktopSnapshot),
50+ health: Effect.succeed({
51+ status: "healthy",
52+ lastSampleAt: Option.some(DateTime.makeUnsafe(desktopSnapshot.sampledAtUnixMs)),
53+ lastError: Option.none(),
54+ }),
55+ })
56+ : DesktopTelemetryReceiver.layerTest();
57+ return ResourceTelemetry.layer.pipe(
58+ Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)),
59+ );
3260 }
3361
3462 describe("ProcessDiagnostics", () => {
35− it.effect("parses POSIX ps rows with full commands", () =>
36− Effect.sync(() => {
37− const rows = ProcessDiagnostics.parsePosixProcessRows(
38− [
39− " 10 1 10 Ss 0.0 1024 01:02.03 /usr/bin/node server.js",
40− " 11 10 10 S+ 12.5 20480 00:04 codex app-server --config /tmp/one two",
41− ].join("\n"),
42− );
43−
44− expect(rows).toEqual([
63+ it.effect("projects live process data from resource telemetry", () =>
64+ Effect.gen(function* () {
65+ const snapshot = makeNativeSnapshot([
4566 {
46− pid: 10,
67+ pid: process.pid,
4768 ppid: 1,
48− pgid: 10,
49− status: "Ss",
69+ startTimeMs: 1_000,
70+ runTimeMs: 60_000,
71+ name: "node",
72+ command: "t3 server",
73+ status: "Running",
5074 cpuPercent: 0,
51− rssBytes: 1024 * 1024,
52− elapsed: "01:02.03",
53− command: "/usr/bin/node server.js",
75+ cpuTimeMs: 100,
76+ residentBytes: 1_024,
77+ virtualBytes: 2_048,
78+ ioReadBytes: 100,
79+ ioWriteBytes: 200,
80+ ioSemantics: "storage",
5481 },
5582 {
56− pid: 11,
57− ppid: 10,
58− pgid: 10,
59− status: "S+",
60− cpuPercent: 12.5,
61− rssBytes: 20480 * 1024,
62− elapsed: "00:04",
63− command: "codex app-server --config /tmp/one two",
83+ pid: 4_242,
84+ ppid: process.pid,
85+ startTimeMs: 2_000,
86+ runTimeMs: 4_000,
87+ name: "agent",
88+ command: "codex app-server",
89+ status: "Running",
90+ cpuPercent: 1.5,
91+ cpuTimeMs: 60,
92+ residentBytes: 2_048,
93+ virtualBytes: 4_096,
94+ ioReadBytes: 300,
95+ ioWriteBytes: 400,
96+ ioSemantics: "storage",
6497 },
6598 ]);
66− }),
67− );
99+ const telemetryLayer = makeTelemetryLayer(snapshot);
100+ const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer));
68101
69− it.effect("aggregates only descendants of the server process", () =>
70− Effect.sync(() => {
71− const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
72− serverPid: 100,
73− readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
74− rows: [
75− {
76− pid: 100,
77− ppid: 1,
78− pgid: 100,
79− status: "S",
80− cpuPercent: 0,
81− rssBytes: 1_000,
82− elapsed: "01:00",
83− command: "t3 server",
84− },
85− {
86− pid: 101,
87− ppid: 100,
88− pgid: 100,
89− status: "S",
90− cpuPercent: 1.5,
91− rssBytes: 2_000,
92− elapsed: "00:20",
93− command: "codex app-server",
94− },
95− {
96− pid: 102,
97− ppid: 101,
98− pgid: 100,
99− status: "R",
100− cpuPercent: 3.25,
101− rssBytes: 4_000,
102− elapsed: "00:05",
103− command: "git status",
104− },
105− {
106− pid: 200,
107− ppid: 1,
108− pgid: 200,
109− status: "S",
110− cpuPercent: 99,
111− rssBytes: 8_000,
112− elapsed: "00:01",
113− command: "unrelated",
114− },
115− {
116− pid: 201,
117− ppid: 100,
118− pgid: 100,
119− status: "R",
120− cpuPercent: 9,
121− rssBytes: 9_000,
122− elapsed: "00:00",
123− command: "ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=",
124− },
125− ],
126− });
127−
128− expect(diagnostics.serverPid).toBe(100);
129− expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z");
130− expect(diagnostics.processCount).toBe(2);
131− expect(diagnostics.totalRssBytes).toBe(6_000);
132− expect(diagnostics.totalCpuPercent).toBe(4.75);
133− expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]);
134− expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]);
135− expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100);
136− expect(diagnostics.processes[0]?.childPids).toEqual([102]);
137− }),
138− );
139−
140− it.effect("preserves ascending sibling order for nested descendants", () =>
141− Effect.sync(() => {
142− const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
143− serverPid: 100,
144− readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
145− rows: [
146− {
147− pid: 101,
148− ppid: 100,
149− pgid: 100,
150− status: "S",
151− cpuPercent: 0,
152− rssBytes: 100,
153− elapsed: "00:10",
154− command: "agent",
155− },
156− {
157− pid: 103,
158− ppid: 101,
159− pgid: 100,
160− status: "S",
161− cpuPercent: 0,
162− rssBytes: 100,
163− elapsed: "00:10",
164− command: "child-b",
165− },
166− {
167− pid: 102,
168− ppid: 101,
169− pgid: 100,
170− status: "S",
171− cpuPercent: 0,
172− rssBytes: 100,
173− elapsed: "00:10",
174− command: "child-a",
175− },
176− ],
177− });
102+ const diagnostics = yield* Effect.gen(function* () {
103+ const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
104+ const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
105+ yield* telemetry.refresh;
106+ return yield* processDiagnostics.read;
107+ }).pipe(Effect.provide(layer));
178108
179− expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]);
109+ expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
110+ expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000);
111+ expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5);
112+ expect(diagnostics.processes[0]?.rssBytes).toBe(2_048);
180113 }),
181114 );
182115
183− it.effect("queries processes through the ChildProcessSpawner service", () =>
116+ it.effect("rejects stale process identities before signaling", () =>
184117 Effect.gen(function* () {
185− const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
186− [];
187− const spawnerLayer = Layer.succeed(
188− ChildProcessSpawner.ChildProcessSpawner,
189− ChildProcessSpawner.make((command) => {
190− const childProcess = command as unknown as {
191− readonly command: string;
192− readonly args: ReadonlyArray<string>;
193− };
194− commands.push({ command: childProcess.command, args: childProcess.args });
195− return Effect.succeed(
196− mockHandle({
197− stdout: [
198− ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`,
199− ` 4242 ${process.pid} ${process.pid} S 1.5 2048 00:04 agent`,
200− ].join("\n"),
201− }),
202− );
203− }),
204− );
205− const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer));
118+ const snapshot = makeNativeSnapshot([]);
119+ const telemetryLayer = makeTelemetryLayer(snapshot);
120+ const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
206121
207− const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
208− Effect.flatMap((pd) => pd.read),
122+ const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
123+ Effect.flatMap((processDiagnostics) =>
124+ processDiagnostics.signal({
125+ pid: 4_242,
126+ startTimeMs: 2_000,
127+ signal: "SIGINT",
128+ }),
129+ ),
209130 Effect.provide(layer),
210131 );
211132
212− expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
213− expect(commands).toEqual([
214− {
215− command: "ps",
216− args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],
217− },
218− ]);
133+ expect(result).toEqual({
134+ pid: 4242,
135+ signal: "SIGINT",
136+ signaled: false,
137+ message: Option.some("Process 4242 no longer matches the selected process identity."),
138+ });
219139 }),
220140 );
221141
222− it.effect("does not allow signaling the diagnostics query process", () =>
142+ it.effect("rejects Electron processes as signal targets", () =>
223143 Effect.gen(function* () {
224− const spawnerLayer = Layer.succeed(
225− ChildProcessSpawner.ChildProcessSpawner,
226− ChildProcessSpawner.make(() =>
227− Effect.succeed(
228− mockHandle({
229− stdout: [
230− ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`,
231− ` 4242 ${process.pid} ${process.pid} R 1.5 2048 00:00 ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=`,
232− ].join("\n"),
233− }),
234− ),
235− ),
144+ const sampledAtUnixMs = DateTime.toEpochMillis(
145+ DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
236146 );
237− const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer));
147+ const snapshot = makeNativeSnapshot([
148+ {
149+ pid: 4_242,
150+ ppid: 1,
151+ startTimeMs: 2_000,
152+ runTimeMs: 4_000,
153+ name: "electron",
154+ command: "electron",
155+ status: "Running",
156+ cpuPercent: 1.5,
157+ cpuTimeMs: 60,
158+ residentBytes: 2_048,
159+ virtualBytes: 4_096,
160+ ioReadBytes: 300,
161+ ioWriteBytes: 400,
162+ ioSemantics: "storage",
163+ },
164+ ]);
165+ const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
166+ const telemetryLayer = makeTelemetryLayer(snapshot, {
167+ version: 1,
168+ type: "desktopTelemetry",
169+ sequence: 1,
170+ sampledAtUnixMs,
171+ power: {
172+ source: "electron-main",
173+ idle: "false",
174+ idleSeconds: 0,
175+ locked: "false",
176+ suspended: false,
177+ onBattery: "false",
178+ lowPowerMode: "unknown",
179+ thermalState: "nominal",
180+ stale: false,
181+ updatedAt: sampledAt,
182+ },
183+ speedLimitPercent: Option.none(),
184+ electronProcesses: [
185+ {
186+ pid: 4_242,
187+ creationTimeMs: 2_000,
188+ type: "Browser",
189+ name: "electron",
190+ cpuPercent: 1.5,
191+ idleWakeupsPerSecond: 0,
192+ workingSetBytes: 2_048,
193+ peakWorkingSetBytes: 2_048,
194+ },
195+ ],
196+ });
197+ const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
238198
239199 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
240− Effect.flatMap((pd) => pd.signal({ pid: 4242, signal: "SIGINT" })),
200+ Effect.flatMap((processDiagnostics) =>
201+ processDiagnostics.signal({
202+ pid: 4_242,
203+ startTimeMs: 2_000,
204+ signal: "SIGKILL",
205+ }),
206+ ),
241207 Effect.provide(layer),
242208 );
243209
244210 expect(result).toEqual({
245− pid: 4242,
246− signal: "SIGINT",
211+ pid: 4_242,
212+ signal: "SIGKILL",
247213 signaled: false,
248− message: Option.some("Process 4242 is not a live descendant of the T3 server."),
214+ message: Option.some("Process 4242 is not a signalable T3 backend descendant."),
249215 });
250216 }),
251217 );
apps/server/src/diagnostics/ProcessDiagnostics.ts+137−413
@@ -1,40 +1,23 @@
11 import type {
2+ ResourceTelemetryProcessCategory,
23 ServerProcessDiagnosticsEntry,
34 ServerProcessDiagnosticsResult,
45 ServerProcessSignal,
56 ServerSignalProcessResult,
67 } from "@t3tools/contracts";
7−import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
88 import * as Context from "effect/Context";
9−import * as DateTime from "effect/DateTime";
10−import * as Duration from "effect/Duration";
119 import * as Effect from "effect/Effect";
1210 import * as Layer from "effect/Layer";
1311 import * as Option from "effect/Option";
1412 import * as Schema from "effect/Schema";
15−import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1613
17−import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
18−
19−export interface ProcessRow {
20− readonly pid: number;
21− readonly ppid: number;
22− readonly pgid: number | null;
23− readonly status: string;
24− readonly cpuPercent: number;
25− readonly rssBytes: number;
26− readonly elapsed: string;
27− readonly command: string;
28−}
29−
30−const PROCESS_QUERY_TIMEOUT_MS = 1_000;
31−const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
32−const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
14+import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
3315
3416 export interface ProcessDiagnosticsShape {
3517 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
3618 readonly signal: (input: {
3719 readonly pid: number;
20+ readonly startTimeMs: number;
3821 readonly signal: ServerProcessSignal;
3922 }) => Effect.Effect<ServerSignalProcessResult>;
4023 }
@@ -44,420 +27,161 @@ export class ProcessDiagnostics extends Context.Service< · 3 unmodified lines
4427 ProcessDiagnosticsShape
4528 >()("t3/diagnostics/ProcessDiagnostics") {}
4629
47−class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnosticsError>()(
48− "ProcessDiagnosticsError",
30+export class ProcessIdentityChanged extends Schema.TaggedErrorClass<ProcessIdentityChanged>()(
31+ "ProcessIdentityChanged",
4932 {
50− message: Schema.String,
51− cause: Schema.optional(Schema.Defect()),
33+ pid: Schema.Number,
34+ startTimeMs: Schema.Number,
5235 },
53−) {}
54−const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
55−
56−function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError {
57− return new ProcessDiagnosticsError({
58− message,
59− ...(cause === undefined ? {} : { cause }),
60− });
61−}
62−
63−function parsePositiveInt(value: string): number | null {
64− const parsed = Number.parseInt(value, 10);
65− return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
66−}
67−
68−function parseNonNegativeInt(value: string): number | null {
69− const parsed = Number.parseInt(value, 10);
70− return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
71−}
72−
73−function parseNumber(value: string): number | null {
74− const parsed = Number.parseFloat(value);
75− return Number.isFinite(parsed) ? parsed : null;
76−}
77−
78−export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
79− const rows: ProcessRow[] = [];
80− const rowPattern =
81− /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+([+-]?(?:\d+\.?\d*|\.\d+))\s+(\d+)\s+(\S+)\s+(.+)$/;
82−
83− for (const line of output.split(/\r?\n/)) {
84− if (line.trim().length === 0) continue;
85−
86− const match = rowPattern.exec(line);
87− if (!match) continue;
88−
89− const pidText = match[1];
90− const ppidText = match[2];
91− const pgidText = match[3];
92− const status = match[4];
93− const cpuText = match[5];
94− const rssText = match[6];
95− const elapsed = match[7];
96− const command = match[8];
97− if (
98− pidText === undefined ||
99− ppidText === undefined ||
100− pgidText === undefined ||
101− status === undefined ||
102− cpuText === undefined ||
103− rssText === undefined ||
104− elapsed === undefined ||
105− command === undefined
106− ) {
107− continue;
108− }
109−
110− const pid = parsePositiveInt(pidText);
111− const ppid = parseNonNegativeInt(ppidText);
112− const pgid = Number.parseInt(pgidText, 10);
113− const cpuPercent = parseNumber(cpuText);
114− const rssKiB = parseNonNegativeInt(rssText);
115− if (
116− pid === null ||
117− ppid === null ||
118− !Number.isInteger(pgid) ||
119− cpuPercent === null ||
120− rssKiB === null ||
121− !status ||
122− !elapsed ||
123− !command
124− ) {
125− continue;
126− }
127−
128− rows.push({
129− pid,
130− ppid,
131− pgid,
132− status,
133− cpuPercent,
134− rssBytes: rssKiB * 1024,
135− elapsed,
136− command,
137− });
36+) {
37+ override get message(): string {
38+ return `Process ${this.pid} no longer matches start time ${this.startTimeMs}.`;
13839 }
139−
140− return rows;
141−}
142−
143−function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
144− if (typeof value !== "object" || value === null) return null;
145− const record = value as Record<string, unknown>;
146− const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
147− const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
148− const commandLine =
149− typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0
150− ? record.CommandLine
151− : typeof record.Name === "string"
152− ? record.Name
153− : null;
154− const workingSet =
155− typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize)
156− ? Math.max(0, Math.round(record.WorkingSetSize))
157− : 0;
158− const cpuPercent =
159− typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime)
160− ? Math.max(0, record.PercentProcessorTime)
161− : 0;
162−
163− if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null;
164− return {
165− pid,
166− ppid,
167− pgid: null,
168− status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live",
169− cpuPercent,
170− rssBytes: workingSet,
171− elapsed: "",
172− command: commandLine,
173− };
17440 }
17541
176−function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
177− if (output.trim().length === 0) return [];
178− try {
179− const parsed = JSON.parse(output) as unknown;
180− const records = Array.isArray(parsed) ? parsed : [parsed];
181− return records.flatMap((record) => {
182− const row = normalizeWindowsProcessRow(record);
183− return row ? [row] : [];
184− });
185− } catch {
186− return [];
42+export class ProcessSignalFailed extends Schema.TaggedErrorClass<ProcessSignalFailed>()(
43+ "ProcessSignalFailed",
44+ {
45+ pid: Schema.Number,
46+ signal: Schema.String,
47+ cause: Schema.Defect(),
48+ },
49+) {
50+ override get message(): string {
51+ return `Failed to signal process ${this.pid} with ${this.signal}.`;
18752 }
18853 }
18954
190−export function buildDescendantEntries(
191− rows: ReadonlyArray<ProcessRow>,
192− serverPid: number,
193−): ReadonlyArray<ServerProcessDiagnosticsEntry> {
194− const childrenByParent = new Map<number, ProcessRow[]>();
195− for (const row of rows) {
196− const children = childrenByParent.get(row.ppid) ?? [];
197− children.push(row);
198− childrenByParent.set(row.ppid, children);
199− }
200−
201− const entries: ServerProcessDiagnosticsEntry[] = [];
202− const visited = new Set<number>();
203− const stack = [...(childrenByParent.get(serverPid) ?? [])]
204− .toSorted((left, right) => left.pid - right.pid)
205− .map((row) => ({ row, depth: 0 }));
206−
207− while (stack.length > 0) {
208− const item = stack.shift();
209− if (!item || visited.has(item.row.pid)) continue;
210− visited.add(item.row.pid);
211−
212− const children = [...(childrenByParent.get(item.row.pid) ?? [])].toSorted(
213− (left, right) => left.pid - right.pid,
214− );
215− entries.push({
216− pid: item.row.pid,
217− ppid: item.row.ppid,
218− pgid: Option.fromNullishOr(item.row.pgid),
219− status: item.row.status,
220− cpuPercent: item.row.cpuPercent,
221− rssBytes: item.row.rssBytes,
222− elapsed: item.row.elapsed || "n/a",
223− command: item.row.command,
224− depth: item.depth,
225− childPids: children.map((child) => child.pid),
226− });
227−
228− stack.unshift(...children.map((row) => ({ row, depth: item.depth + 1 })));
229− }
55+export type ProcessDiagnosticsError = ProcessIdentityChanged | ProcessSignalFailed;
23056
231− return entries;
57+function formatElapsed(runTimeMs: number): string {
58+ const totalSeconds = Math.max(0, Math.floor(runTimeMs / 1_000));
59+ const hours = Math.floor(totalSeconds / 3_600);
60+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
61+ const seconds = totalSeconds % 60;
62+ return hours > 0
63+ ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
64+ : `${minutes}:${String(seconds).padStart(2, "0")}`;
23265 }
23366
234−export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean {
235− if (row.ppid !== serverPid) return false;
236−
237− const command = row.command.trim();
67+function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean {
23868 return (
239− /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) ||
240− (/\bpowershell(?:\.exe)?\b/i.test(command) &&
241− /\bGet-CimInstance\s+Win32_Process\b/i.test(command))
242− );
243−}
244−
245−function makeResult(input: {
246− readonly serverPid: number;
247− readonly rows: ReadonlyArray<ProcessRow>;
248− readonly readAt: DateTime.Utc;
249− readonly error?: string;
250−}): ServerProcessDiagnosticsResult {
251− const readAt = input.readAt;
252− const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid));
253− const processes = buildDescendantEntries(rows, input.serverPid);
254− const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0);
255− const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0);
256−
257− return {
258− serverPid: input.serverPid,
259− readAt,
260− processCount: processes.length,
261− totalRssBytes,
262− totalCpuPercent,
263− processes,
264− error: input.error ? Option.some({ message: input.error }) : Option.none(),
265− };
266−}
267−
268−interface ProcessOutput {
269− readonly exitCode: number;
270− readonly stdout: string;
271− readonly stderr: string;
272−}
273−
274−const runProcess = Effect.fn("runProcess")(
275− function* (input: {
276− readonly command: string;
277− readonly args: ReadonlyArray<string>;
278− readonly errorMessage: string;
279− }) {
280− const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
281− // `ps` and `powershell.exe` are real executables; spawning through cmd.exe
282− // shell mode would re-tokenize the PowerShell `-Command` payload (which
283− // contains pipes) before PowerShell ever sees it.
284− const child = yield* spawner.spawn(
285− ChildProcess.make(input.command, input.args, {
286− cwd: process.cwd(),
287− }),
288− );
289− const [stdout, stderr, exitCode] = yield* Effect.all(
290− [
291− collectUint8StreamText({
292− stream: child.stdout,
293− maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES,
294− truncatedMarker: "\n\n[truncated]",
295− }),
296− collectUint8StreamText({
297− stream: child.stderr,
298− maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES,
299− truncatedMarker: "\n\n[truncated]",
300− }),
301− child.exitCode,
302− ],
303− { concurrency: "unbounded" },
304− );
305−
306− return {
307− exitCode,
308− stdout: stdout.text,
309− stderr: stderr.text,
310− } satisfies ProcessOutput;
311− },
312− (effect, input) =>
313− effect.pipe(
314− Effect.scoped,
315− Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
316− Effect.flatMap((result) =>
317− Option.match(result, {
318− onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)),
319− onSome: Effect.succeed,
320− }),
321− ),
322− Effect.mapError((cause) =>
323− isProcessDiagnosticsError(cause)
324− ? cause
325− : toProcessDiagnosticsError(input.errorMessage, cause),
326− ),
327− ),
328−);
329−
330−function readPosixProcessRows(): Effect.Effect<
331− ReadonlyArray<ProcessRow>,
332− ProcessDiagnosticsError,
333− ChildProcessSpawner.ChildProcessSpawner
334−> {
335− return runProcess({
336− command: "ps",
337− args: ["-axo", POSIX_PROCESS_QUERY_COMMAND],
338− errorMessage: "Failed to query process diagnostics.",
339− }).pipe(
340− Effect.flatMap((result) =>
341− result.exitCode !== 0
342− ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed."))
343− : Effect.succeed(parsePosixProcessRows(result.stdout)),
344− ),
345− );
346−}
347−
348−function readWindowsProcessRows(): Effect.Effect<
349− ReadonlyArray<ProcessRow>,
350− ProcessDiagnosticsError,
351− ChildProcessSpawner.ChildProcessSpawner
352−> {
353− const command = [
354− "$processes = Get-CimInstance Win32_Process | ForEach-Object {",
355− '$perf = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess = $($_.ProcessId)" -ErrorAction SilentlyContinue;',
356− "[pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; Name = $_.Name; CommandLine = $_.CommandLine; Status = $_.Status; WorkingSetSize = $_.WorkingSetSize; PercentProcessorTime = if ($perf) { $perf.PercentProcessorTime } else { 0 } }",
357− "};",
358− "$processes | ConvertTo-Json -Compress -Depth 3",
359− ].join(" ");
360−
361− return runProcess({
362− command: "powershell.exe",
363− args: ["-NoProfile", "-NonInteractive", "-Command", command],
364− errorMessage: "Failed to query process diagnostics.",
365− }).pipe(
366− Effect.flatMap((result) =>
367− result.exitCode !== 0
368− ? Effect.fail(
369− toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."),
370− )
371− : Effect.succeed(parseWindowsProcessRows(result.stdout)),
372− ),
373− );
374−}
375−
376−export const readProcessRows = Effect.gen(function* () {
377− const platform = yield* HostProcessPlatform;
378− return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();
379−});
380−
381−export function aggregateProcessDiagnostics(input: {
382− readonly serverPid: number;
383− readonly rows: ReadonlyArray<ProcessRow>;
384− readonly readAt: DateTime.Utc;
385−}): ServerProcessDiagnosticsResult {
386− return makeResult(input);
387−}
388−
389−function assertDescendantPid(
390− pid: number,
391−): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> {
392− if (pid === process.pid) {
393− return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process."));
394− }
395−
396− return readProcessRows.pipe(
397− Effect.flatMap((rows) => {
398− const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid));
399− const descendant = buildDescendantEntries(filteredRows, process.pid).some(
400− (entry) => entry.pid === pid,
401− );
402− return descendant
403− ? Effect.void
404− : Effect.fail(
405− toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`),
406− );
407− }),
69+ category === "server-child" || category === "provider-root" || category === "terminal-root"
40870 );
40971 }
41072
41173 export const make = Effect.fn("makeProcessDiagnostics")(function* () {
412− const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
413−
414− const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () {
415− const readAt = yield* DateTime.now;
416− const rows = yield* readProcessRows.pipe(
417− Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
418− );
419− return makeResult({ serverPid: process.pid, rows, readAt });
420− }).pipe(
421− Effect.catch((error: ProcessDiagnosticsError) =>
422− DateTime.now.pipe(
423− Effect.map((readAt) =>
424− makeResult({ serverPid: process.pid, rows: [], readAt, error: error.message }),
425− ),
426− ),
427− ),
74+ const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
75+ const read: ProcessDiagnosticsShape["read"] = telemetry.latest.pipe(
76+ Effect.map((snapshot) => {
77+ const processes = snapshot.processes
78+ .filter((entry) => entry.identity.pid !== process.pid)
79+ .map(
80+ (entry): ServerProcessDiagnosticsEntry => ({
81+ pid: entry.identity.pid,
82+ startTimeMs: entry.identity.startTimeMs,
83+ ppid: entry.ppid,
84+ pgid: Option.none(),
85+ status: entry.status || "Unknown",
86+ cpuPercent: entry.cpuPercent,
87+ rssBytes: entry.residentBytes,
88+ elapsed: formatElapsed(entry.runTimeMs),
89+ command: entry.command || entry.name || "unknown",
90+ depth: Math.max(0, entry.depth - 1),
91+ childPids: entry.childPids,
92+ }),
93+ );
94+ return {
95+ serverPid: process.pid,
96+ readAt: snapshot.readAt,
97+ processCount: processes.length,
98+ totalRssBytes: processes.reduce((total, entry) => total + entry.rssBytes, 0),
99+ totalCpuPercent: processes.reduce((total, entry) => total + entry.cpuPercent, 0),
100+ processes,
101+ error: Option.map(snapshot.health.native.lastError, (message) => ({ message })),
102+ };
103+ }),
428104 );
429105
430106 const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")(
431107 function* (input) {
432− return yield* assertDescendantPid(input.pid).pipe(
433− Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
434− Effect.flatMap(() =>
435− Effect.try({
436− try: () => {
437− process.kill(input.pid, input.signal);
438− return {
439− pid: input.pid,
440− signal: input.signal,
441− signaled: true,
442− message: Option.none(),
443− };
444− },
445− catch: (cause) =>
446− toProcessDiagnosticsError(
447− `Failed to signal process ${input.pid} with ${input.signal}.`,
448− cause,
449− ),
450− }),
451− ),
452− Effect.catch((error: ProcessDiagnosticsError) =>
453− Effect.succeed({
454− pid: input.pid,
455− signal: input.signal,
456− signaled: false,
457− message: Option.some(error.message),
458− }),
459− ),
108+ if (input.pid === process.pid) {
109+ return {
110+ pid: input.pid,
111+ signal: input.signal,
112+ signaled: false,
113+ message: Option.some("Refusing to signal the T3 server process."),
114+ };
115+ }
116+ const current = yield* telemetry.latest;
117+ const selected = current.processes.find(
118+ (entry) =>
119+ entry.identity.pid === input.pid && entry.identity.startTimeMs === input.startTimeMs,
460120 );
121+ if (!selected) {
122+ return {
123+ pid: input.pid,
124+ signal: input.signal,
125+ signaled: false,
126+ message: Option.some(
127+ `Process ${input.pid} no longer matches the selected process identity.`,
128+ ),
129+ };
130+ }
131+ if (!canSignalCategory(selected.category)) {
132+ return {
133+ pid: input.pid,
134+ signal: input.signal,
135+ signaled: false,
136+ message: Option.some(`Process ${input.pid} is not a signalable T3 backend descendant.`),
137+ };
138+ }
139+ return yield* telemetry
140+ .validateProcessIdentity({
141+ pid: input.pid,
142+ startTimeMs: input.startTimeMs,
143+ })
144+ .pipe(
145+ Effect.flatMap((valid) =>
146+ valid
147+ ? Effect.void
148+ : Effect.fail(
149+ new ProcessIdentityChanged({
150+ pid: input.pid,
151+ startTimeMs: input.startTimeMs,
152+ }),
153+ ),
154+ ),
155+ Effect.flatMap(() =>
156+ Effect.try({
157+ try: () => {
158+ process.kill(input.pid, input.signal);
159+ return {
160+ pid: input.pid,
161+ signal: input.signal,
162+ signaled: true,
163+ message: Option.none(),
164+ };
165+ },
166+ catch: (cause) =>
167+ new ProcessSignalFailed({
168+ pid: input.pid,
169+ signal: input.signal,
170+ cause,
171+ }),
172+ }),
173+ ),
174+ Effect.catch((error) =>
175+ Effect.succeed({
176+ pid: input.pid,
177+ signal: input.signal,
178+ signaled: false,
179+ message: Option.some(
180+ error instanceof Error ? error.message : "Failed to signal process.",
181+ ),
182+ }),
183+ ),
184+ );
461185 },
462186 );
463187
apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+112−211
@@ -1,231 +1,132 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import type { ResourceTelemetryHistory } from "@t3tools/contracts";
23 import * as DateTime from "effect/DateTime";
34 import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
46 import * as Option from "effect/Option";
7+import * as Stream from "effect/Stream";
58
6−import {
7− aggregateProcessResourceHistory,
8− collectMonitoredSamples,
9−} from "./ProcessResourceMonitor.ts";
9+import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
10+import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
1011
1112 describe("ProcessResourceMonitor", () => {
12− it.effect("samples the server root process and descendants", () =>
13− Effect.sync(() => {
14− const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
15− const samples = collectMonitoredSamples({
16− serverPid: 100,
17− sampledAt,
18− sampledAtMs: DateTime.toEpochMillis(sampledAt),
19− rows: [
20− {
21− pid: 100,
22− ppid: 1,
23− pgid: 100,
24− status: "S",
25− cpuPercent: 2,
26− rssBytes: 1_000,
27− elapsed: "01:00",
28− command: "t3 server",
29− },
30− {
31− pid: 101,
32− ppid: 100,
33− pgid: 100,
34− status: "S",
35− cpuPercent: 10,
36− rssBytes: 2_000,
37− elapsed: "00:20",
38− command: "codex app-server",
39− },
40− {
41− pid: 102,
42− ppid: 101,
43− pgid: 100,
44− status: "R",
45− cpuPercent: 50,
46− rssBytes: 3_000,
47− elapsed: "00:05",
48− command: "rg needle",
49− },
13+ it.effect("projects resource telemetry history into the legacy diagnostics contract", () =>
14+ Effect.gen(function* () {
15+ const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
16+ const history: ResourceTelemetryHistory = {
17+ readAt,
18+ windowMs: 60_000,
19+ bucketMs: 10_000,
20+ sampleIntervalMs: 1_000,
21+ retainedSampleCount: 2,
22+ buckets: [
5023 {
51− pid: 200,
52− ppid: 1,
53− pgid: 200,
54− status: "R",
55− cpuPercent: 99,
56− rssBytes: 9_000,
57− elapsed: "00:05",
58− command: "unrelated",
24+ startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
25+ endedAt: readAt,
26+ avgCpuPercent: 15,
27+ maxCpuPercent: 25,
28+ maxRssBytes: 4_096,
29+ ioReadBytes: 1_024,
30+ ioWriteBytes: 2_048,
31+ maxProcessCount: 2,
5932 },
6033 ],
61− });
62−
63− expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]);
64− expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]);
65− expect(samples[0]?.isServerRoot).toBe(true);
66− expect(samples[1]?.isServerRoot).toBe(false);
67− }),
68− );
69−
70− it.effect("rolls samples up by process and CPU time", () =>
71− Effect.sync(() => {
72− const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
73− const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z");
74− const samples = [
75− ...collectMonitoredSamples({
76− serverPid: 100,
77− sampledAt: firstAt,
78− sampledAtMs: DateTime.toEpochMillis(firstAt),
79− rows: [
80− {
81− pid: 100,
82− ppid: 1,
83− pgid: 100,
84− status: "S",
85− cpuPercent: 10,
86− rssBytes: 1_000,
87− elapsed: "01:00",
88− command: "t3 server",
89− },
90− ],
91− }),
92− ...collectMonitoredSamples({
93− serverPid: 100,
94− sampledAt: secondAt,
95− sampledAtMs: DateTime.toEpochMillis(secondAt),
96− rows: [
97− {
98− pid: 100,
99− ppid: 1,
100− pgid: 100,
101− status: "S",
102− cpuPercent: 30,
103− rssBytes: 2_000,
104− elapsed: "01:05",
105− command: "t3 server",
106− },
107− ],
108− }),
109− ];
110−
111− const result = aggregateProcessResourceHistory({
112− samples,
113− readAt: secondAt,
114− readAtMs: DateTime.toEpochMillis(secondAt),
115− windowMs: 60_000,
116− bucketMs: 10_000,
117− lastError: null,
118− });
119−
120− expect(Option.isNone(result.error)).toBe(true);
121− expect(result.topProcesses).toHaveLength(1);
122− expect(result.topProcesses[0]?.avgCpuPercent).toBe(20);
123− expect(result.topProcesses[0]?.maxCpuPercent).toBe(30);
124− expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2);
125− expect(result.totalCpuSecondsApprox).toBe(2);
126− expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true);
127− }),
128− );
129−
130− it.effect("keeps a process grouped when elapsed time drifts between samples", () =>
131− Effect.sync(() => {
132− const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z");
133− const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z");
134− const samples = [
135− ...collectMonitoredSamples({
136− serverPid: 100,
137− sampledAt: firstAt,
138− sampledAtMs: DateTime.toEpochMillis(firstAt),
139− rows: [
140− {
141− pid: 100,
142− ppid: 1,
143− pgid: 100,
144− status: "S",
145− cpuPercent: 1,
146− rssBytes: 1_000,
147− elapsed: "01:00",
148− command: "t3 server",
149− },
150− ],
151− }),
152− ...collectMonitoredSamples({
153− serverPid: 100,
154− sampledAt: secondAt,
155− sampledAtMs: DateTime.toEpochMillis(secondAt),
156− rows: [
157− {
158− pid: 100,
159− ppid: 1,
160− pgid: 100,
161− status: "S",
162− cpuPercent: 2,
163− rssBytes: 2_000,
164− elapsed: "01:06",
165− command: "t3 server",
166− },
167− ],
168− }),
169− ];
170−
171− const result = aggregateProcessResourceHistory({
172− samples,
173− readAt: secondAt,
174− readAtMs: DateTime.toEpochMillis(secondAt),
175− windowMs: 60_000,
176− bucketMs: 10_000,
177− lastError: null,
178− });
179−
180− expect(result.topProcesses).toHaveLength(1);
181− expect(result.topProcesses[0]?.isServerRoot).toBe(true);
182− expect(result.topProcesses[0]?.sampleCount).toBe(2);
183− expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000);
184− }),
185− );
186−
187− it.effect("returns all process summaries in the selected window", () =>
188− Effect.sync(() => {
189− const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
190− const samples = collectMonitoredSamples({
191− serverPid: 100,
192− sampledAt,
193− sampledAtMs: DateTime.toEpochMillis(sampledAt),
194− rows: [
34+ topProcesses: [
19535 {
196− pid: 100,
36+ identity: { pid: process.pid, startTimeMs: 100 },
19737 ppid: 1,
198− pgid: 100,
199− status: "S",
200− cpuPercent: 1,
201− rssBytes: 1_000,
202− elapsed: "01:00",
38+ depth: 0,
39+ name: "node",
20340 command: "t3 server",
41+ category: "server",
42+ firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
43+ lastSeenAt: readAt,
44+ currentCpuPercent: 5,
45+ avgCpuPercent: 4,
46+ maxCpuPercent: 8,
47+ cpuTimeMs: 1_500,
48+ currentRssBytes: 2_048,
49+ peakRssBytes: 4_096,
50+ ioReadBytes: 1_024,
51+ ioWriteBytes: 2_048,
52+ ioSemantics: "storage",
53+ sampleCount: 2,
20454 },
205− ...Array.from({ length: 35 }, (_, index) => ({
206− pid: 200 + index,
207− ppid: index === 0 ? 100 : 199 + index,
208− pgid: 100,
209− status: "S",
210− cpuPercent: 35 - index,
211− rssBytes: 2_000 + index,
212− elapsed: "00:10",
213− command: `worker ${index}`,
214− })),
21555 ],
216− });
56+ health: {
57+ native: {
58+ status: "degraded",
59+ lastSampleAt: Option.some(readAt),
60+ lastError: Option.some("collector stalled"),
61+ },
62+ desktop: {
63+ status: "healthy",
64+ lastSampleAt: Option.some(readAt),
65+ lastError: Option.none(),
66+ },
67+ sidecarVersion: Option.some("0.1.0"),
68+ sidecarPid: Option.some(9_000),
69+ restartCount: 1,
70+ collectionDurationMicros: 250,
71+ scannedProcessCount: 80,
72+ retainedProcessCount: 2,
73+ inaccessibleProcessCount: 0,
74+ },
75+ };
76+ const telemetry: ResourceTelemetry.ResourceTelemetryShape = {
77+ latest: Effect.die("unused"),
78+ changes: Stream.empty,
79+ readHistory: () => Effect.succeed(history),
80+ refresh: Effect.die("unused"),
81+ validateProcessIdentity: () => Effect.die("unused"),
82+ retry: Effect.die("unused"),
83+ };
84+ const layer = ProcessResourceMonitor.layer.pipe(
85+ Layer.provide(
86+ Layer.succeed(
87+ ResourceTelemetry.ResourceTelemetry,
88+ ResourceTelemetry.ResourceTelemetry.of(telemetry),
89+ ),
90+ ),
91+ );
21792
218− const result = aggregateProcessResourceHistory({
219− samples,
220− readAt: sampledAt,
221− readAtMs: DateTime.toEpochMillis(sampledAt),
222− windowMs: 60_000,
223− bucketMs: 10_000,
224− lastError: null,
225− });
93+ const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe(
94+ Effect.flatMap((monitor) =>
95+ monitor.readHistory({
96+ windowMs: 60_000,
97+ bucketMs: 10_000,
98+ }),
99+ ),
100+ Effect.provide(layer),
101+ );
226102
227− expect(result.topProcesses).toHaveLength(36);
228− expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true);
103+ expect(result.totalCpuSecondsApprox).toBe(1.5);
104+ expect(result.topProcesses).toEqual([
105+ {
106+ processKey: `${process.pid}:100`,
107+ pid: process.pid,
108+ ppid: 1,
109+ command: "t3 server",
110+ depth: 0,
111+ isServerRoot: true,
112+ firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
113+ lastSeenAt: readAt,
114+ currentCpuPercent: 5,
115+ avgCpuPercent: 4,
116+ maxCpuPercent: 8,
117+ cpuSecondsApprox: 1.5,
118+ currentRssBytes: 2_048,
119+ maxRssBytes: 4_096,
120+ sampleCount: 2,
121+ },
122+ ]);
123+ expect(result.buckets[0]).toMatchObject({
124+ avgCpuPercent: 15,
125+ maxCpuPercent: 25,
126+ maxRssBytes: 4_096,
127+ maxProcessCount: 2,
128+ });
129+ expect(result.error).toEqual(Option.some({ message: "collector stalled" }));
229130 }),
230131 );
231132 });
apps/server/src/diagnostics/ProcessResourceMonitor.ts+44−271
@@ -1,45 +1,13 @@
11 import type {
2− ServerProcessResourceHistoryBucket,
32 ServerProcessResourceHistoryInput,
43 ServerProcessResourceHistoryResult,
5− ServerProcessResourceHistorySummary,
64 } from "@t3tools/contracts";
75 import * as Context from "effect/Context";
8−import * as DateTime from "effect/DateTime";
96 import * as Effect from "effect/Effect";
107 import * as Layer from "effect/Layer";
118 import * as Option from "effect/Option";
12−import * as Ref from "effect/Ref";
13−import { ChildProcessSpawner } from "effect/unstable/process";
149
15−import {
16− buildDescendantEntries,
17− isDiagnosticsQueryProcess,
18− type ProcessRow,
19− readProcessRows,
20−} from "./ProcessDiagnostics.ts";
21−
22−const SAMPLE_INTERVAL_MS = 5_000;
23−const RETENTION_MS = 60 * 60_000;
24−const MAX_RETAINED_SAMPLES = 20_000;
25−
26−export interface ProcessResourceSample {
27− readonly sampledAt: DateTime.Utc;
28− readonly sampledAtMs: number;
29− readonly processKey: string;
30− readonly pid: number;
31− readonly ppid: number;
32− readonly command: string;
33− readonly cpuPercent: number;
34− readonly rssBytes: number;
35− readonly depth: number;
36− readonly isServerRoot: boolean;
37−}
38−
39−interface MonitorState {
40− readonly samples: ReadonlyArray<ProcessResourceSample>;
41− readonly lastError: string | null;
42−}
10+import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
4311
4412 export interface ProcessResourceMonitorShape {
4513 readonly readHistory: (
@@ -52,246 +20,51 @@ export class ProcessResourceMonitor extends Context.Service< · 6 unmodified lines
5220 ProcessResourceMonitorShape
5321 >()("t3/diagnostics/ProcessResourceMonitor") {}
5422
55−function dateTimeFromMillis(ms: number): DateTime.Utc {
56− return DateTime.makeUnsafe(ms);
57−}
58−
59−function sampleKey(row: Pick<ProcessRow, "pid" | "command">): string {
60− return `${row.pid}:${row.command}`;
61−}
62−
63−function findServerRootRow(rows: ReadonlyArray<ProcessRow>, serverPid: number): ProcessRow | null {
64− return rows.find((row) => row.pid === serverPid) ?? null;
65−}
66−
67−export function collectMonitoredSamples(input: {
68− readonly rows: ReadonlyArray<ProcessRow>;
69− readonly serverPid: number;
70− readonly sampledAt: DateTime.Utc;
71− readonly sampledAtMs: number;
72−}): ReadonlyArray<ProcessResourceSample> {
73− const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid));
74− const root = findServerRootRow(rows, input.serverPid);
75− const descendants = buildDescendantEntries(rows, input.serverPid);
76− const samples: ProcessResourceSample[] = [];
77−
78− if (root) {
79− samples.push({
80− sampledAt: input.sampledAt,
81− sampledAtMs: input.sampledAtMs,
82− processKey: sampleKey(root),
83− pid: root.pid,
84− ppid: root.ppid,
85− command: root.command,
86− cpuPercent: root.cpuPercent,
87− rssBytes: root.rssBytes,
88− depth: 0,
89− isServerRoot: true,
90− });
91− }
92−
93− for (const process of descendants) {
94− samples.push({
95− sampledAt: input.sampledAt,
96− sampledAtMs: input.sampledAtMs,
97− processKey: sampleKey(process),
98− pid: process.pid,
99− ppid: process.ppid,
100− command: process.command,
101− cpuPercent: process.cpuPercent,
102− rssBytes: process.rssBytes,
103− depth: process.depth + 1,
104− isServerRoot: false,
105− });
106− }
107−
108− return samples;
109−}
110−
111−function trimSamples(
112− samples: ReadonlyArray<ProcessResourceSample>,
113− nowMs: number,
114−): ReadonlyArray<ProcessResourceSample> {
115− const minSampledAtMs = nowMs - RETENTION_MS;
116− const retained = samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs);
117− return retained.length <= MAX_RETAINED_SAMPLES
118− ? retained
119− : retained.slice(retained.length - MAX_RETAINED_SAMPLES);
120−}
121−
122−function summarizeProcesses(
123− samples: ReadonlyArray<ProcessResourceSample>,
124−): ReadonlyArray<ServerProcessResourceHistorySummary> {
125− const groups = new Map<string, ProcessResourceSample[]>();
126− for (const sample of samples) {
127− const processSamples = groups.get(sample.processKey) ?? [];
128− processSamples.push(sample);
129− groups.set(sample.processKey, processSamples);
130− }
131−
132− return [...groups.entries()]
133− .map(([processKey, processSamples]) => {
134− const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs);
135− const first = sorted[0]!;
136− const latest = sorted[sorted.length - 1]!;
137− const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0);
138− const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent));
139− const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes));
140− const cpuSecondsApprox = sorted.reduce(
141− (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000),
142− 0,
143− );
144−
145− return {
146− processKey,
147− pid: latest.pid,
148− ppid: latest.ppid,
149− command: latest.command,
150− depth: latest.depth,
151− isServerRoot: latest.isServerRoot,
152− firstSeenAt: first.sampledAt,
153− lastSeenAt: latest.sampledAt,
154− currentCpuPercent: latest.cpuPercent,
155− avgCpuPercent: cpuPercentTotal / sorted.length,
156− maxCpuPercent,
157− cpuSecondsApprox,
158− currentRssBytes: latest.rssBytes,
159− maxRssBytes,
160− sampleCount: sorted.length,
161− } satisfies ServerProcessResourceHistorySummary;
162− })
163− .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox);
164−}
165−
166−function buildBuckets(input: {
167− readonly samples: ReadonlyArray<ProcessResourceSample>;
168− readonly nowMs: number;
169− readonly windowMs: number;
170− readonly bucketMs: number;
171−}): ReadonlyArray<ServerProcessResourceHistoryBucket> {
172− const bucketMs = Math.max(1_000, input.bucketMs);
173− const windowStartMs = input.nowMs - input.windowMs;
174− const buckets: ServerProcessResourceHistoryBucket[] = [];
175−
176− for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += bucketMs) {
177− const endedAtMs = Math.min(input.nowMs, startedAtMs + bucketMs);
178− const bucketSamples = input.samples.filter(
179− (sample) =>
180− sample.sampledAtMs >= startedAtMs &&
181− (endedAtMs === input.nowMs
182− ? sample.sampledAtMs <= endedAtMs
183− : sample.sampledAtMs < endedAtMs),
184− );
185− const samplesByRead = new Map<number, ProcessResourceSample[]>();
186− for (const sample of bucketSamples) {
187− const samplesAtTime = samplesByRead.get(sample.sampledAtMs) ?? [];
188− samplesAtTime.push(sample);
189− samplesByRead.set(sample.sampledAtMs, samplesAtTime);
190− }
191−
192− const readTotals = [...samplesByRead.values()].map((samplesAtTime) => ({
193− cpuPercent: samplesAtTime.reduce((total, sample) => total + sample.cpuPercent, 0),
194− rssBytes: samplesAtTime.reduce((total, sample) => total + sample.rssBytes, 0),
195− processCount: samplesAtTime.length,
196− }));
197− const avgCpuPercent =
198− readTotals.length === 0
199− ? 0
200− : readTotals.reduce((total, read) => total + read.cpuPercent, 0) / readTotals.length;
201−
202− buckets.push({
203− startedAt: dateTimeFromMillis(startedAtMs),
204− endedAt: dateTimeFromMillis(endedAtMs),
205− avgCpuPercent,
206− maxCpuPercent: readTotals.length ? Math.max(...readTotals.map((read) => read.cpuPercent)) : 0,
207− maxRssBytes: readTotals.length ? Math.max(...readTotals.map((read) => read.rssBytes)) : 0,
208− maxProcessCount: readTotals.length
209− ? Math.max(...readTotals.map((read) => read.processCount))
210− : 0,
211− });
212− }
213−
214− return buckets;
215−}
216−
217−export function aggregateProcessResourceHistory(input: {
218− readonly samples: ReadonlyArray<ProcessResourceSample>;
219− readonly readAt: DateTime.Utc;
220− readonly readAtMs: number;
221− readonly windowMs: number;
222− readonly bucketMs: number;
223− readonly lastError: string | null;
224−}): ServerProcessResourceHistoryResult {
225− const windowMs = Math.max(1_000, input.windowMs);
226− const bucketMs = Math.max(1_000, input.bucketMs);
227− const minSampledAtMs = input.readAtMs - windowMs;
228− const samples = input.samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs);
229− const topProcesses = summarizeProcesses(samples);
230− const totalCpuSecondsApprox = samples.reduce(
231− (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000),
232− 0,
233− );
234−
235− return {
236− readAt: input.readAt,
237− windowMs,
238− bucketMs,
239− sampleIntervalMs: SAMPLE_INTERVAL_MS,
240− retainedSampleCount: input.samples.length,
241− totalCpuSecondsApprox,
242− buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }),
243− topProcesses,
244− error: input.lastError ? Option.some({ message: input.lastError }) : Option.none(),
245− };
246−}
247−
24823 export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
249− const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
250− const state = yield* Ref.make<MonitorState>({ samples: [], lastError: null });
251−
252− const sampleOnce = Effect.gen(function* () {
253− const sampledAt = yield* DateTime.now;
254− const sampledAtMs = DateTime.toEpochMillis(sampledAt);
255− const rows = yield* readProcessRows.pipe(
256− Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
257− );
258− const samples = collectMonitoredSamples({
259− rows,
260− serverPid: process.pid,
261− sampledAt,
262− sampledAtMs,
263− });
264− yield* Ref.update(state, (current) => ({
265− samples: trimSamples([...current.samples, ...samples], sampledAtMs),
266− lastError: null,
267− }));
268− }).pipe(
269− Effect.catch((error: unknown) =>
270− Ref.update(state, (current) => ({
271− ...current,
272− lastError: error instanceof Error ? error.message : "Failed to sample process resources.",
273− })),
274− ),
275− );
276−
277− yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe(
278− Effect.forkScoped,
279− );
280−
24+ const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
28125 const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) =>
282− Effect.gen(function* () {
283− const readAt = yield* DateTime.now;
284− const readAtMs = DateTime.toEpochMillis(readAt);
285− const current = yield* Ref.get(state);
286− return aggregateProcessResourceHistory({
287− samples: current.samples,
288− readAt,
289− readAtMs,
290− windowMs: input.windowMs,
291− bucketMs: input.bucketMs,
292− lastError: current.lastError,
293− });
294− });
26+ telemetry.readHistory(input).pipe(
27+ Effect.map((history) => {
28+ const topProcesses = history.topProcesses.map((entry) => ({
29+ processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`,
30+ pid: entry.identity.pid,
31+ ppid: entry.ppid,
32+ command: entry.command || entry.name || "unknown",
33+ depth: entry.depth,
34+ isServerRoot: entry.category === "server",
35+ firstSeenAt: entry.firstSeenAt,
36+ lastSeenAt: entry.lastSeenAt,
37+ currentCpuPercent: entry.currentCpuPercent,
38+ avgCpuPercent: entry.avgCpuPercent,
39+ maxCpuPercent: entry.maxCpuPercent,
40+ cpuSecondsApprox: entry.cpuTimeMs / 1_000,
41+ currentRssBytes: entry.currentRssBytes,
42+ maxRssBytes: entry.peakRssBytes,
43+ sampleCount: entry.sampleCount,
44+ }));
45+ return {
46+ readAt: history.readAt,
47+ windowMs: history.windowMs,
48+ bucketMs: history.bucketMs,
49+ sampleIntervalMs: history.sampleIntervalMs,
50+ retainedSampleCount: history.retainedSampleCount,
51+ totalCpuSecondsApprox: topProcesses.reduce(
52+ (total, entry) => total + entry.cpuSecondsApprox,
53+ 0,
54+ ),
55+ buckets: history.buckets.map((bucket) => ({
56+ startedAt: bucket.startedAt,
57+ endedAt: bucket.endedAt,
58+ avgCpuPercent: bucket.avgCpuPercent,
59+ maxCpuPercent: bucket.maxCpuPercent,
60+ maxRssBytes: bucket.maxRssBytes,
61+ maxProcessCount: bucket.maxProcessCount,
62+ })),
63+ topProcesses,
64+ error: history.health.native.lastError.pipe(Option.map((message) => ({ message }))),
65+ };
66+ }),
67+ );
29568
29669 return ProcessResourceMonitor.of({ readHistory });
29770 });
apps/server/src/observability/Layers/Observability.ts+10−0
@@ -7,6 +7,7 @@ import * as Tracer from "effect/Tracer"; · 6 unmodified lines
77 import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
88
99 import { ServerConfig } from "../../config.ts";
10+import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
1011 import { ServerLoggerLive } from "../../serverLogger.ts";
1112 import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
1213
@@ -15,6 +16,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson; · 2 unmodified lines
1516 export const ObservabilityLive = Layer.unwrap(
1617 Effect.gen(function* () {
1718 const config = yield* ServerConfig;
19+ const attribution = yield* ResourceAttribution.ResourceAttribution;
1820
1921 const traceReferencesLayer = Layer.mergeAll(
2022 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
@@ -29,6 +31,14 @@ export const ObservabilityLive = Layer.unwrap( · 8 unmodified lines
2931 maxBytes: config.traceMaxBytes,
3032 maxFiles: config.traceMaxFiles,
3133 batchWindowMs: config.traceBatchWindowMs,
34+ onFlush: (stats) =>
35+ attribution.record({
36+ component: "server-trace",
37+ operation: "append",
38+ logicalWriteBytes: stats.logicalWriteBytes,
39+ count: stats.count,
40+ durationMs: stats.durationMs,
41+ }),
3242 });
3343 const delegate =
3444 config.otlpTracesUrl === undefined
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+33−0
@@ -7,6 +7,7 @@ import { ThreadId } from "@t3tools/contracts"; · 6 unmodified lines
77 import { assert, describe, it } from "@effect/vitest";
88 import * as Effect from "effect/Effect";
99
10+import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
1011 import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
1112
1213 function parseLogLine(line: string) {
@@ -207,4 +208,36 @@ describe("EventNdjsonLogger", () => { · 194 unmodified lines
207208 }
208209 }),
209210 );
211+
212+ it.effect("reports logical provider log writes to resource attribution", () =>
213+ Effect.gen(function* () {
214+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-"));
215+ const basePath = path.join(tempDir, "provider-native.ndjson");
216+
217+ try {
218+ const attribution = yield* ResourceAttribution.make();
219+ const logger = yield* makeEventNdjsonLogger(basePath, {
220+ stream: "native",
221+ batchWindowMs: 0,
222+ attribution,
223+ });
224+ assert.notEqual(logger, undefined);
225+ if (!logger) {
226+ return;
227+ }
228+
229+ yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution"));
230+ yield* logger.close();
231+
232+ const snapshot = yield* attribution.snapshot;
233+ assert.equal(snapshot.entries.length, 1);
234+ assert.equal(snapshot.entries[0]?.component, "provider-event-log");
235+ assert.equal(snapshot.entries[0]?.operation, "native.append");
236+ assert.equal(snapshot.entries[0]?.count, 1);
237+ assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0);
238+ } finally {
239+ fs.rmSync(tempDir, { recursive: true, force: true });
240+ }
241+ }),
242+ );
210243 });
apps/server/src/provider/Layers/EventNdjsonLogger.ts+28−1
@@ -11,6 +11,7 @@ import path from "node:path"; · 10 unmodified lines
1111
1212 import type { ThreadId } from "@t3tools/contracts";
1313 import { RotatingFileSink } from "@t3tools/shared/logging";
14+import * as Clock from "effect/Clock";
1415 import * as Effect from "effect/Effect";
1516 import * as Exit from "effect/Exit";
1617 import * as Logger from "effect/Logger";
@@ -19,6 +20,7 @@ import * as Scope from "effect/Scope"; · 2 unmodified lines
1920 import * as SynchronizedRef from "effect/SynchronizedRef";
2021
2122 import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
23+import type { ResourceAttributionShape } from "../../resourceTelemetry/ResourceAttribution.ts";
2224
2325 const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
2426 const DEFAULT_MAX_FILES = 10;
@@ -26,6 +28,7 @@ const DEFAULT_BATCH_WINDOW_MS = 200; · 1 unmodified line
2628 const GLOBAL_THREAD_SEGMENT = "_global";
2729 const LOG_SCOPE = "provider-observability";
2830 const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);
31+const textEncoder = new TextEncoder();
2932
3033 export type EventNdjsonStream = "native" | "canonical" | "orchestration";
3134
@@ -40,6 +43,7 @@ export interface EventNdjsonLoggerOptions { · 8 unmodified lines
4043 readonly maxBytes?: number;
4144 readonly maxFiles?: number;
4245 readonly batchWindowMs?: number;
46+ readonly attribution?: ResourceAttributionShape;
4347 }
4448
4549 interface ThreadWriter {
@@ -104,6 +108,8 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 58 unmodified lines
104108 readonly maxFiles: number;
105109 readonly batchWindowMs: number;
106110 readonly streamLabel: string;
111+ readonly stream: EventNdjsonStream;
112+ readonly attribution?: ResourceAttributionShape;
107113 }): Effect.fn.Return<ThreadWriter | undefined> {
108114 const sinkResult = yield* Effect.sync(() => {
109115 try {
@@ -135,12 +141,19 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 25 unmodified lines
135141 const batchedLogger = yield* Logger.batched(lineLogger, {
136142 window: input.batchWindowMs,
137143 flush: Effect.fn("makeThreadWriter.flush")(function* (messages) {
144+ const startedAt = yield* Clock.currentTimeMillis;
138145 const flushResult = yield* Effect.sync(() => {
139146 try {
147+ let logicalWriteBytes = 0;
140148 for (const message of messages) {
141149 sink.write(message);
150+ logicalWriteBytes += textEncoder.encode(message).byteLength;
142151 }
143− return { ok: true as const };
152+ return {
153+ ok: true as const,
154+ logicalWriteBytes,
155+ count: messages.length,
156+ };
144157 } catch (error) {
145158 return { ok: false as const, error };
146159 }
@@ -151,6 +164,18 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 4 unmodified lines
151164 filePath: input.filePath,
152165 error: flushResult.error,
153166 });
167+ return;
168+ }
169+
170+ if (input.attribution && flushResult.count > 0) {
171+ const completedAt = yield* Clock.currentTimeMillis;
172+ yield* input.attribution.record({
173+ component: "provider-event-log",
174+ operation: `${input.stream}.append`,
175+ logicalWriteBytes: flushResult.logicalWriteBytes,
176+ count: flushResult.count,
177+ durationMs: Math.max(0, completedAt - startedAt),
178+ });
154179 }
155180 }),
156181 }).pipe(Effect.provideService(Scope.Scope, scope));
@@ -216,6 +241,8 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function · 59 unmodified lines
216241 maxFiles,
217242 batchWindowMs,
218243 streamLabel,
244+ stream: options.stream,
245+ ...(options.attribution ? { attribution: options.attribution } : {}),
219246 }).pipe(
220247 Effect.map((writer) => {
221248 if (!writer) {
apps/server/src/provider/Layers/ProviderEventLoggers.ts+4−0
@@ -32,6 +32,7 @@ import * as Effect from "effect/Effect"; · 31 unmodified lines
3232 import * as Layer from "effect/Layer";
3333
3434 import { ServerConfig } from "../../config.ts";
35+import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
3536 import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3637
3738 export interface ProviderEventLoggersShape {
@@ -71,11 +72,14 @@ export const ProviderEventLoggersLive = Layer.effect( · 33 unmodified lines
7172 ProviderEventLoggers,
7273 Effect.gen(function* () {
7374 const { providerEventLogPath } = yield* ServerConfig;
75+ const attribution = yield* ResourceAttribution.ResourceAttribution;
7476 const native = yield* makeEventNdjsonLogger(providerEventLogPath, {
7577 stream: "native",
78+ attribution,
7679 });
7780 const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, {
7881 stream: "canonical",
82+ attribution,
7983 });
8084 return {
8185 native,
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts+279−0
@@ -0,0 +1,279 @@
1+// @effect-diagnostics nodeBuiltinImport:off
2+import * as NodeFileSystem from "node:fs";
3+
4+import * as NodeStream from "@effect/platform-node/NodeStream";
5+import {
6+ DesktopHostTelemetryMessage,
7+ type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue,
8+ type DesktopHostTelemetrySnapshot,
9+ type ResourceTelemetrySourceStatus,
10+} from "@t3tools/contracts";
11+import * as Context from "effect/Context";
12+import * as DateTime from "effect/DateTime";
13+import * as Effect from "effect/Effect";
14+import * as Layer from "effect/Layer";
15+import * as Option from "effect/Option";
16+import * as PubSub from "effect/PubSub";
17+import * as Ref from "effect/Ref";
18+import * as Schema from "effect/Schema";
19+import * as Stream from "effect/Stream";
20+import * as Ndjson from "effect/unstable/encoding/Ndjson";
21+
22+import { ServerConfig } from "../config.ts";
23+
24+export class DesktopTelemetryDescriptorUnavailable extends Schema.TaggedErrorClass<DesktopTelemetryDescriptorUnavailable>()(
25+ "DesktopTelemetryDescriptorUnavailable",
26+ {
27+ mode: Schema.String,
28+ },
29+) {
30+ override get message(): string {
31+ return `Desktop telemetry descriptor is unavailable in '${this.mode}' mode.`;
32+ }
33+}
34+
35+export class DesktopTelemetryProtocolMismatch extends Schema.TaggedErrorClass<DesktopTelemetryProtocolMismatch>()(
36+ "DesktopTelemetryProtocolMismatch",
37+ {
38+ expectedVersion: Schema.Number,
39+ receivedVersion: Schema.Number,
40+ },
41+) {
42+ override get message(): string {
43+ return `Desktop telemetry protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`;
44+ }
45+}
46+
47+export class DesktopTelemetryDecodeFailed extends Schema.TaggedErrorClass<DesktopTelemetryDecodeFailed>()(
48+ "DesktopTelemetryDecodeFailed",
49+ {
50+ cause: Schema.Defect(),
51+ },
52+) {
53+ override get message(): string {
54+ return "Failed to decode desktop telemetry.";
55+ }
56+}
57+
58+export class DesktopTelemetryStreamFailed extends Schema.TaggedErrorClass<DesktopTelemetryStreamFailed>()(
59+ "DesktopTelemetryStreamFailed",
60+ {
61+ fd: Schema.Number,
62+ cause: Schema.Defect(),
63+ },
64+) {
65+ override get message(): string {
66+ return `Desktop telemetry stream on fd ${this.fd} failed.`;
67+ }
68+}
69+
70+export class DesktopTelemetryStreamClosed extends Schema.TaggedErrorClass<DesktopTelemetryStreamClosed>()(
71+ "DesktopTelemetryStreamClosed",
72+ {
73+ fd: Schema.Number,
74+ },
75+) {
76+ override get message(): string {
77+ return `Desktop telemetry stream on fd ${this.fd} closed.`;
78+ }
79+}
80+
81+export type DesktopTelemetryReceiverError =
82+ | DesktopTelemetryDescriptorUnavailable
83+ | DesktopTelemetryProtocolMismatch
84+ | DesktopTelemetryDecodeFailed
85+ | DesktopTelemetryStreamFailed
86+ | DesktopTelemetryStreamClosed;
87+
88+export interface DesktopTelemetryReceiverHealth {
89+ readonly status: ResourceTelemetrySourceStatus;
90+ readonly lastSampleAt: Option.Option<DateTime.Utc>;
91+ readonly lastError: Option.Option<string>;
92+}
93+
94+export interface DesktopTelemetryReceiverShape {
95+ readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;
96+ readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
97+ readonly health: Effect.Effect<DesktopTelemetryReceiverHealth>;
98+ readonly healthChanges: Stream.Stream<DesktopTelemetryReceiverHealth>;
99+}
100+
101+export class DesktopTelemetryReceiver extends Context.Service<
102+ DesktopTelemetryReceiver,
103+ DesktopTelemetryReceiverShape
104+>()("t3/resourceTelemetry/DesktopTelemetryReceiver") {}
105+
106+const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage);
107+const isDescriptorUnavailable = Schema.is(DesktopTelemetryDescriptorUnavailable);
108+const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch);
109+const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed);
110+const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed);
111+
112+function normalizeReceiverError(error: unknown): DesktopTelemetryReceiverError {
113+ if (
114+ isDescriptorUnavailable(error) ||
115+ isProtocolMismatch(error) ||
116+ isDecodeFailed(error) ||
117+ isStreamFailed(error)
118+ ) {
119+ return error;
120+ }
121+ return new DesktopTelemetryDecodeFailed({ cause: error });
122+}
123+
124+function messageVersion(value: unknown): number | undefined {
125+ if (typeof value !== "object" || value === null) return undefined;
126+ const version = Reflect.get(value, "version");
127+ return typeof version === "number" ? version : undefined;
128+}
129+
130+export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () {
131+ const config = yield* ServerConfig;
132+ const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>());
133+ const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
134+ const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4);
135+ const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({
136+ status: config.desktopTelemetryFd === undefined ? "unavailable" : "starting",
137+ lastSampleAt: Option.none(),
138+ lastError:
139+ config.desktopTelemetryFd === undefined
140+ ? Option.some(
141+ new DesktopTelemetryDescriptorUnavailable({
142+ mode: config.mode,
143+ }).message,
144+ )
145+ : Option.none(),
146+ });
147+ const updateHealth = (
148+ update: (current: DesktopTelemetryReceiverHealth) => DesktopTelemetryReceiverHealth,
149+ ) =>
150+ Ref.modify(health, (current) => {
151+ const next = update(current);
152+ return [next, next];
153+ }).pipe(
154+ Effect.flatMap((next) => PubSub.publish(healthChanges, next)),
155+ Effect.asVoid,
156+ );
157+
158+ if (config.desktopTelemetryFd !== undefined) {
159+ const fd = config.desktopTelemetryFd;
160+ const readable = yield* Effect.acquireRelease(
161+ Effect.try({
162+ try: () =>
163+ NodeFileSystem.createReadStream("", {
164+ fd,
165+ autoClose: true,
166+ }),
167+ catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }),
168+ }),
169+ (stream) =>
170+ Effect.sync(() => {
171+ stream.destroy();
172+ }),
173+ );
174+
175+ const messages: Stream.Stream<DesktopHostTelemetryMessageValue, DesktopTelemetryReceiverError> =
176+ NodeStream.fromReadable<Uint8Array, DesktopTelemetryStreamFailed>({
177+ evaluate: () => readable,
178+ closeOnDone: true,
179+ onError: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }),
180+ }).pipe(
181+ Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })),
182+ Stream.mapEffect(
183+ (
184+ value,
185+ ): Effect.Effect<
186+ DesktopHostTelemetryMessageValue,
187+ DesktopTelemetryProtocolMismatch | DesktopTelemetryDecodeFailed
188+ > => {
189+ const version = messageVersion(value);
190+ if (version !== undefined && version !== 1) {
191+ return Effect.fail(
192+ new DesktopTelemetryProtocolMismatch({
193+ expectedVersion: 1,
194+ receivedVersion: version,
195+ }),
196+ );
197+ }
198+ return decodeMessage(value).pipe(
199+ Effect.mapError((cause) => new DesktopTelemetryDecodeFailed({ cause })),
200+ );
201+ },
202+ ),
203+ Stream.mapError(normalizeReceiverError),
204+ );
205+
206+ yield* messages.pipe(
207+ Stream.runForEach((message) => {
208+ if (message.type === "desktopTelemetryHello") {
209+ return updateHealth(
210+ (current): DesktopTelemetryReceiverHealth => ({
211+ ...current,
212+ status: "healthy",
213+ lastError: Option.none(),
214+ }),
215+ );
216+ }
217+
218+ const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs);
219+ return Ref.set(latest, Option.some(message)).pipe(
220+ Effect.andThen(
221+ Ref.set(health, {
222+ status: "healthy",
223+ lastSampleAt: Option.some(sampledAt),
224+ lastError: Option.none(),
225+ }),
226+ ),
227+ Effect.andThen(PubSub.publish(changes, message)),
228+ Effect.asVoid,
229+ );
230+ }),
231+ Effect.andThen(
232+ updateHealth(
233+ (current): DesktopTelemetryReceiverHealth => ({
234+ ...current,
235+ status: "stopped",
236+ lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message),
237+ }),
238+ ),
239+ ),
240+ Effect.catch((error) =>
241+ updateHealth(
242+ (current): DesktopTelemetryReceiverHealth => ({
243+ ...current,
244+ status: "degraded",
245+ lastError: Option.some(error.message),
246+ }),
247+ ),
248+ ),
249+ Effect.forkScoped,
250+ );
251+ }
252+
253+ return DesktopTelemetryReceiver.of({
254+ latest: Ref.get(latest),
255+ changes: Stream.fromPubSub(changes),
256+ health: Ref.get(health),
257+ healthChanges: Stream.fromPubSub(healthChanges),
258+ });
259+});
260+
261+export const layer = Layer.effect(DesktopTelemetryReceiver, make());
262+
263+export const layerTest = (
264+ overrides: Partial<DesktopTelemetryReceiverShape> = {},
265+): Layer.Layer<DesktopTelemetryReceiver> =>
266+ Layer.succeed(
267+ DesktopTelemetryReceiver,
268+ DesktopTelemetryReceiver.of({
269+ latest: Effect.succeedNone,
270+ changes: Stream.empty,
271+ health: Effect.succeed({
272+ status: "unavailable",
273+ lastSampleAt: Option.none(),
274+ lastError: Option.some("Desktop telemetry test implementation is unavailable."),
275+ }),
276+ healthChanges: Stream.empty,
277+ ...overrides,
278+ }),
279+ );
apps/server/src/resourceTelemetry/Model.test.ts+393−0
@@ -0,0 +1,393 @@
1+import {
2+ type DesktopElectronProcessMetric,
3+ type DesktopHostTelemetrySnapshot,
4+ type ResourceMonitorProcessSample,
5+ type ResourceMonitorSnapshotEvent,
6+} from "@t3tools/contracts";
7+import { describe, expect, it } from "@effect/vitest";
8+import * as DateTime from "effect/DateTime";
9+import * as Option from "effect/Option";
10+
11+import { emptyTelemetryCounters, mergeProcesses, type MergeProcessesResult } from "./Model.ts";
12+
13+const SERVER_PID = 100;
14+const BASE_TIME_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"));
15+
16+function processSample(
17+ input: Partial<ResourceMonitorProcessSample> &
18+ Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">,
19+): ResourceMonitorProcessSample {
20+ return {
21+ runTimeMs: 1_000,
22+ name: `process-${input.pid}`,
23+ command: `process-${input.pid}`,
24+ status: "Running",
25+ cpuPercent: 0,
26+ cpuTimeMs: 0,
27+ residentBytes: 1_024,
28+ virtualBytes: 2_048,
29+ ioReadBytes: 0,
30+ ioWriteBytes: 0,
31+ ioSemantics: "storage",
32+ ...input,
33+ };
34+}
35+
36+function nativeSnapshot(
37+ sampledAtUnixMs: number,
38+ processes: ReadonlyArray<ResourceMonitorProcessSample>,
39+ sequence = 1,
40+): ResourceMonitorSnapshotEvent {
41+ return {
42+ version: 1,
43+ type: "snapshot",
44+ sequence,
45+ sampledAtUnixMs,
46+ collectionDurationMicros: 250,
47+ scannedProcessCount: processes.length,
48+ retainedProcessCount: processes.length,
49+ inaccessibleProcessCount: 0,
50+ processes: [...processes],
51+ };
52+}
53+
54+function electronMetric(
55+ input: Partial<DesktopElectronProcessMetric> &
56+ Pick<DesktopElectronProcessMetric, "pid" | "creationTimeMs" | "type">,
57+): DesktopElectronProcessMetric {
58+ return {
59+ cpuPercent: 0,
60+ idleWakeupsPerSecond: 0,
61+ workingSetBytes: 1_024,
62+ peakWorkingSetBytes: 2_048,
63+ ...input,
64+ };
65+}
66+
67+function desktopSnapshot(
68+ sampledAtUnixMs: number,
69+ electronProcesses: ReadonlyArray<DesktopElectronProcessMetric>,
70+): DesktopHostTelemetrySnapshot {
71+ const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
72+ return {
73+ version: 1,
74+ type: "desktopTelemetry",
75+ sequence: 1,
76+ sampledAtUnixMs,
77+ power: {
78+ source: "electron-main",
79+ idle: "false",
80+ idleSeconds: 0,
81+ locked: "false",
82+ suspended: false,
83+ onBattery: "false",
84+ lowPowerMode: "unknown",
85+ thermalState: "nominal",
86+ stale: false,
87+ updatedAt: sampledAt,
88+ },
89+ speedLimitPercent: Option.none(),
90+ electronProcesses: [...electronProcesses],
91+ };
92+}
93+
94+function merge(input: {
95+ readonly native: ResourceMonitorSnapshotEvent;
96+ readonly desktop?: DesktopHostTelemetrySnapshot;
97+ readonly previous?: MergeProcessesResult;
98+ readonly sidecarPid?: number;
99+}): MergeProcessesResult {
100+ return mergeProcesses({
101+ serverPid: SERVER_PID,
102+ sidecarPid: Option.fromUndefinedOr(input.sidecarPid),
103+ fallbackSampledAtMs: input.native.sampledAtUnixMs,
104+ nativeSnapshot: Option.some(input.native),
105+ desktopSnapshot: Option.fromUndefinedOr(input.desktop),
106+ previous: input.previous?.previous ?? new Map(),
107+ counters: input.previous?.counters ?? emptyTelemetryCounters(),
108+ updatePrevious: true,
109+ });
110+}
111+
112+describe("resource telemetry process model", () => {
113+ it("builds complete descendant depths and isolates monitor overhead", () => {
114+ const result = merge({
115+ sidecarPid: 900,
116+ native: nativeSnapshot(BASE_TIME_MS, [
117+ processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
118+ processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }),
119+ processSample({ pid: 201, ppid: 200, startTimeMs: 3_000 }),
120+ processSample({ pid: 202, ppid: 201, startTimeMs: 4_000 }),
121+ processSample({ pid: 900, ppid: SERVER_PID, startTimeMs: 5_000 }),
122+ ]),
123+ });
124+
125+ expect(result.processes.map((process) => [process.identity.pid, process.depth])).toEqual([
126+ [100, 0],
127+ [200, 1],
128+ [201, 2],
129+ [202, 3],
130+ [900, 1],
131+ ]);
132+ expect(result.processes.find((process) => process.identity.pid === 900)?.category).toBe(
133+ "resource-monitor",
134+ );
135+ expect(result.groups.backend.processCount).toBe(4);
136+ expect(result.groups.monitor.processCount).toBe(1);
137+ expect(result.groups.monitor.processStarts).toBe(1);
138+ expect(result.groups.allT3.processStarts).toBe(5);
139+ });
140+
141+ it("deduplicates Electron metrics and classifies Electron descendants", () => {
142+ const electronStart = 10_000;
143+ const result = merge({
144+ native: nativeSnapshot(BASE_TIME_MS, [
145+ processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
146+ processSample({ pid: 300, ppid: 1, startTimeMs: electronStart }),
147+ processSample({ pid: 301, ppid: 300, startTimeMs: electronStart + 1 }),
148+ ]),
149+ desktop: desktopSnapshot(BASE_TIME_MS, [
150+ electronMetric({
151+ pid: 300,
152+ creationTimeMs: electronStart + 500,
153+ type: "Browser",
154+ name: "electron",
155+ }),
156+ electronMetric({
157+ pid: 301,
158+ creationTimeMs: electronStart + 500,
159+ type: "Utility",
160+ name: "network-service",
161+ }),
162+ ]),
163+ });
164+
165+ expect(result.processes.filter((process) => process.identity.pid === 300)).toHaveLength(1);
166+ expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe(
167+ "electron-main",
168+ );
169+ expect(result.processes.find((process) => process.identity.pid === 301)?.category).toBe(
170+ "electron-utility",
171+ );
172+ expect(result.processes.find((process) => process.identity.pid === 301)?.depth).toBe(1);
173+ expect(result.groups.electron.processCount).toBe(2);
174+ });
175+
176+ it("ignores stale Electron metrics after PID reuse", () => {
177+ const result = merge({
178+ native: nativeSnapshot(BASE_TIME_MS, [
179+ processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
180+ processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 50_000 }),
181+ ]),
182+ desktop: desktopSnapshot(BASE_TIME_MS, [
183+ electronMetric({
184+ pid: 300,
185+ creationTimeMs: 10_000,
186+ type: "Browser",
187+ }),
188+ ]),
189+ });
190+
191+ expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe(
192+ "server-child",
193+ );
194+ expect(result.groups.electron.processCount).toBe(0);
195+ });
196+
197+ it("derives rates from cumulative counters and preserves I/O semantics", () => {
198+ const first = merge({
199+ native: nativeSnapshot(BASE_TIME_MS, [
200+ processSample({
201+ pid: SERVER_PID,
202+ ppid: 1,
203+ startTimeMs: 1_000,
204+ cpuTimeMs: 1_000,
205+ ioReadBytes: 10_000,
206+ ioWriteBytes: 20_000,
207+ ioSemantics: "all-io",
208+ }),
209+ ]),
210+ });
211+ const second = merge({
212+ previous: first,
213+ native: nativeSnapshot(
214+ BASE_TIME_MS + 1_000,
215+ [
216+ processSample({
217+ pid: SERVER_PID,
218+ ppid: 1,
219+ startTimeMs: 1_000,
220+ cpuTimeMs: 1_250,
221+ ioReadBytes: 12_000,
222+ ioWriteBytes: 23_000,
223+ ioSemantics: "all-io",
224+ }),
225+ ],
226+ 2,
227+ ),
228+ });
229+ const server = second.processes[0]!;
230+
231+ expect(server.cpuPercent).toBe(25);
232+ expect(server.ioReadBytesPerSecond).toBe(2_000);
233+ expect(server.ioWriteBytesPerSecond).toBe(3_000);
234+ expect(server.ioSemantics).toBe("all-io");
235+ expect(second.groups.backend.cpuTimeMs).toBe(250);
236+ expect(second.groups.backend.ioReadBytes).toBe(2_000);
237+ expect(second.groups.backend.ioWriteBytes).toBe(3_000);
238+ });
239+
240+ it("preserves native rates while applying a desktop-only update", () => {
241+ const first = merge({
242+ native: nativeSnapshot(BASE_TIME_MS, [
243+ processSample({
244+ pid: SERVER_PID,
245+ ppid: 1,
246+ startTimeMs: 1_000,
247+ cpuTimeMs: 1_000,
248+ ioReadBytes: 10_000,
249+ ioWriteBytes: 20_000,
250+ }),
251+ ]),
252+ });
253+ const second = merge({
254+ previous: first,
255+ native: nativeSnapshot(
256+ BASE_TIME_MS + 1_000,
257+ [
258+ processSample({
259+ pid: SERVER_PID,
260+ ppid: 1,
261+ startTimeMs: 1_000,
262+ cpuTimeMs: 1_250,
263+ ioReadBytes: 12_000,
264+ ioWriteBytes: 23_000,
265+ }),
266+ ],
267+ 2,
268+ ),
269+ });
270+ const desktopOnly = mergeProcesses({
271+ serverPid: SERVER_PID,
272+ sidecarPid: Option.none(),
273+ fallbackSampledAtMs: BASE_TIME_MS + 1_000,
274+ nativeSnapshot: Option.some(
275+ nativeSnapshot(
276+ BASE_TIME_MS + 1_000,
277+ [
278+ processSample({
279+ pid: SERVER_PID,
280+ ppid: 1,
281+ startTimeMs: 1_000,
282+ cpuTimeMs: 1_250,
283+ ioReadBytes: 12_000,
284+ ioWriteBytes: 23_000,
285+ }),
286+ ],
287+ 2,
288+ ),
289+ ),
290+ desktopSnapshot: Option.some(desktopSnapshot(BASE_TIME_MS + 1_500, [])),
291+ previous: second.previous,
292+ counters: second.counters,
293+ updatePrevious: false,
294+ });
295+
296+ expect(desktopOnly.processes[0]?.cpuPercent).toBe(25);
297+ expect(desktopOnly.processes[0]?.ioReadBytesPerSecond).toBe(2_000);
298+ expect(desktopOnly.processes[0]?.ioWriteBytesPerSecond).toBe(3_000);
299+ });
300+
301+ it("resets deltas when counters decrease or the sampling gap is unsafe", () => {
302+ const first = merge({
303+ native: nativeSnapshot(BASE_TIME_MS, [
304+ processSample({
305+ pid: SERVER_PID,
306+ ppid: 1,
307+ startTimeMs: 1_000,
308+ cpuTimeMs: 1_000,
309+ ioReadBytes: 10_000,
310+ ioWriteBytes: 20_000,
311+ }),
312+ ]),
313+ });
314+ const decreased = merge({
315+ previous: first,
316+ native: nativeSnapshot(
317+ BASE_TIME_MS + 1_000,
318+ [
319+ processSample({
320+ pid: SERVER_PID,
321+ ppid: 1,
322+ startTimeMs: 1_000,
323+ cpuTimeMs: 100,
324+ ioReadBytes: 100,
325+ ioWriteBytes: 200,
326+ }),
327+ ],
328+ 2,
329+ ),
330+ });
331+ const delayed = merge({
332+ previous: decreased,
333+ native: nativeSnapshot(
334+ BASE_TIME_MS + 20_000,
335+ [
336+ processSample({
337+ pid: SERVER_PID,
338+ ppid: 1,
339+ startTimeMs: 1_000,
340+ cpuTimeMs: 10_000,
341+ ioReadBytes: 100_000,
342+ ioWriteBytes: 200_000,
343+ }),
344+ ],
345+ 3,
346+ ),
347+ });
348+
349+ expect(decreased.processes[0]?.cpuPercent).toBe(0);
350+ expect(decreased.processes[0]?.ioReadBytesPerSecond).toBe(0);
351+ expect(decreased.processes[0]?.ioWriteBytesPerSecond).toBe(0);
352+ expect(delayed.processes[0]?.cpuPercent).toBe(0);
353+ expect(delayed.processes[0]?.ioReadBytesPerSecond).toBe(0);
354+ expect(delayed.processes[0]?.ioWriteBytesPerSecond).toBe(0);
355+ expect(delayed.groups.backend.cpuTimeMs).toBe(0);
356+ expect(delayed.groups.backend.ioReadBytes).toBe(0);
357+ expect(delayed.groups.backend.ioWriteBytes).toBe(0);
358+ });
359+
360+ it("treats reused PIDs as an exit plus a new process", () => {
361+ const first = merge({
362+ native: nativeSnapshot(BASE_TIME_MS, [
363+ processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
364+ processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }),
365+ ]),
366+ });
367+ const second = merge({
368+ previous: first,
369+ native: nativeSnapshot(
370+ BASE_TIME_MS + 1_000,
371+ [
372+ processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
373+ processSample({
374+ pid: 200,
375+ ppid: SERVER_PID,
376+ startTimeMs: 9_000,
377+ cpuTimeMs: 999,
378+ ioReadBytes: 999,
379+ ioWriteBytes: 999,
380+ }),
381+ ],
382+ 2,
383+ ),
384+ });
385+ const reused = second.processes.find((process) => process.identity.pid === 200)!;
386+
387+ expect(reused.identity.startTimeMs).toBe(9_000);
388+ expect(reused.cpuPercent).toBe(0);
389+ expect(reused.ioReadBytesPerSecond).toBe(0);
390+ expect(second.groups.backend.processStarts).toBe(3);
391+ expect(second.groups.backend.processExits).toBe(1);
392+ });
393+});
apps/server/src/resourceTelemetry/Model.ts+542−0
@@ -0,0 +1,542 @@
1+import type {
2+ DesktopElectronProcessMetric,
3+ DesktopHostTelemetrySnapshot,
4+ ResourceMonitorProcessSample,
5+ ResourceMonitorSnapshotEvent,
6+ ResourceTelemetryAggregate,
7+ ResourceTelemetryProcess,
8+ ResourceTelemetryProcessCategory,
9+} from "@t3tools/contracts";
10+import * as DateTime from "effect/DateTime";
11+import * as Option from "effect/Option";
12+
13+const MAX_DELTA_INTERVAL_MS = 10_000;
14+const ELECTRON_IDENTITY_TOLERANCE_MS = 2_000;
15+
16+export interface ProcessState {
17+ readonly process: ResourceTelemetryProcess;
18+ readonly sampledAtMs: number;
19+}
20+
21+export interface GroupCounters {
22+ readonly cpuTimeMs: number;
23+ readonly ioReadBytes: number;
24+ readonly ioWriteBytes: number;
25+ readonly processStarts: number;
26+ readonly processExits: number;
27+}
28+
29+export interface TelemetryCounters {
30+ readonly backend: GroupCounters;
31+ readonly electron: GroupCounters;
32+ readonly monitor: GroupCounters;
33+ readonly allT3: GroupCounters;
34+}
35+
36+export interface ProcessDelta {
37+ readonly identityKey: string;
38+ readonly category: ResourceTelemetryProcessCategory;
39+ readonly cpuTimeMs: number;
40+ readonly ioReadBytes: number;
41+ readonly ioWriteBytes: number;
42+}
43+
44+export interface MergeProcessesInput {
45+ readonly serverPid: number;
46+ readonly sidecarPid: Option.Option<number>;
47+ readonly fallbackSampledAtMs: number;
48+ readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>;
49+ readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>;
50+ readonly previous: ReadonlyMap<string, ProcessState>;
51+ readonly counters: TelemetryCounters;
52+ readonly updatePrevious: boolean;
53+}
54+
55+export interface MergeProcessesResult {
56+ readonly sampledAtMs: number;
57+ readonly processes: ReadonlyArray<ResourceTelemetryProcess>;
58+ readonly previous: ReadonlyMap<string, ProcessState>;
59+ readonly counters: TelemetryCounters;
60+ readonly groups: {
61+ readonly backend: ResourceTelemetryAggregate;
62+ readonly electron: ResourceTelemetryAggregate;
63+ readonly monitor: ResourceTelemetryAggregate;
64+ readonly allT3: ResourceTelemetryAggregate;
65+ };
66+ readonly deltas: ReadonlyArray<ProcessDelta>;
67+}
68+
69+export const emptyGroupCounters = (): GroupCounters => ({
70+ cpuTimeMs: 0,
71+ ioReadBytes: 0,
72+ ioWriteBytes: 0,
73+ processStarts: 0,
74+ processExits: 0,
75+});
76+
77+export const emptyTelemetryCounters = (): TelemetryCounters => ({
78+ backend: emptyGroupCounters(),
79+ electron: emptyGroupCounters(),
80+ monitor: emptyGroupCounters(),
81+ allT3: emptyGroupCounters(),
82+});
83+
84+export function processIdentityKey(pid: number, startTimeMs: number): string {
85+ return `${pid}:${startTimeMs}`;
86+}
87+
88+function finiteNonNegative(value: number): number {
89+ return Number.isFinite(value) ? Math.max(0, value) : 0;
90+}
91+
92+function categoryGroup(
93+ category: ResourceTelemetryProcessCategory,
94+): "backend" | "electron" | "monitor" {
95+ if (category === "resource-monitor") return "monitor";
96+ if (category.startsWith("electron-")) return "electron";
97+ return "backend";
98+}
99+
100+function electronCategory(metric: DesktopElectronProcessMetric): ResourceTelemetryProcessCategory {
101+ switch (metric.type) {
102+ case "Browser":
103+ return "electron-main";
104+ case "Tab":
105+ return "electron-renderer";
106+ case "GPU":
107+ return "electron-gpu";
108+ default:
109+ return "electron-utility";
110+ }
111+}
112+
113+function matchElectronMetric(
114+ process: ResourceMonitorProcessSample,
115+ metricsByPid: ReadonlyMap<number, DesktopElectronProcessMetric>,
116+): DesktopElectronProcessMetric | undefined {
117+ const metric = metricsByPid.get(process.pid);
118+ if (!metric) return undefined;
119+ return Math.abs(metric.creationTimeMs - process.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS
120+ ? metric
121+ : undefined;
122+}
123+
124+function syntheticNativeSample(
125+ metric: DesktopElectronProcessMetric,
126+ sampledAtMs: number,
127+): ResourceMonitorProcessSample {
128+ return {
129+ pid: metric.pid,
130+ ppid: 0,
131+ startTimeMs: metric.creationTimeMs,
132+ runTimeMs: Math.max(0, sampledAtMs - metric.creationTimeMs),
133+ name: metric.name ?? metric.serviceName ?? metric.type,
134+ command: metric.name ?? metric.serviceName ?? metric.type,
135+ status: "Running",
136+ cpuPercent: metric.cpuPercent,
137+ cpuTimeMs: Math.max(0, Math.round((metric.cumulativeCpuSeconds ?? 0) * 1_000)),
138+ residentBytes: metric.workingSetBytes,
139+ virtualBytes: 0,
140+ ioReadBytes: 0,
141+ ioWriteBytes: 0,
142+ ioSemantics: "storage",
143+ };
144+}
145+
146+function processDepths(
147+ processes: ReadonlyArray<ResourceMonitorProcessSample>,
148+ roots: ReadonlySet<number>,
149+): ReadonlyMap<number, number> {
150+ const childrenByParent = new Map<number, number[]>();
151+ for (const process of processes) {
152+ const children = childrenByParent.get(process.ppid) ?? [];
153+ children.push(process.pid);
154+ childrenByParent.set(process.ppid, children);
155+ }
156+
157+ const depths = new Map<number, number>();
158+ const queue = [...roots].map((pid) => ({ pid, depth: 0 }));
159+ while (queue.length > 0) {
160+ const current = queue.shift();
161+ if (!current || depths.has(current.pid)) continue;
162+ depths.set(current.pid, current.depth);
163+ for (const childPid of childrenByParent.get(current.pid) ?? []) {
164+ queue.push({ pid: childPid, depth: current.depth + 1 });
165+ }
166+ }
167+ return depths;
168+}
169+
170+function isElectronDescendant(
171+ pid: number,
172+ processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>,
173+ electronPids: ReadonlySet<number>,
174+): boolean {
175+ const visited = new Set<number>();
176+ let currentPid = pid;
177+ while (!visited.has(currentPid)) {
178+ visited.add(currentPid);
179+ if (electronPids.has(currentPid)) return true;
180+ const current = processesByPid.get(currentPid);
181+ if (!current || current.ppid <= 0 || current.ppid === currentPid) return false;
182+ currentPid = current.ppid;
183+ }
184+ return false;
185+}
186+
187+function hasElectronAncestor(
188+ process: ResourceMonitorProcessSample,
189+ processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>,
190+ electronPids: ReadonlySet<number>,
191+): boolean {
192+ const visited = new Set<number>();
193+ let currentPid = process.ppid;
194+ while (currentPid > 0 && !visited.has(currentPid)) {
195+ visited.add(currentPid);
196+ if (electronPids.has(currentPid)) return true;
197+ const current = processesByPid.get(currentPid);
198+ if (!current || current.ppid === currentPid) return false;
199+ currentPid = current.ppid;
200+ }
201+ return false;
202+}
203+
204+function orderProcessTree(
205+ processes: ReadonlyArray<ResourceTelemetryProcess>,
206+ rootPids: ReadonlyArray<number>,
207+): ReadonlyArray<ResourceTelemetryProcess> {
208+ const processesByPid = new Map(processes.map((process) => [process.identity.pid, process]));
209+ const childrenByParent = new Map<number, ResourceTelemetryProcess[]>();
210+ for (const process of processes) {
211+ const children = childrenByParent.get(process.ppid) ?? [];
212+ children.push(process);
213+ childrenByParent.set(process.ppid, children);
214+ }
215+ for (const children of childrenByParent.values()) {
216+ children.sort((left, right) => left.identity.pid - right.identity.pid);
217+ }
218+
219+ const ordered: ResourceTelemetryProcess[] = [];
220+ const visited = new Set<number>();
221+ const visit = (process: ResourceTelemetryProcess): void => {
222+ if (visited.has(process.identity.pid)) return;
223+ visited.add(process.identity.pid);
224+ ordered.push(process);
225+ for (const child of childrenByParent.get(process.identity.pid) ?? []) {
226+ visit(child);
227+ }
228+ };
229+
230+ for (const rootPid of rootPids) {
231+ const root = processesByPid.get(rootPid);
232+ if (root) visit(root);
233+ }
234+ for (const process of processes.toSorted(
235+ (left, right) => left.depth - right.depth || left.identity.pid - right.identity.pid,
236+ )) {
237+ visit(process);
238+ }
239+ return ordered;
240+}
241+
242+function delta(input: {
243+ readonly current: number;
244+ readonly previous: number;
245+ readonly elapsedMs: number;
246+}): number {
247+ if (
248+ input.elapsedMs <= 0 ||
249+ input.elapsedMs > MAX_DELTA_INTERVAL_MS ||
250+ input.current < input.previous
251+ ) {
252+ return 0;
253+ }
254+ return input.current - input.previous;
255+}
256+
257+function incrementCounters(counters: GroupCounters, update: Partial<GroupCounters>): GroupCounters {
258+ return {
259+ cpuTimeMs: counters.cpuTimeMs + (update.cpuTimeMs ?? 0),
260+ ioReadBytes: counters.ioReadBytes + (update.ioReadBytes ?? 0),
261+ ioWriteBytes: counters.ioWriteBytes + (update.ioWriteBytes ?? 0),
262+ processStarts: counters.processStarts + (update.processStarts ?? 0),
263+ processExits: counters.processExits + (update.processExits ?? 0),
264+ };
265+}
266+
267+function applyLifecycleCounters(input: {
268+ readonly counters: TelemetryCounters;
269+ readonly deltas: ReadonlyArray<ProcessDelta>;
270+ readonly current: ReadonlyMap<string, ProcessState>;
271+ readonly previous: ReadonlyMap<string, ProcessState>;
272+}): TelemetryCounters {
273+ let backend = input.counters.backend;
274+ let electron = input.counters.electron;
275+ let monitor = input.counters.monitor;
276+ let allT3 = input.counters.allT3;
277+ for (const processDelta of input.deltas) {
278+ const group = categoryGroup(processDelta.category);
279+ switch (group) {
280+ case "backend":
281+ backend = incrementCounters(backend, processDelta);
282+ break;
283+ case "electron":
284+ electron = incrementCounters(electron, processDelta);
285+ break;
286+ case "monitor":
287+ monitor = incrementCounters(monitor, processDelta);
288+ break;
289+ }
290+ allT3 = incrementCounters(allT3, processDelta);
291+ }
292+
293+ for (const [identityKey, current] of input.current) {
294+ if (input.previous.has(identityKey)) continue;
295+ const group = categoryGroup(current.process.category);
296+ switch (group) {
297+ case "backend":
298+ backend = incrementCounters(backend, { processStarts: 1 });
299+ break;
300+ case "electron":
301+ electron = incrementCounters(electron, { processStarts: 1 });
302+ break;
303+ case "monitor":
304+ monitor = incrementCounters(monitor, { processStarts: 1 });
305+ break;
306+ }
307+ allT3 = incrementCounters(allT3, { processStarts: 1 });
308+ }
309+
310+ for (const [identityKey, previous] of input.previous) {
311+ if (input.current.has(identityKey)) continue;
312+ const group = categoryGroup(previous.process.category);
313+ switch (group) {
314+ case "backend":
315+ backend = incrementCounters(backend, { processExits: 1 });
316+ break;
317+ case "electron":
318+ electron = incrementCounters(electron, { processExits: 1 });
319+ break;
320+ case "monitor":
321+ monitor = incrementCounters(monitor, { processExits: 1 });
322+ break;
323+ }
324+ allT3 = incrementCounters(allT3, { processExits: 1 });
325+ }
326+
327+ return { backend, electron, monitor, allT3 };
328+}
329+
330+function aggregate(
331+ processes: ReadonlyArray<ResourceTelemetryProcess>,
332+ counters: GroupCounters,
333+): ResourceTelemetryAggregate {
334+ return {
335+ processCount: processes.length,
336+ currentCpuPercent: processes.reduce((total, process) => total + process.cpuPercent, 0),
337+ cpuTimeMs: counters.cpuTimeMs,
338+ currentRssBytes: processes.reduce((total, process) => total + process.residentBytes, 0),
339+ peakRssBytes: processes.reduce((total, process) => total + process.peakResidentBytes, 0),
340+ ioReadBytes: counters.ioReadBytes,
341+ ioWriteBytes: counters.ioWriteBytes,
342+ ioReadBytesPerSecond: processes.reduce(
343+ (total, process) => total + process.ioReadBytesPerSecond,
344+ 0,
345+ ),
346+ ioWriteBytesPerSecond: processes.reduce(
347+ (total, process) => total + process.ioWriteBytesPerSecond,
348+ 0,
349+ ),
350+ processStarts: counters.processStarts,
351+ processExits: counters.processExits,
352+ };
353+}
354+
355+export function mergeProcesses(input: MergeProcessesInput): MergeProcessesResult {
356+ const nativeProcesses = Option.match(input.nativeSnapshot, {
357+ onNone: () => [] as ReadonlyArray<ResourceMonitorProcessSample>,
358+ onSome: (snapshot) => snapshot.processes,
359+ });
360+ const electronMetrics = Option.match(input.desktopSnapshot, {
361+ onNone: () => [] as ReadonlyArray<DesktopElectronProcessMetric>,
362+ onSome: (snapshot) => snapshot.electronProcesses,
363+ });
364+ const sampledAtMs = Option.match(input.nativeSnapshot, {
365+ onNone: () =>
366+ Option.match(input.desktopSnapshot, {
367+ onNone: () => input.fallbackSampledAtMs,
368+ onSome: (snapshot) => snapshot.sampledAtUnixMs,
369+ }),
370+ onSome: (snapshot) => snapshot.sampledAtUnixMs,
371+ });
372+ const nativeByPid = new Map(nativeProcesses.map((process) => [process.pid, process]));
373+ const metricsByPid = new Map<number, DesktopElectronProcessMetric>();
374+ for (const metric of electronMetrics) {
375+ const nativeProcess = nativeByPid.get(metric.pid);
376+ if (!nativeProcess) {
377+ nativeByPid.set(metric.pid, syntheticNativeSample(metric, sampledAtMs));
378+ metricsByPid.set(metric.pid, metric);
379+ continue;
380+ }
381+ if (
382+ Math.abs(metric.creationTimeMs - nativeProcess.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS
383+ ) {
384+ metricsByPid.set(metric.pid, metric);
385+ }
386+ }
387+ const processes = [...nativeByPid.values()];
388+ const processesByPid = new Map(processes.map((process) => [process.pid, process]));
389+ const electronPids = new Set(metricsByPid.keys());
390+ const electronRootPids = [...electronPids]
391+ .filter((pid) => {
392+ const process = processesByPid.get(pid);
393+ return process === undefined
394+ ? true
395+ : !hasElectronAncestor(process, processesByPid, electronPids);
396+ })
397+ .toSorted((left, right) => left - right);
398+ const rootPids = [input.serverPid, ...electronRootPids];
399+ const roots = new Set(rootPids);
400+ const depths = processDepths(processes, roots);
401+ const childrenByParent = new Map<number, number[]>();
402+ for (const process of processes) {
403+ const children = childrenByParent.get(process.ppid) ?? [];
404+ children.push(process.pid);
405+ childrenByParent.set(process.ppid, children);
406+ }
407+
408+ const nextPrevious = new Map<string, ProcessState>();
409+ const processDeltas: ProcessDelta[] = [];
410+ const normalized = processes.map((process): ResourceTelemetryProcess => {
411+ const identityKey = processIdentityKey(process.pid, process.startTimeMs);
412+ const previous = input.previous.get(identityKey);
413+ const elapsedMs = previous ? sampledAtMs - previous.sampledAtMs : 0;
414+ const cpuTimeDelta = previous
415+ ? delta({
416+ current: process.cpuTimeMs,
417+ previous: previous.process.cpuTimeMs,
418+ elapsedMs,
419+ })
420+ : 0;
421+ const ioReadDelta = previous
422+ ? delta({
423+ current: process.ioReadBytes,
424+ previous: previous.process.ioReadBytes,
425+ elapsedMs,
426+ })
427+ : 0;
428+ const ioWriteDelta = previous
429+ ? delta({
430+ current: process.ioWriteBytes,
431+ previous: previous.process.ioWriteBytes,
432+ elapsedMs,
433+ })
434+ : 0;
435+ const electronMetric = matchElectronMetric(process, metricsByPid);
436+ const category: ResourceTelemetryProcessCategory =
437+ process.pid === input.serverPid
438+ ? "server"
439+ : Option.contains(input.sidecarPid, process.pid)
440+ ? "resource-monitor"
441+ : electronMetric
442+ ? electronCategory(electronMetric)
443+ : isElectronDescendant(process.pid, processesByPid, electronPids)
444+ ? "electron-utility"
445+ : "server-child";
446+ const firstSeenAt = previous?.process.firstSeenAt ?? DateTime.makeUnsafe(sampledAtMs);
447+ const preservePreviousRates = !input.updatePrevious && previous !== undefined;
448+ const cpuPercent = preservePreviousRates
449+ ? previous.process.cpuPercent
450+ : previous && elapsedMs > 0 && elapsedMs <= MAX_DELTA_INTERVAL_MS
451+ ? (cpuTimeDelta / elapsedMs) * 100
452+ : finiteNonNegative(process.cpuPercent);
453+ const normalizedProcess: ResourceTelemetryProcess = {
454+ identity: {
455+ pid: process.pid,
456+ startTimeMs: process.startTimeMs,
457+ },
458+ ppid: process.ppid,
459+ childPids: [...(childrenByParent.get(process.pid) ?? [])].toSorted(
460+ (left, right) => left - right,
461+ ),
462+ depth: depths.get(process.pid) ?? 0,
463+ name: process.name,
464+ command: process.command,
465+ status: process.status,
466+ category,
467+ ...(electronMetric ? { electronType: electronMetric.type } : {}),
468+ ...(electronMetric?.serviceName ? { electronServiceName: electronMetric.serviceName } : {}),
469+ cpuPercent: finiteNonNegative(cpuPercent),
470+ cpuTimeMs: process.cpuTimeMs,
471+ residentBytes: process.residentBytes,
472+ peakResidentBytes: Math.max(
473+ process.residentBytes,
474+ electronMetric?.peakWorkingSetBytes ?? 0,
475+ previous?.process.peakResidentBytes ?? 0,
476+ ),
477+ virtualBytes: process.virtualBytes,
478+ ioReadBytes: process.ioReadBytes,
479+ ioWriteBytes: process.ioWriteBytes,
480+ ioReadBytesPerSecond: preservePreviousRates
481+ ? previous.process.ioReadBytesPerSecond
482+ : elapsedMs > 0
483+ ? finiteNonNegative((ioReadDelta * 1_000) / elapsedMs)
484+ : 0,
485+ ioWriteBytesPerSecond: preservePreviousRates
486+ ? previous.process.ioWriteBytesPerSecond
487+ : elapsedMs > 0
488+ ? finiteNonNegative((ioWriteDelta * 1_000) / elapsedMs)
489+ : 0,
490+ ioSemantics: process.ioSemantics,
491+ ...(electronMetric ? { idleWakeupsPerSecond: electronMetric.idleWakeupsPerSecond } : {}),
492+ runTimeMs: process.runTimeMs,
493+ firstSeenAt,
494+ lastSeenAt: DateTime.makeUnsafe(sampledAtMs),
495+ };
496+ nextPrevious.set(identityKey, {
497+ process: normalizedProcess,
498+ sampledAtMs,
499+ });
500+ processDeltas.push({
501+ identityKey,
502+ category,
503+ cpuTimeMs: cpuTimeDelta,
504+ ioReadBytes: ioReadDelta,
505+ ioWriteBytes: ioWriteDelta,
506+ });
507+ return normalizedProcess;
508+ });
509+ const ordered = orderProcessTree(normalized, rootPids);
510+
511+ const counters = input.updatePrevious
512+ ? applyLifecycleCounters({
513+ counters: input.counters,
514+ deltas: processDeltas,
515+ current: nextPrevious,
516+ previous: input.previous,
517+ })
518+ : input.counters;
519+ const backendProcesses = ordered.filter(
520+ (process) => categoryGroup(process.category) === "backend",
521+ );
522+ const electronProcesses = ordered.filter(
523+ (process) => categoryGroup(process.category) === "electron",
524+ );
525+ const monitorProcesses = ordered.filter(
526+ (process) => categoryGroup(process.category) === "monitor",
527+ );
528+
529+ return {
530+ sampledAtMs,
531+ processes: ordered,
532+ previous: input.updatePrevious ? nextPrevious : input.previous,
533+ counters,
534+ groups: {
535+ backend: aggregate(backendProcesses, counters.backend),
536+ electron: aggregate(electronProcesses, counters.electron),
537+ monitor: aggregate(monitorProcesses, counters.monitor),
538+ allT3: aggregate(ordered, counters.allT3),
539+ },
540+ deltas: processDeltas,
541+ };
542+}
apps/server/src/resourceTelemetry/NativeTelemetryClient.ts+656−0
@@ -0,0 +1,656 @@
1+import type {
2+ ResourceMonitorCapabilities,
3+ ResourceMonitorCommand,
4+ ResourceMonitorEvent,
5+ ResourceMonitorExternalProcess,
6+ ResourceMonitorHelloEvent,
7+ ResourceMonitorSnapshotEvent,
8+ ResourceTelemetrySourceStatus,
9+} from "@t3tools/contracts";
10+import {
11+ RESOURCE_MONITOR_PROTOCOL_VERSION,
12+ ResourceMonitorCommand as ResourceMonitorCommandSchema,
13+ ResourceMonitorEvent as ResourceMonitorEventSchema,
14+} from "@t3tools/contracts";
15+import * as Cause from "effect/Cause";
16+import * as Context from "effect/Context";
17+import * as Crypto from "effect/Crypto";
18+import * as DateTime from "effect/DateTime";
19+import * as Deferred from "effect/Deferred";
20+import * as Duration from "effect/Duration";
21+import * as Effect from "effect/Effect";
22+import * as Fiber from "effect/Fiber";
23+import * as Layer from "effect/Layer";
24+import * as Option from "effect/Option";
25+import * as PubSub from "effect/PubSub";
26+import * as Queue from "effect/Queue";
27+import * as Ref from "effect/Ref";
28+import * as Result from "effect/Result";
29+import * as Schema from "effect/Schema";
30+import * as Semaphore from "effect/Semaphore";
31+import * as Stream from "effect/Stream";
32+import * as Ndjson from "effect/unstable/encoding/Ndjson";
33+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
34+
35+import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";
36+import { ServerConfig } from "../config.ts";
37+
38+const SAMPLE_INTERVAL_MS = 1_000;
39+const HANDSHAKE_TIMEOUT = Duration.seconds(5);
40+const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5);
41+const INITIAL_RESTART_DELAY = Duration.millis(500);
42+const MAX_RESTART_DELAY = Duration.seconds(10);
43+const FAILURE_WINDOW_MS = 60_000;
44+const MAX_FAILURES_PER_WINDOW = 5;
45+
46+export class NativeTelemetrySpawnFailed extends Schema.TaggedErrorClass<NativeTelemetrySpawnFailed>()(
47+ "NativeTelemetrySpawnFailed",
48+ {
49+ path: Schema.String,
50+ cause: Schema.Defect(),
51+ },
52+) {
53+ override get message(): string {
54+ return `Failed to start resource monitor '${this.path}'.`;
55+ }
56+}
57+
58+export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass<NativeTelemetryHandshakeTimedOut>()(
59+ "NativeTelemetryHandshakeTimedOut",
60+ {
61+ timeoutMs: Schema.Number,
62+ },
63+) {
64+ override get message(): string {
65+ return `Resource monitor handshake timed out after ${this.timeoutMs}ms.`;
66+ }
67+}
68+
69+export class NativeTelemetryProtocolMismatch extends Schema.TaggedErrorClass<NativeTelemetryProtocolMismatch>()(
70+ "NativeTelemetryProtocolMismatch",
71+ {
72+ expectedVersion: Schema.Number,
73+ receivedVersion: Schema.Number,
74+ },
75+) {
76+ override get message(): string {
77+ return `Resource monitor protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`;
78+ }
79+}
80+
81+export class NativeTelemetryDecodeFailed extends Schema.TaggedErrorClass<NativeTelemetryDecodeFailed>()(
82+ "NativeTelemetryDecodeFailed",
83+ {
84+ cause: Schema.Defect(),
85+ },
86+) {
87+ override get message(): string {
88+ return "Failed to decode resource monitor output.";
89+ }
90+}
91+
92+export class NativeTelemetryCommandFailed extends Schema.TaggedErrorClass<NativeTelemetryCommandFailed>()(
93+ "NativeTelemetryCommandFailed",
94+ {
95+ operation: Schema.String,
96+ cause: Schema.Defect(),
97+ },
98+) {
99+ override get message(): string {
100+ return `Resource monitor command '${this.operation}' failed.`;
101+ }
102+}
103+
104+export class NativeTelemetryExited extends Schema.TaggedErrorClass<NativeTelemetryExited>()(
105+ "NativeTelemetryExited",
106+ {
107+ exitCode: Schema.Number,
108+ },
109+) {
110+ override get message(): string {
111+ return `Resource monitor exited with code ${this.exitCode}.`;
112+ }
113+}
114+
115+export class NativeTelemetryUnavailable extends Schema.TaggedErrorClass<NativeTelemetryUnavailable>()(
116+ "NativeTelemetryUnavailable",
117+ {
118+ reason: Schema.String,
119+ },
120+) {
121+ override get message(): string {
122+ return `Resource monitor is unavailable: ${this.reason}`;
123+ }
124+}
125+
126+export type NativeTelemetryClientError =
127+ | NativeTelemetrySpawnFailed
128+ | NativeTelemetryHandshakeTimedOut
129+ | NativeTelemetryProtocolMismatch
130+ | NativeTelemetryDecodeFailed
131+ | NativeTelemetryCommandFailed
132+ | NativeTelemetryExited
133+ | NativeTelemetryUnavailable;
134+
135+export interface NativeTelemetryClientHealth {
136+ readonly status: ResourceTelemetrySourceStatus;
137+ readonly hello: Option.Option<ResourceMonitorHelloEvent>;
138+ readonly lastSampleAt: Option.Option<DateTime.Utc>;
139+ readonly lastError: Option.Option<string>;
140+ readonly restartCount: number;
141+}
142+
143+export interface NativeTelemetryClientShape {
144+ readonly capabilities: Effect.Effect<ResourceMonitorCapabilities, NativeTelemetryClientError>;
145+ readonly snapshots: Stream.Stream<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>;
146+ readonly setExternalProcesses: (
147+ processes: ReadonlyArray<ResourceMonitorExternalProcess>,
148+ ) => Effect.Effect<void, NativeTelemetryClientError>;
149+ readonly sampleNow: Effect.Effect<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>;
150+ readonly retry: Effect.Effect<boolean>;
151+ readonly health: Effect.Effect<NativeTelemetryClientHealth>;
152+ readonly healthChanges: Stream.Stream<NativeTelemetryClientHealth>;
153+}
154+
155+export class NativeTelemetryClient extends Context.Service<
156+ NativeTelemetryClient,
157+ NativeTelemetryClientShape
158+>()("t3/resourceTelemetry/NativeTelemetryClient") {}
159+
160+interface ClientState {
161+ readonly status: ResourceTelemetrySourceStatus;
162+ readonly handle: Option.Option<ChildProcessSpawner.ChildProcessHandle>;
163+ readonly hello: Option.Option<ResourceMonitorHelloEvent>;
164+ readonly lastSampleAt: Option.Option<DateTime.Utc>;
165+ readonly lastError: Option.Option<string>;
166+ readonly restartCount: number;
167+}
168+
169+const initialState: ClientState = {
170+ status: "starting",
171+ handle: Option.none(),
172+ hello: Option.none(),
173+ lastSampleAt: Option.none(),
174+ lastError: Option.none(),
175+ restartCount: 0,
176+};
177+
178+function toHealth(state: ClientState): NativeTelemetryClientHealth {
179+ return {
180+ status: state.status,
181+ hello: state.hello,
182+ lastSampleAt: state.lastSampleAt,
183+ lastError: state.lastError,
184+ restartCount: state.restartCount,
185+ };
186+}
187+
188+const decodeMonitorEvent: (
189+ value: unknown,
190+) => Effect.Effect<ResourceMonitorEvent, Schema.SchemaError> = Schema.decodeUnknownEffect(
191+ ResourceMonitorEventSchema,
192+);
193+const encodeMonitorCommand = Schema.encodeEffect(
194+ Schema.fromJsonString(ResourceMonitorCommandSchema),
195+);
196+const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch);
197+const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed);
198+const isCommandFailed = Schema.is(NativeTelemetryCommandFailed);
199+
200+function eventVersion(value: unknown): number | undefined {
201+ if (typeof value !== "object" || value === null) return undefined;
202+ const version = Reflect.get(value, "version");
203+ return typeof version === "number" ? version : undefined;
204+}
205+
206+function restartDelay(attempt: number): Duration.Duration {
207+ return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY);
208+}
209+
210+function errorMessage(error: NativeTelemetryClientError): string {
211+ return error.message;
212+}
213+
214+export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () {
215+ const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary;
216+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
217+ const crypto = yield* Crypto.Crypto;
218+ const config = yield* ServerConfig;
219+ const state = yield* Ref.make(initialState);
220+ const externalProcesses = yield* Ref.make<ReadonlyArray<ResourceMonitorExternalProcess>>([]);
221+ const pendingSamples = yield* Ref.make(
222+ new Map<string, Deferred.Deferred<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>>(),
223+ );
224+ const snapshots = yield* PubSub.sliding<ResourceMonitorSnapshotEvent>(8);
225+ const healthChanges = yield* PubSub.sliding<NativeTelemetryClientHealth>(4);
226+ const retryQueue = yield* Queue.sliding<void>(1);
227+ const commandMutex = yield* Semaphore.make(1);
228+ const publishHealth = Ref.get(state).pipe(
229+ Effect.map(toHealth),
230+ Effect.flatMap((health) => PubSub.publish(healthChanges, health)),
231+ Effect.asVoid,
232+ );
233+
234+ const failPending = (error: NativeTelemetryClientError) =>
235+ Ref.getAndSet(pendingSamples, new Map()).pipe(
236+ Effect.flatMap((pending) =>
237+ Effect.forEach(pending.values(), (deferred) => Deferred.fail(deferred, error), {
238+ discard: true,
239+ }),
240+ ),
241+ );
242+
243+ const writeCommand = (
244+ handle: ChildProcessSpawner.ChildProcessHandle,
245+ command: ResourceMonitorCommand,
246+ ): Effect.Effect<void, NativeTelemetryClientError> =>
247+ commandMutex.withPermits(1)(
248+ encodeMonitorCommand(command).pipe(
249+ Effect.map((encoded) => `${encoded}\n`),
250+ Effect.mapError(
251+ (cause) =>
252+ new NativeTelemetryCommandFailed({
253+ operation: command.type,
254+ cause,
255+ }),
256+ ),
257+ Effect.flatMap((encoded) =>
258+ Stream.run(Stream.encodeText(Stream.make(encoded)), handle.stdin),
259+ ),
260+ Effect.mapError(
261+ (cause) =>
262+ new NativeTelemetryCommandFailed({
263+ operation: command.type,
264+ cause,
265+ }),
266+ ),
267+ ),
268+ );
269+
270+ const processEvent = (
271+ event: ResourceMonitorEvent,
272+ helloDeferred: Deferred.Deferred<ResourceMonitorHelloEvent>,
273+ ): Effect.Effect<void, NativeTelemetryClientError> => {
274+ switch (event.type) {
275+ case "hello":
276+ return Ref.update(state, (current) => ({
277+ ...current,
278+ status: "healthy" as const,
279+ hello: Option.some(event),
280+ lastError: Option.none(),
281+ })).pipe(
282+ Effect.andThen(publishHealth),
283+ Effect.andThen(Deferred.succeed(helloDeferred, event)),
284+ Effect.asVoid,
285+ );
286+ case "snapshot":
287+ return Effect.gen(function* () {
288+ const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs);
289+ yield* Ref.update(state, (current) => ({
290+ ...current,
291+ status: "healthy" as const,
292+ lastSampleAt: Option.some(sampledAt),
293+ lastError: Option.none(),
294+ }));
295+ yield* PubSub.publish(snapshots, event);
296+ if (event.requestId) {
297+ const deferred = yield* Ref.modify(pendingSamples, (pending) => {
298+ const next = new Map(pending);
299+ const current = next.get(event.requestId!);
300+ next.delete(event.requestId!);
301+ return [Option.fromUndefinedOr(current), next];
302+ });
303+ if (Option.isSome(deferred)) {
304+ yield* Deferred.succeed(deferred.value, event);
305+ }
306+ }
307+ });
308+ case "error":
309+ return Ref.update(state, (current) => ({
310+ ...current,
311+ status: "degraded" as const,
312+ lastError: Option.some(event.message),
313+ })).pipe(
314+ Effect.andThen(publishHealth),
315+ Effect.andThen(
316+ event.recoverable
317+ ? Effect.void
318+ : Effect.fail(
319+ new NativeTelemetryCommandFailed({
320+ operation: event.code,
321+ cause: event.message,
322+ }),
323+ ),
324+ ),
325+ );
326+ }
327+ };
328+
329+ const runAttempt: Effect.Effect<void, NativeTelemetryClientError> = Effect.scoped(
330+ Effect.gen(function* () {
331+ const executablePath = yield* binary.resolve.pipe(
332+ Effect.mapError(
333+ (error) =>
334+ new NativeTelemetryUnavailable({
335+ reason: error.message,
336+ }),
337+ ),
338+ );
339+ const command = ChildProcess.make(executablePath, [], {
340+ cwd: config.cwd,
341+ stdin: {
342+ stream: "pipe",
343+ endOnDone: false,
344+ },
345+ stdout: "pipe",
346+ stderr: "pipe",
347+ killSignal: "SIGTERM",
348+ forceKillAfter: Duration.seconds(2),
349+ });
350+ const handle = yield* Effect.acquireRelease(
351+ spawner
352+ .spawn(command)
353+ .pipe(
354+ Effect.mapError(
355+ (cause) => new NativeTelemetrySpawnFailed({ path: executablePath, cause }),
356+ ),
357+ ),
358+ (child) => child.kill().pipe(Effect.ignore),
359+ );
360+ yield* Ref.update(state, (current) => ({
361+ ...current,
362+ status: "starting" as const,
363+ handle: Option.some(handle),
364+ hello: Option.none(),
365+ }));
366+ yield* publishHealth;
367+
368+ const helloDeferred = yield* Deferred.make<ResourceMonitorHelloEvent>();
369+ const eventFiber = yield* handle.stdout.pipe(
370+ Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })),
371+ Stream.mapEffect(
372+ (
373+ value,
374+ ): Effect.Effect<
375+ ResourceMonitorEvent,
376+ NativeTelemetryProtocolMismatch | NativeTelemetryDecodeFailed
377+ > => {
378+ const version = eventVersion(value);
379+ if (version !== undefined && version !== RESOURCE_MONITOR_PROTOCOL_VERSION) {
380+ return Effect.fail(
381+ new NativeTelemetryProtocolMismatch({
382+ expectedVersion: RESOURCE_MONITOR_PROTOCOL_VERSION,
383+ receivedVersion: version,
384+ }),
385+ );
386+ }
387+ return decodeMonitorEvent(value).pipe(
388+ Effect.mapError((cause) => new NativeTelemetryDecodeFailed({ cause })),
389+ );
390+ },
391+ ),
392+ Stream.runForEach((event) => processEvent(event, helloDeferred)),
393+ Effect.mapError((cause) =>
394+ isProtocolMismatch(cause) || isDecodeFailed(cause) || isCommandFailed(cause)
395+ ? cause
396+ : new NativeTelemetryDecodeFailed({ cause }),
397+ ),
398+ Effect.forkScoped,
399+ );
400+ yield* handle.stderr.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped);
401+
402+ const hello = yield* Deferred.await(helloDeferred).pipe(
403+ Effect.timeoutOption(HANDSHAKE_TIMEOUT),
404+ Effect.flatMap(
405+ Option.match({
406+ onNone: () =>
407+ Effect.fail(
408+ new NativeTelemetryHandshakeTimedOut({
409+ timeoutMs: Duration.toMillis(HANDSHAKE_TIMEOUT),
410+ }),
411+ ),
412+ onSome: Effect.succeed,
413+ }),
414+ ),
415+ );
416+ yield* writeCommand(handle, {
417+ version: RESOURCE_MONITOR_PROTOCOL_VERSION,
418+ type: "configure",
419+ rootPid: process.pid,
420+ sampleIntervalMs: SAMPLE_INTERVAL_MS,
421+ externalProcesses: [...(yield* Ref.get(externalProcesses))],
422+ });
423+ yield* Ref.update(state, (current) => ({
424+ ...current,
425+ status: "healthy" as const,
426+ hello: Option.some(hello),
427+ }));
428+
429+ const exitEffect = handle.exitCode.pipe(
430+ Effect.mapError(
431+ (cause) =>
432+ new NativeTelemetryCommandFailed({
433+ operation: "waitForExit",
434+ cause,
435+ }),
436+ ),
437+ Effect.flatMap((exitCode) =>
438+ Effect.fail(new NativeTelemetryExited({ exitCode: Number(exitCode) })),
439+ ),
440+ );
441+ const decoderEffect = Fiber.join(eventFiber).pipe(
442+ Effect.andThen(Effect.fail(new NativeTelemetryExited({ exitCode: -1 }))),
443+ );
444+ return yield* Effect.raceFirst(exitEffect, decoderEffect);
445+ }),
446+ ).pipe(
447+ Effect.ensuring(
448+ Ref.update(state, (current) => ({
449+ ...current,
450+ handle: Option.none(),
451+ })),
452+ ),
453+ );
454+
455+ yield* Effect.gen(function* () {
456+ let failures: ReadonlyArray<number> = [];
457+ let restartAttempt = 0;
458+
459+ while (true) {
460+ const result = yield* Effect.result(runAttempt);
461+ if (Result.isSuccess(result)) {
462+ return;
463+ }
464+
465+ const error = result.failure;
466+ const now = DateTime.toEpochMillis(yield* DateTime.now);
467+ failures = [...failures.filter((failedAt) => now - failedAt <= FAILURE_WINDOW_MS), now];
468+ const exhausted = failures.length >= MAX_FAILURES_PER_WINDOW;
469+ yield* Ref.update(state, (current) => ({
470+ ...current,
471+ status: exhausted ? ("unavailable" as const) : ("degraded" as const),
472+ hello: Option.none(),
473+ lastError: Option.some(errorMessage(error)),
474+ restartCount: current.restartCount + 1,
475+ }));
476+ yield* publishHealth;
477+ yield* failPending(error);
478+
479+ if (exhausted) {
480+ yield* Queue.take(retryQueue);
481+ failures = [];
482+ restartAttempt = 0;
483+ yield* Ref.update(state, (current) => ({
484+ ...current,
485+ status: "starting" as const,
486+ hello: Option.none(),
487+ lastError: Option.none(),
488+ }));
489+ yield* publishHealth;
490+ continue;
491+ }
492+
493+ const manuallyRetried = yield* Effect.raceFirst(
494+ Effect.sleep(restartDelay(restartAttempt)).pipe(Effect.as(false)),
495+ Queue.take(retryQueue).pipe(Effect.as(true)),
496+ );
497+ restartAttempt = manuallyRetried ? 0 : restartAttempt + 1;
498+ }
499+ }).pipe(
500+ Effect.catchCause((cause) =>
501+ Cause.hasInterruptsOnly(cause)
502+ ? Effect.void
503+ : Ref.update(state, (current) => ({
504+ ...current,
505+ status: "unavailable" as const,
506+ hello: Option.none(),
507+ lastError: Option.some(Cause.pretty(cause)),
508+ })).pipe(
509+ Effect.andThen(publishHealth),
510+ Effect.andThen(
511+ Effect.logWarning("Resource monitor supervisor failed", {
512+ cause: Cause.pretty(cause),
513+ }),
514+ ),
515+ ),
516+ ),
517+ Effect.forkScoped,
518+ );
519+
520+ const setExternalProcesses: NativeTelemetryClientShape["setExternalProcesses"] = (processes) =>
521+ Effect.gen(function* () {
522+ yield* Ref.set(externalProcesses, [...processes]);
523+ const current = yield* Ref.get(state);
524+ if (Option.isNone(current.handle)) return;
525+ yield* writeCommand(current.handle.value, {
526+ version: RESOURCE_MONITOR_PROTOCOL_VERSION,
527+ type: "setExternalProcesses",
528+ processes: [...processes],
529+ });
530+ });
531+
532+ const sampleNow: NativeTelemetryClientShape["sampleNow"] = Effect.gen(function* () {
533+ const current = yield* Ref.get(state);
534+ if (Option.isNone(current.handle)) {
535+ return yield* new NativeTelemetryUnavailable({
536+ reason: Option.getOrElse(current.lastError, () => "sidecar is not running"),
537+ });
538+ }
539+
540+ const requestId = yield* crypto.randomUUIDv4.pipe(
541+ Effect.mapError(
542+ (cause) =>
543+ new NativeTelemetryCommandFailed({
544+ operation: "createRequestId",
545+ cause,
546+ }),
547+ ),
548+ );
549+ const deferred = yield* Deferred.make<
550+ ResourceMonitorSnapshotEvent,
551+ NativeTelemetryClientError
552+ >();
553+ yield* Ref.update(pendingSamples, (pending) => {
554+ const next = new Map(pending);
555+ next.set(requestId, deferred);
556+ return next;
557+ });
558+ return yield* writeCommand(current.handle.value, {
559+ version: RESOURCE_MONITOR_PROTOCOL_VERSION,
560+ type: "sampleNow",
561+ requestId,
562+ }).pipe(
563+ Effect.andThen(
564+ Deferred.await(deferred).pipe(
565+ Effect.timeoutOption(SAMPLE_REQUEST_TIMEOUT),
566+ Effect.flatMap(
567+ Option.match({
568+ onNone: () =>
569+ Effect.fail(
570+ new NativeTelemetryCommandFailed({
571+ operation: "sampleNow",
572+ cause: "sample request timed out",
573+ }),
574+ ),
575+ onSome: Effect.succeed,
576+ }),
577+ ),
578+ ),
579+ ),
580+ Effect.ensuring(
581+ Ref.update(pendingSamples, (pending) => {
582+ const next = new Map(pending);
583+ next.delete(requestId);
584+ return next;
585+ }),
586+ ),
587+ );
588+ });
589+
590+ const health = Ref.get(state).pipe(Effect.map(toHealth));
591+
592+ return NativeTelemetryClient.of({
593+ capabilities: Ref.get(state).pipe(
594+ Effect.flatMap((current) =>
595+ Option.match(current.hello, {
596+ onNone: () =>
597+ Effect.fail(
598+ new NativeTelemetryUnavailable({
599+ reason: Option.getOrElse(current.lastError, () => "handshake is incomplete"),
600+ }),
601+ ),
602+ onSome: (hello) => Effect.succeed(hello.capabilities),
603+ }),
604+ ),
605+ ),
606+ snapshots: Stream.fromPubSub(snapshots),
607+ setExternalProcesses,
608+ sampleNow,
609+ retry: Ref.get(state).pipe(
610+ Effect.flatMap((current) =>
611+ current.status === "healthy" || current.status === "starting"
612+ ? Effect.succeed(false)
613+ : Queue.offer(retryQueue, undefined).pipe(Effect.as(true)),
614+ ),
615+ ),
616+ health,
617+ healthChanges: Stream.fromPubSub(healthChanges),
618+ });
619+});
620+
621+export const layer = Layer.effect(NativeTelemetryClient, make());
622+
623+export const layerTest = (
624+ overrides: Partial<NativeTelemetryClientShape> = {},
625+): Layer.Layer<NativeTelemetryClient> =>
626+ Layer.succeed(
627+ NativeTelemetryClient,
628+ NativeTelemetryClient.of({
629+ capabilities: Effect.succeed({
630+ cumulativeCpuTime: true,
631+ currentCpuPercent: true,
632+ residentMemory: true,
633+ virtualMemory: true,
634+ ioBytes: true,
635+ processStartTime: true,
636+ processTree: true,
637+ }),
638+ snapshots: Stream.empty,
639+ setExternalProcesses: () => Effect.void,
640+ sampleNow: Effect.fail(
641+ new NativeTelemetryUnavailable({
642+ reason: "No resource monitor sample was configured for this test.",
643+ }),
644+ ),
645+ retry: Effect.succeed(false),
646+ health: Effect.succeed({
647+ status: "unavailable",
648+ hello: Option.none(),
649+ lastSampleAt: Option.none(),
650+ lastError: Option.some("Resource monitor test implementation is unavailable."),
651+ restartCount: 0,
652+ }),
653+ healthChanges: Stream.empty,
654+ ...overrides,
655+ }),
656+ );
apps/server/src/resourceTelemetry/ResourceAttribution.ts+73−0
@@ -0,0 +1,73 @@
1+import type { ResourceAttributionEntry, ResourceAttributionSnapshot } from "@t3tools/contracts";
2+import * as Context from "effect/Context";
3+import * as DateTime from "effect/DateTime";
4+import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
6+import * as Ref from "effect/Ref";
7+
8+export interface ResourceAttributionRecord {
9+ readonly component: string;
10+ readonly operation: string;
11+ readonly logicalReadBytes?: number;
12+ readonly logicalWriteBytes?: number;
13+ readonly count?: number;
14+ readonly durationMs?: number;
15+}
16+
17+export interface ResourceAttributionShape {
18+ readonly record: (input: ResourceAttributionRecord) => Effect.Effect<void>;
19+ readonly snapshot: Effect.Effect<ResourceAttributionSnapshot>;
20+}
21+
22+export class ResourceAttribution extends Context.Service<
23+ ResourceAttribution,
24+ ResourceAttributionShape
25+>()("t3/resourceTelemetry/ResourceAttribution") {}
26+
27+function key(input: Pick<ResourceAttributionRecord, "component" | "operation">): string {
28+ return `${input.component}\u0000${input.operation}`;
29+}
30+
31+function nonNegativeInteger(value: number | undefined, fallback: number): number {
32+ if (value === undefined) return fallback;
33+ if (!Number.isFinite(value)) return 0;
34+ return Math.max(0, Math.round(value));
35+}
36+
37+export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () {
38+ const entries = yield* Ref.make(new Map<string, ResourceAttributionEntry>());
39+
40+ const record: ResourceAttributionShape["record"] = (input) =>
41+ Ref.update(entries, (current) => {
42+ const next = new Map(current);
43+ const entryKey = key(input);
44+ const existing = next.get(entryKey);
45+ next.set(entryKey, {
46+ component: input.component,
47+ operation: input.operation,
48+ logicalReadBytes:
49+ (existing?.logicalReadBytes ?? 0) + nonNegativeInteger(input.logicalReadBytes, 0),
50+ logicalWriteBytes:
51+ (existing?.logicalWriteBytes ?? 0) + nonNegativeInteger(input.logicalWriteBytes, 0),
52+ count: (existing?.count ?? 0) + nonNegativeInteger(input.count, 1),
53+ durationMs: (existing?.durationMs ?? 0) + nonNegativeInteger(input.durationMs, 0),
54+ });
55+ return next;
56+ });
57+
58+ return ResourceAttribution.of({
59+ record,
60+ snapshot: Effect.gen(function* () {
61+ const readAt = yield* DateTime.now;
62+ const current = yield* Ref.get(entries);
63+ return {
64+ readAt,
65+ entries: [...current.values()].toSorted(
66+ (left, right) => right.logicalWriteBytes - left.logicalWriteBytes,
67+ ),
68+ };
69+ }),
70+ });
71+});
72+
73+export const layer = Layer.effect(ResourceAttribution, make());
apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts+80−0
@@ -0,0 +1,80 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import {
3+ HostProcessArchitecture,
4+ HostProcessEnvironment,
5+ HostProcessPlatform,
6+} from "@t3tools/shared/hostProcess";
7+import { assert, describe, it } from "@effect/vitest";
8+import * as Effect from "effect/Effect";
9+import * as FileSystem from "effect/FileSystem";
10+
11+import { ServerConfig } from "../config.ts";
12+import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";
13+
14+describe("ResourceMonitorBinary", () => {
15+ it.effect("resolves an executable override", () =>
16+ Effect.gen(function* () {
17+ const fileSystem = yield* FileSystem.FileSystem;
18+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
19+ prefix: "t3-resource-monitor-binary-",
20+ });
21+ const binaryPath = `${baseDir}/t3-resource-monitor`;
22+ yield* fileSystem.writeFileString(binaryPath, "binary");
23+ yield* fileSystem.chmod(binaryPath, 0o755);
24+
25+ const service = yield* ResourceMonitorBinary.make().pipe(
26+ Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
27+ Effect.provideService(HostProcessPlatform, "linux"),
28+ Effect.provideService(HostProcessArchitecture, "x64"),
29+ Effect.provideService(HostProcessEnvironment, {
30+ T3CODE_RESOURCE_MONITOR_PATH: binaryPath,
31+ }),
32+ );
33+
34+ assert.equal(yield* service.resolve, binaryPath);
35+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
36+ );
37+
38+ it.effect("rejects a non-executable POSIX override", () =>
39+ Effect.gen(function* () {
40+ const fileSystem = yield* FileSystem.FileSystem;
41+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
42+ prefix: "t3-resource-monitor-binary-",
43+ });
44+ const binaryPath = `${baseDir}/t3-resource-monitor`;
45+ yield* fileSystem.writeFileString(binaryPath, "binary");
46+ yield* fileSystem.chmod(binaryPath, 0o644);
47+
48+ const service = yield* ResourceMonitorBinary.make().pipe(
49+ Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
50+ Effect.provideService(HostProcessPlatform, "linux"),
51+ Effect.provideService(HostProcessArchitecture, "x64"),
52+ Effect.provideService(HostProcessEnvironment, {
53+ T3CODE_RESOURCE_MONITOR_PATH: binaryPath,
54+ }),
55+ );
56+ const error = yield* Effect.flip(service.resolve);
57+
58+ assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryNotExecutable);
59+ assert.equal(error.path, binaryPath);
60+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
61+ );
62+
63+ it.effect("rejects unsupported platform and architecture pairs", () =>
64+ Effect.gen(function* () {
65+ const fileSystem = yield* FileSystem.FileSystem;
66+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
67+ prefix: "t3-resource-monitor-binary-",
68+ });
69+ const service = yield* ResourceMonitorBinary.make().pipe(
70+ Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
71+ Effect.provideService(HostProcessPlatform, "freebsd"),
72+ Effect.provideService(HostProcessArchitecture, "ia32"),
73+ Effect.provideService(HostProcessEnvironment, {}),
74+ );
75+ const error = yield* Effect.flip(service.resolve);
76+
77+ assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported);
78+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
79+ );
80+});
apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts+193−0
@@ -0,0 +1,193 @@
1+import {
2+ HostProcessArchitecture,
3+ HostProcessEnvironment,
4+ HostProcessPlatform,
5+} from "@t3tools/shared/hostProcess";
6+import * as Context from "effect/Context";
7+import * as Effect from "effect/Effect";
8+import * as FileSystem from "effect/FileSystem";
9+import * as Layer from "effect/Layer";
10+import * as Option from "effect/Option";
11+import * as Path from "effect/Path";
12+import * as Schema from "effect/Schema";
13+
14+import { ServerConfig } from "../config.ts";
15+
16+export class ResourceMonitorBinaryUnsupported extends Schema.TaggedErrorClass<ResourceMonitorBinaryUnsupported>()(
17+ "ResourceMonitorBinaryUnsupported",
18+ {
19+ platform: Schema.String,
20+ architecture: Schema.String,
21+ },
22+) {
23+ override get message(): string {
24+ return `Resource monitoring is unsupported on ${this.platform}/${this.architecture}.`;
25+ }
26+}
27+
28+export class ResourceMonitorBinaryNotFound extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotFound>()(
29+ "ResourceMonitorBinaryNotFound",
30+ {
31+ platform: Schema.String,
32+ architecture: Schema.String,
33+ candidates: Schema.Array(Schema.String),
34+ },
35+) {
36+ override get message(): string {
37+ return `Resource monitor binary was not found for ${this.platform}/${this.architecture}.`;
38+ }
39+}
40+
41+export class ResourceMonitorBinaryNotExecutable extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotExecutable>()(
42+ "ResourceMonitorBinaryNotExecutable",
43+ {
44+ path: Schema.String,
45+ mode: Schema.Number,
46+ },
47+) {
48+ override get message(): string {
49+ return `Resource monitor binary at '${this.path}' is not executable.`;
50+ }
51+}
52+
53+export type ResourceMonitorBinaryError =
54+ | ResourceMonitorBinaryUnsupported
55+ | ResourceMonitorBinaryNotFound
56+ | ResourceMonitorBinaryNotExecutable;
57+
58+export interface ResourceMonitorBinaryShape {
59+ readonly resolve: Effect.Effect<string, ResourceMonitorBinaryError>;
60+}
61+
62+export class ResourceMonitorBinary extends Context.Service<
63+ ResourceMonitorBinary,
64+ ResourceMonitorBinaryShape
65+>()("t3/resourceTelemetry/ResourceMonitorBinary") {}
66+
67+function binaryName(platform: NodeJS.Platform): string {
68+ return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
69+}
70+
71+export function resourceMonitorPlatformKey(
72+ platform: NodeJS.Platform,
73+ architecture: NodeJS.Architecture,
74+): string | undefined {
75+ if (
76+ (platform !== "darwin" && platform !== "linux" && platform !== "win32") ||
77+ (architecture !== "arm64" && architecture !== "x64")
78+ ) {
79+ return undefined;
80+ }
81+ return `${platform}-${architecture}`;
82+}
83+
84+function resourceMonitorRustTarget(
85+ platform: NodeJS.Platform,
86+ architecture: NodeJS.Architecture,
87+): string | undefined {
88+ if (platform === "darwin") {
89+ return architecture === "arm64"
90+ ? "aarch64-apple-darwin"
91+ : architecture === "x64"
92+ ? "x86_64-apple-darwin"
93+ : undefined;
94+ }
95+ if (platform === "linux") {
96+ return architecture === "arm64"
97+ ? "aarch64-unknown-linux-gnu"
98+ : architecture === "x64"
99+ ? "x86_64-unknown-linux-gnu"
100+ : undefined;
101+ }
102+ if (platform === "win32") {
103+ return architecture === "arm64"
104+ ? "aarch64-pc-windows-msvc"
105+ : architecture === "x64"
106+ ? "x86_64-pc-windows-msvc"
107+ : undefined;
108+ }
109+ return undefined;
110+}
111+
112+export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(function* () {
113+ const config = yield* ServerConfig;
114+ const fileSystem = yield* FileSystem.FileSystem;
115+ const path = yield* Path.Path;
116+ const platform = yield* HostProcessPlatform;
117+ const architecture = yield* HostProcessArchitecture;
118+ const environment = yield* HostProcessEnvironment;
119+ const executableName = binaryName(platform);
120+ const platformKey = resourceMonitorPlatformKey(platform, architecture);
121+ const rustTarget = resourceMonitorRustTarget(platform, architecture);
122+ if (platformKey === undefined || rustTarget === undefined) {
123+ return ResourceMonitorBinary.of({
124+ resolve: Effect.fail(
125+ new ResourceMonitorBinaryUnsupported({
126+ platform,
127+ architecture,
128+ }),
129+ ),
130+ });
131+ }
132+
133+ const candidates = [
134+ environment.T3CODE_RESOURCE_MONITOR_PATH,
135+ config.resourceMonitorPath,
136+ path.resolve(import.meta.dirname, "resource-monitor", platformKey, executableName),
137+ path.resolve(import.meta.dirname, "resource-monitor", executableName),
138+ path.resolve(import.meta.dirname, "../resource-monitor", executableName),
139+ path.resolve(
140+ import.meta.dirname,
141+ "../../../../native/resource-monitor/target",
142+ rustTarget,
143+ "release",
144+ executableName,
145+ ),
146+ path.resolve(
147+ import.meta.dirname,
148+ "../../../native/resource-monitor/target",
149+ rustTarget,
150+ "release",
151+ executableName,
152+ ),
153+ path.resolve(
154+ import.meta.dirname,
155+ "../../../../native/resource-monitor/target/release",
156+ executableName,
157+ ),
158+ path.resolve(
159+ import.meta.dirname,
160+ "../../../../native/resource-monitor/target/debug",
161+ executableName,
162+ ),
163+ ].filter((candidate): candidate is string => Boolean(candidate));
164+
165+ const resolve: ResourceMonitorBinaryShape["resolve"] = Effect.gen(function* () {
166+ for (const candidate of candidates) {
167+ const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
168+ if (!exists) continue;
169+
170+ if (platform !== "win32") {
171+ const stat = yield* fileSystem.stat(candidate).pipe(Effect.option);
172+ if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) {
173+ return yield* new ResourceMonitorBinaryNotExecutable({
174+ path: candidate,
175+ mode: stat.value.mode,
176+ });
177+ }
178+ }
179+
180+ return candidate;
181+ }
182+
183+ return yield* new ResourceMonitorBinaryNotFound({
184+ platform,
185+ architecture,
186+ candidates,
187+ });
188+ });
189+
190+ return ResourceMonitorBinary.of({ resolve });
191+});
192+
193+export const layer = Layer.effect(ResourceMonitorBinary, make());
apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts+302−0
@@ -0,0 +1,302 @@
1+import type {
2+ DesktopHostTelemetrySnapshot,
3+ ResourceMonitorProcessSample,
4+ ResourceMonitorSnapshotEvent,
5+} from "@t3tools/contracts";
6+import { describe, expect, it } from "@effect/vitest";
7+import * as DateTime from "effect/DateTime";
8+import * as Duration from "effect/Duration";
9+import * as Effect from "effect/Effect";
10+import * as Fiber from "effect/Fiber";
11+import * as Layer from "effect/Layer";
12+import * as Option from "effect/Option";
13+import * as PubSub from "effect/PubSub";
14+import * as Ref from "effect/Ref";
15+import * as Stream from "effect/Stream";
16+import * as TestClock from "effect/testing/TestClock";
17+
18+import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts";
19+import * as NativeTelemetryClient from "./NativeTelemetryClient.ts";
20+import * as ResourceAttribution from "./ResourceAttribution.ts";
21+import * as ResourceTelemetry from "./ResourceTelemetry.ts";
22+
23+function processSample(
24+ input: Partial<ResourceMonitorProcessSample> &
25+ Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">,
26+): ResourceMonitorProcessSample {
27+ return {
28+ runTimeMs: 1_000,
29+ name: `process-${input.pid}`,
30+ command: `process-${input.pid}`,
31+ status: "Running",
32+ cpuPercent: 0,
33+ cpuTimeMs: 0,
34+ residentBytes: 1_024,
35+ virtualBytes: 2_048,
36+ ioReadBytes: 0,
37+ ioWriteBytes: 0,
38+ ioSemantics: "storage",
39+ ...input,
40+ };
41+}
42+
43+function nativeSnapshot(input: {
44+ readonly sequence: number;
45+ readonly sampledAtUnixMs: number;
46+ readonly childCpuTimeMs: number;
47+ readonly childWriteBytes: number;
48+}): ResourceMonitorSnapshotEvent {
49+ const processes = [
50+ processSample({
51+ pid: process.pid,
52+ ppid: 1,
53+ startTimeMs: 100,
54+ cpuTimeMs: input.sequence * 10,
55+ }),
56+ processSample({
57+ pid: 4_242,
58+ ppid: process.pid,
59+ startTimeMs: 200,
60+ name: "codex",
61+ command: "codex app-server",
62+ cpuTimeMs: input.childCpuTimeMs,
63+ ioWriteBytes: input.childWriteBytes,
64+ }),
65+ processSample({
66+ pid: 5_000,
67+ ppid: 1,
68+ startTimeMs: 300,
69+ name: "electron",
70+ command: "electron",
71+ cpuTimeMs: input.sequence * 20,
72+ }),
73+ processSample({
74+ pid: 9_000,
75+ ppid: process.pid,
76+ startTimeMs: 400,
77+ name: "t3-resource-monitor",
78+ command: "t3-resource-monitor",
79+ cpuTimeMs: input.sequence * 5,
80+ }),
81+ ];
82+ return {
83+ version: 1,
84+ type: "snapshot",
85+ sequence: input.sequence,
86+ sampledAtUnixMs: input.sampledAtUnixMs,
87+ collectionDurationMicros: 300,
88+ scannedProcessCount: 80,
89+ retainedProcessCount: processes.length,
90+ inaccessibleProcessCount: 1,
91+ processes,
92+ };
93+}
94+
95+function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot {
96+ const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
97+ return {
98+ version: 1,
99+ type: "desktopTelemetry",
100+ sequence: 1,
101+ sampledAtUnixMs,
102+ power: {
103+ source: "electron-main",
104+ idle: "false",
105+ idleSeconds: 2,
106+ locked: "false",
107+ suspended: false,
108+ onBattery: "true",
109+ lowPowerMode: "unknown",
110+ thermalState: "fair",
111+ stale: false,
112+ updatedAt: sampledAt,
113+ },
114+ speedLimitPercent: Option.some(90),
115+ electronProcesses: [
116+ {
117+ pid: 5_000,
118+ creationTimeMs: 300,
119+ type: "Browser",
120+ name: "electron",
121+ cpuPercent: 2,
122+ cumulativeCpuSeconds: 0.02,
123+ idleWakeupsPerSecond: 3,
124+ workingSetBytes: 4_096,
125+ peakWorkingSetBytes: 8_192,
126+ },
127+ ],
128+ };
129+}
130+
131+describe("ResourceTelemetry", () => {
132+ it.effect("combines native, Electron, attribution, retry, and history data", () =>
133+ Effect.gen(function* () {
134+ const startedAt = DateTime.toEpochMillis(yield* DateTime.now);
135+ const samples = [
136+ nativeSnapshot({
137+ sequence: 1,
138+ sampledAtUnixMs: startedAt,
139+ childCpuTimeMs: 100,
140+ childWriteBytes: 1_000,
141+ }),
142+ nativeSnapshot({
143+ sequence: 2,
144+ sampledAtUnixMs: startedAt + 1_000,
145+ childCpuTimeMs: 350,
146+ childWriteBytes: 5_000,
147+ }),
148+ nativeSnapshot({
149+ sequence: 1,
150+ sampledAtUnixMs: startedAt + 2_000,
151+ childCpuTimeMs: 500,
152+ childWriteBytes: 7_000,
153+ }),
154+ ] as const;
155+ const sampleIndex = yield* Ref.make(0);
156+ const externalProcesses = yield* Ref.make<
157+ ReadonlyArray<{ readonly pid: number; readonly startTimeMs?: number }>
158+ >([]);
159+ const retryCount = yield* Ref.make(0);
160+ const nativeHealth = yield* Ref.make<NativeTelemetryClient.NativeTelemetryClientHealth>({
161+ status: "healthy",
162+ hello: Option.some({
163+ version: 1,
164+ type: "hello",
165+ sidecarVersion: "0.1.0",
166+ sidecarPid: 9_000,
167+ platform: "test",
168+ arch: "test",
169+ capabilities: {
170+ cumulativeCpuTime: true,
171+ currentCpuPercent: true,
172+ residentMemory: true,
173+ virtualMemory: true,
174+ ioBytes: true,
175+ processStartTime: true,
176+ processTree: true,
177+ },
178+ }),
179+ lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)),
180+ lastError: Option.none(),
181+ restartCount: 2,
182+ });
183+ const nativeHealthChanges =
184+ yield* PubSub.sliding<NativeTelemetryClient.NativeTelemetryClientHealth>(4);
185+ const nativeLayer = NativeTelemetryClient.layerTest({
186+ setExternalProcesses: (processes) => Ref.set(externalProcesses, processes),
187+ sampleNow: Ref.modify(sampleIndex, (index) => [
188+ samples[Math.min(index, samples.length - 1)]!,
189+ index + 1,
190+ ]),
191+ retry: Ref.updateAndGet(retryCount, (count) => count + 1).pipe(Effect.as(true)),
192+ health: Ref.get(nativeHealth),
193+ healthChanges: Stream.fromPubSub(nativeHealthChanges),
194+ });
195+ const desktopLayer = DesktopTelemetryReceiver.layerTest({
196+ latest: Effect.succeedSome(desktopSnapshot(startedAt)),
197+ health: Effect.succeed({
198+ status: "healthy",
199+ lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)),
200+ lastError: Option.none(),
201+ }),
202+ });
203+ const attributionLayer = ResourceAttribution.layer;
204+ const dependencies = Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer);
205+ const telemetryLayer = ResourceTelemetry.layer.pipe(Layer.provide(dependencies));
206+ const layer = Layer.mergeAll(dependencies, telemetryLayer);
207+
208+ yield* Effect.gen(function* () {
209+ const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
210+ const attribution = yield* ResourceAttribution.ResourceAttribution;
211+
212+ expect(yield* Ref.get(externalProcesses)).toEqual([{ pid: 5_000, startTimeMs: 300 }]);
213+
214+ yield* attribution.record({
215+ component: "provider-event-log",
216+ operation: "append",
217+ logicalWriteBytes: 512,
218+ count: 2,
219+ durationMs: 4,
220+ });
221+ const first = yield* telemetry.refresh;
222+ expect(first.groups.backend.processCount).toBe(2);
223+ expect(first.groups.electron.processCount).toBe(1);
224+ expect(first.groups.monitor.processCount).toBe(1);
225+ expect(first.power.onBattery).toBe("true");
226+ expect(Option.getOrNull(first.speedLimitPercent)).toBe(90);
227+ expect(first.attribution.entries).toEqual([
228+ {
229+ component: "provider-event-log",
230+ operation: "append",
231+ logicalReadBytes: 0,
232+ logicalWriteBytes: 512,
233+ count: 2,
234+ durationMs: 4,
235+ },
236+ ]);
237+
238+ yield* TestClock.adjust(Duration.seconds(1));
239+ const second = yield* telemetry.refresh;
240+ const codex = second.processes.find((entry) => entry.identity.pid === 4_242);
241+ expect(codex?.cpuPercent).toBe(25);
242+ expect(codex?.ioWriteBytesPerSecond).toBe(4_000);
243+ expect(second.groups.backend.ioWriteBytes).toBe(4_000);
244+ expect(second.health.collectionDurationMicros).toBe(300);
245+ expect(second.health.scannedProcessCount).toBe(80);
246+ expect(second.health.inaccessibleProcessCount).toBe(1);
247+
248+ const history = yield* telemetry.readHistory({
249+ windowMs: 60_000,
250+ bucketMs: 10_000,
251+ });
252+ expect(history.retainedSampleCount).toBeGreaterThan(0);
253+ expect(
254+ history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.sampleCount,
255+ ).toBe(2);
256+ expect(history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.cpuTimeMs).toBe(
257+ 250,
258+ );
259+ expect(
260+ history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.ioWriteBytes,
261+ ).toBe(4_000);
262+ expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(
263+ 4_000,
264+ );
265+
266+ const retry = yield* telemetry.retry;
267+ expect(retry.accepted).toBe(true);
268+ expect(yield* Ref.get(retryCount)).toBe(1);
269+
270+ yield* Ref.update(nativeHealth, (current) => ({
271+ ...current,
272+ hello: Option.map(current.hello, (hello) => ({
273+ ...hello,
274+ sidecarPid: 9_001,
275+ })),
276+ restartCount: 3,
277+ }));
278+ yield* TestClock.adjust(Duration.seconds(1));
279+ const restarted = yield* telemetry.refresh;
280+ expect(DateTime.toEpochMillis(restarted.readAt)).toBe(startedAt + 2_000);
281+ expect(Option.getOrNull(restarted.health.sidecarPid)).toBe(9_001);
282+
283+ const healthUpdateFiber = yield* Stream.runHead(telemetry.changes).pipe(Effect.forkChild);
284+ yield* Effect.yieldNow;
285+ yield* Ref.update(nativeHealth, (current) => ({
286+ ...current,
287+ status: "degraded" as const,
288+ lastError: Option.some("collector exited"),
289+ }));
290+ yield* PubSub.publish(nativeHealthChanges, yield* Ref.get(nativeHealth));
291+ const healthUpdate = Option.getOrThrow(yield* Fiber.join(healthUpdateFiber));
292+ expect(healthUpdate.health.native.status).toBe("degraded");
293+ expect(Option.getOrNull(healthUpdate.health.native.lastError)).toBe("collector exited");
294+ const degradedHistory = yield* telemetry.readHistory({
295+ windowMs: 60_000,
296+ bucketMs: 10_000,
297+ });
298+ expect(degradedHistory.health.native.status).toBe("degraded");
299+ }).pipe(Effect.provide(layer));
300+ }),
301+ );
302+});
apps/server/src/resourceTelemetry/ResourceTelemetry.ts+372−0
@@ -0,0 +1,372 @@
1+import type {
2+ DesktopHostTelemetrySnapshot,
3+ HostPowerSnapshot,
4+ ResourceMonitorSnapshotEvent,
5+ ResourceTelemetryHealth,
6+ ResourceTelemetryHistory,
7+ ResourceTelemetryHistoryInput,
8+ ResourceTelemetryProcessIdentity,
9+ ResourceTelemetryRetryResult,
10+ ResourceTelemetrySnapshot,
11+} from "@t3tools/contracts";
12+import * as Context from "effect/Context";
13+import * as DateTime from "effect/DateTime";
14+import * as Effect from "effect/Effect";
15+import * as Layer from "effect/Layer";
16+import * as Option from "effect/Option";
17+import * as PubSub from "effect/PubSub";
18+import * as Ref from "effect/Ref";
19+import * as Schema from "effect/Schema";
20+import * as Semaphore from "effect/Semaphore";
21+import * as Stream from "effect/Stream";
22+
23+import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts";
24+import {
25+ emptyTelemetryCounters,
26+ mergeProcesses,
27+ type ProcessState,
28+ type TelemetryCounters,
29+} from "./Model.ts";
30+import * as NativeTelemetryClient from "./NativeTelemetryClient.ts";
31+import * as ResourceAttribution from "./ResourceAttribution.ts";
32+import * as ResourceTelemetryStore from "./ResourceTelemetryStore.ts";
33+
34+const SAMPLE_INTERVAL_MS = 1_000;
35+
36+export class ResourceTelemetryRefreshFailed extends Schema.TaggedErrorClass<ResourceTelemetryRefreshFailed>()(
37+ "ResourceTelemetryRefreshFailed",
38+ {
39+ operation: Schema.String,
40+ cause: Schema.Defect(),
41+ },
42+) {
43+ override get message(): string {
44+ return `Resource telemetry operation '${this.operation}' failed.`;
45+ }
46+}
47+
48+export interface ResourceTelemetryShape {
49+ readonly latest: Effect.Effect<ResourceTelemetrySnapshot>;
50+ readonly changes: Stream.Stream<ResourceTelemetrySnapshot>;
51+ readonly readHistory: (
52+ input: ResourceTelemetryHistoryInput,
53+ ) => Effect.Effect<ResourceTelemetryHistory>;
54+ readonly refresh: Effect.Effect<ResourceTelemetrySnapshot, ResourceTelemetryRefreshFailed>;
55+ readonly validateProcessIdentity: (
56+ identity: ResourceTelemetryProcessIdentity,
57+ ) => Effect.Effect<boolean, ResourceTelemetryRefreshFailed>;
58+ readonly retry: Effect.Effect<ResourceTelemetryRetryResult>;
59+}
60+
61+export class ResourceTelemetry extends Context.Service<ResourceTelemetry, ResourceTelemetryShape>()(
62+ "t3/resourceTelemetry/ResourceTelemetry",
63+) {}
64+
65+interface TelemetryState {
66+ readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>;
67+ readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>;
68+ readonly previous: ReadonlyMap<string, ProcessState>;
69+ readonly counters: TelemetryCounters;
70+ readonly latest: ResourceTelemetrySnapshot;
71+ readonly lastNativeSequence: number;
72+ readonly lastNativeRestartCount: number;
73+}
74+
75+function unknownPower(updatedAt: DateTime.Utc): HostPowerSnapshot {
76+ return {
77+ source: "unknown",
78+ idle: "unknown",
79+ idleSeconds: null,
80+ locked: "unknown",
81+ suspended: false,
82+ onBattery: "unknown",
83+ lowPowerMode: "unknown",
84+ thermalState: "unknown",
85+ stale: true,
86+ updatedAt,
87+ };
88+}
89+
90+function buildHealth(input: {
91+ readonly native: NativeTelemetryClient.NativeTelemetryClientHealth;
92+ readonly desktop: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth;
93+ readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>;
94+}): ResourceTelemetryHealth {
95+ return {
96+ native: {
97+ status: input.native.status,
98+ lastSampleAt: input.native.lastSampleAt,
99+ lastError: input.native.lastError,
100+ },
101+ desktop: {
102+ status: input.desktop.status,
103+ lastSampleAt: input.desktop.lastSampleAt,
104+ lastError: input.desktop.lastError,
105+ },
106+ sidecarVersion: Option.map(input.native.hello, (hello) => hello.sidecarVersion),
107+ sidecarPid: Option.map(input.native.hello, (hello) => hello.sidecarPid),
108+ restartCount: input.native.restartCount,
109+ collectionDurationMicros: Option.match(input.nativeSnapshot, {
110+ onNone: () => 0,
111+ onSome: (snapshot) => snapshot.collectionDurationMicros,
112+ }),
113+ scannedProcessCount: Option.match(input.nativeSnapshot, {
114+ onNone: () => 0,
115+ onSome: (snapshot) => snapshot.scannedProcessCount,
116+ }),
117+ retainedProcessCount: Option.match(input.nativeSnapshot, {
118+ onNone: () => 0,
119+ onSome: (snapshot) => snapshot.retainedProcessCount,
120+ }),
121+ inaccessibleProcessCount: Option.match(input.nativeSnapshot, {
122+ onNone: () => 0,
123+ onSome: (snapshot) => snapshot.inaccessibleProcessCount,
124+ }),
125+ };
126+}
127+
128+export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () {
129+ const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient;
130+ const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
131+ const attribution = yield* ResourceAttribution.ResourceAttribution;
132+ const mutex = yield* Semaphore.make(1);
133+ const changes = yield* PubSub.sliding<ResourceTelemetrySnapshot>(8);
134+ const initialReadAt = yield* DateTime.now;
135+ const initialDesktop = yield* desktopReceiver.latest;
136+ if (Option.isSome(initialDesktop)) {
137+ yield* nativeClient
138+ .setExternalProcesses(
139+ initialDesktop.value.electronProcesses.map((process) => ({
140+ pid: process.pid,
141+ startTimeMs: process.creationTimeMs,
142+ })),
143+ )
144+ .pipe(Effect.ignore);
145+ }
146+ const [initialNativeHealth, initialDesktopHealth, initialAttribution] = yield* Effect.all([
147+ nativeClient.health,
148+ desktopReceiver.health,
149+ attribution.snapshot,
150+ ]);
151+ const initialMerge = mergeProcesses({
152+ serverPid: process.pid,
153+ sidecarPid: Option.map(initialNativeHealth.hello, (hello) => hello.sidecarPid),
154+ fallbackSampledAtMs: DateTime.toEpochMillis(initialReadAt),
155+ nativeSnapshot: Option.none(),
156+ desktopSnapshot: initialDesktop,
157+ previous: new Map(),
158+ counters: emptyTelemetryCounters(),
159+ updatePrevious: false,
160+ });
161+ const initialSnapshot: ResourceTelemetrySnapshot = {
162+ readAt: initialReadAt,
163+ sampleIntervalMs: SAMPLE_INTERVAL_MS,
164+ processes: initialMerge.processes,
165+ groups: initialMerge.groups,
166+ power: Option.match(initialDesktop, {
167+ onNone: () => unknownPower(initialReadAt),
168+ onSome: (desktop) => desktop.power,
169+ }),
170+ speedLimitPercent: Option.flatMap(initialDesktop, (desktop) => desktop.speedLimitPercent),
171+ attribution: initialAttribution,
172+ health: buildHealth({
173+ native: initialNativeHealth,
174+ desktop: initialDesktopHealth,
175+ nativeSnapshot: Option.none(),
176+ }),
177+ };
178+ const state = yield* Ref.make<TelemetryState>({
179+ nativeSnapshot: Option.none(),
180+ desktopSnapshot: initialDesktop,
181+ previous: new Map(),
182+ counters: emptyTelemetryCounters(),
183+ latest: initialSnapshot,
184+ lastNativeSequence: 0,
185+ lastNativeRestartCount: initialNativeHealth.restartCount,
186+ });
187+ const store = yield* ResourceTelemetryStore.make(initialSnapshot);
188+ const refreshHealth = mutex.withPermits(1)(
189+ Effect.gen(function* () {
190+ const current = yield* Ref.get(state);
191+ const [nativeHealth, desktopHealth] = yield* Effect.all([
192+ nativeClient.health,
193+ desktopReceiver.health,
194+ ]);
195+ const snapshot: ResourceTelemetrySnapshot = {
196+ ...current.latest,
197+ health: buildHealth({
198+ native: nativeHealth,
199+ desktop: desktopHealth,
200+ nativeSnapshot: current.nativeSnapshot,
201+ }),
202+ };
203+ yield* Ref.set(state, {
204+ ...current,
205+ latest: snapshot,
206+ });
207+ yield* store.updateLatest(snapshot);
208+ yield* PubSub.publish(changes, snapshot);
209+ }),
210+ );
211+
212+ const rebuild = (input: {
213+ readonly nativeSnapshot?: ResourceMonitorSnapshotEvent;
214+ readonly desktopSnapshot?: DesktopHostTelemetrySnapshot;
215+ readonly updatePrevious: boolean;
216+ }): Effect.Effect<ResourceTelemetrySnapshot> =>
217+ mutex.withPermits(1)(
218+ Effect.gen(function* () {
219+ const current = yield* Ref.get(state);
220+ const nativeHealth = yield* nativeClient.health;
221+ const nativeGenerationChanged =
222+ nativeHealth.restartCount !== current.lastNativeRestartCount;
223+ if (
224+ input.nativeSnapshot &&
225+ !nativeGenerationChanged &&
226+ input.nativeSnapshot.sequence <= current.lastNativeSequence
227+ ) {
228+ return current.latest;
229+ }
230+ const nativeSnapshot = input.nativeSnapshot
231+ ? Option.some(input.nativeSnapshot)
232+ : current.nativeSnapshot;
233+ const desktopSnapshot = input.desktopSnapshot
234+ ? Option.some(input.desktopSnapshot)
235+ : current.desktopSnapshot;
236+ const [desktopHealth, attributionSnapshot] = yield* Effect.all([
237+ desktopReceiver.health,
238+ attribution.snapshot,
239+ ]);
240+ const merged = mergeProcesses({
241+ serverPid: process.pid,
242+ sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid),
243+ fallbackSampledAtMs: DateTime.toEpochMillis(current.latest.readAt),
244+ nativeSnapshot,
245+ desktopSnapshot,
246+ previous: current.previous,
247+ counters: current.counters,
248+ updatePrevious: input.updatePrevious,
249+ });
250+ const readAt = DateTime.makeUnsafe(merged.sampledAtMs);
251+ const snapshot: ResourceTelemetrySnapshot = {
252+ readAt,
253+ sampleIntervalMs: SAMPLE_INTERVAL_MS,
254+ processes: merged.processes,
255+ groups: merged.groups,
256+ power: Option.match(desktopSnapshot, {
257+ onNone: () => unknownPower(readAt),
258+ onSome: (desktop) => desktop.power,
259+ }),
260+ speedLimitPercent: Option.match(desktopSnapshot, {
261+ onNone: () => Option.none(),
262+ onSome: (desktop) => desktop.speedLimitPercent,
263+ }),
264+ attribution: attributionSnapshot,
265+ health: buildHealth({
266+ native: nativeHealth,
267+ desktop: desktopHealth,
268+ nativeSnapshot,
269+ }),
270+ };
271+ yield* Ref.set(state, {
272+ nativeSnapshot,
273+ desktopSnapshot,
274+ previous: merged.previous,
275+ counters: merged.counters,
276+ latest: snapshot,
277+ lastNativeSequence: input.nativeSnapshot?.sequence ?? current.lastNativeSequence,
278+ lastNativeRestartCount: input.nativeSnapshot
279+ ? nativeHealth.restartCount
280+ : current.lastNativeRestartCount,
281+ });
282+ if (input.updatePrevious) {
283+ yield* store.record(snapshot, merged.deltas);
284+ }
285+ yield* PubSub.publish(changes, snapshot);
286+ return snapshot;
287+ }),
288+ );
289+
290+ const ingestNative = (snapshot: ResourceMonitorSnapshotEvent) =>
291+ rebuild({ nativeSnapshot: snapshot, updatePrevious: true });
292+ const ingestDesktop = (snapshot: DesktopHostTelemetrySnapshot) =>
293+ Effect.gen(function* () {
294+ yield* nativeClient
295+ .setExternalProcesses(
296+ snapshot.electronProcesses.map((process) => ({
297+ pid: process.pid,
298+ startTimeMs: process.creationTimeMs,
299+ })),
300+ )
301+ .pipe(Effect.ignore);
302+ return yield* rebuild({ desktopSnapshot: snapshot, updatePrevious: false });
303+ });
304+
305+ yield* nativeClient.snapshots.pipe(
306+ Stream.runForEach((snapshot) => ingestNative(snapshot)),
307+ Effect.catch((error) =>
308+ Effect.logWarning("Native resource telemetry stream stopped", {
309+ cause: error.message,
310+ }),
311+ ),
312+ Effect.forkScoped,
313+ );
314+ yield* desktopReceiver.changes.pipe(
315+ Stream.runForEach((snapshot) => ingestDesktop(snapshot)),
316+ Effect.forkScoped,
317+ );
318+ yield* nativeClient.healthChanges.pipe(
319+ Stream.runForEach(() => refreshHealth),
320+ Effect.forkScoped,
321+ );
322+ yield* desktopReceiver.healthChanges.pipe(
323+ Stream.runForEach(() => refreshHealth),
324+ Effect.forkScoped,
325+ );
326+
327+ const refresh: ResourceTelemetryShape["refresh"] = nativeClient.sampleNow.pipe(
328+ Effect.flatMap(ingestNative),
329+ Effect.mapError(
330+ (cause) =>
331+ new ResourceTelemetryRefreshFailed({
332+ operation: "refresh",
333+ cause,
334+ }),
335+ ),
336+ );
337+
338+ const validateProcessIdentity: ResourceTelemetryShape["validateProcessIdentity"] = (identity) =>
339+ nativeClient.sampleNow.pipe(
340+ Effect.map((snapshot) =>
341+ snapshot.processes.some(
342+ (process) => process.pid === identity.pid && process.startTimeMs === identity.startTimeMs,
343+ ),
344+ ),
345+ Effect.mapError(
346+ (cause) =>
347+ new ResourceTelemetryRefreshFailed({
348+ operation: "validateProcessIdentity",
349+ cause,
350+ }),
351+ ),
352+ );
353+
354+ return ResourceTelemetry.of({
355+ latest: Ref.get(state).pipe(Effect.map((current) => current.latest)),
356+ changes: Stream.fromPubSub(changes),
357+ readHistory: store.readHistory,
358+ refresh,
359+ validateProcessIdentity,
360+ retry: nativeClient.retry.pipe(
361+ Effect.zip(Ref.get(state)),
362+ Effect.map(
363+ ([accepted, current]): ResourceTelemetryRetryResult => ({
364+ accepted,
365+ snapshot: current.latest,
366+ }),
367+ ),
368+ ),
369+ });
370+});
371+
372+export const layer = Layer.effect(ResourceTelemetry, make());
apps/server/src/resourceTelemetry/ResourceTelemetryStore.ts+253−0
@@ -0,0 +1,253 @@
1+import type {
2+ ResourceTelemetryHistory,
3+ ResourceTelemetryHistoryBucket,
4+ ResourceTelemetryHistoryInput,
5+ ResourceTelemetryProcess,
6+ ResourceTelemetryProcessSummary,
7+ ResourceTelemetrySnapshot,
8+} from "@t3tools/contracts";
9+import * as Context from "effect/Context";
10+import * as DateTime from "effect/DateTime";
11+import * as Effect from "effect/Effect";
12+import * as Layer from "effect/Layer";
13+import * as Ref from "effect/Ref";
14+
15+import type { ProcessDelta } from "./Model.ts";
16+import { processIdentityKey } from "./Model.ts";
17+
18+const RETENTION_MS = 60 * 60_000;
19+const MAX_AGGREGATE_SAMPLES = 3_600;
20+const MAX_PROCESS_SAMPLES = 20_000;
21+
22+interface AggregateSample {
23+ readonly sampledAtMs: number;
24+ readonly cpuPercent: number;
25+ readonly rssBytes: number;
26+ readonly processCount: number;
27+ readonly ioReadBytes: number;
28+ readonly ioWriteBytes: number;
29+}
30+
31+interface ProcessSample {
32+ readonly sampledAtMs: number;
33+ readonly process: ResourceTelemetryProcess;
34+ readonly cpuTimeMs: number;
35+ readonly ioReadBytes: number;
36+ readonly ioWriteBytes: number;
37+}
38+
39+interface StoreState {
40+ readonly aggregateSamples: ReadonlyArray<AggregateSample>;
41+ readonly processSamples: ReadonlyArray<ProcessSample>;
42+ readonly latest: ResourceTelemetrySnapshot;
43+}
44+
45+export interface ResourceTelemetryStoreShape {
46+ readonly updateLatest: (snapshot: ResourceTelemetrySnapshot) => Effect.Effect<void>;
47+ readonly record: (
48+ snapshot: ResourceTelemetrySnapshot,
49+ deltas: ReadonlyArray<ProcessDelta>,
50+ ) => Effect.Effect<void>;
51+ readonly readHistory: (
52+ input: ResourceTelemetryHistoryInput,
53+ ) => Effect.Effect<ResourceTelemetryHistory>;
54+}
55+
56+export class ResourceTelemetryStore extends Context.Service<
57+ ResourceTelemetryStore,
58+ ResourceTelemetryStoreShape
59+>()("t3/resourceTelemetry/ResourceTelemetryStore") {}
60+
61+function trimByTime<A extends { readonly sampledAtMs: number }>(
62+ values: ReadonlyArray<A>,
63+ nowMs: number,
64+ max: number,
65+): ReadonlyArray<A> {
66+ const retained = values.filter((value) => value.sampledAtMs >= nowMs - RETENTION_MS);
67+ return retained.length <= max ? retained : retained.slice(retained.length - max);
68+}
69+
70+function summarizeProcesses(
71+ samples: ReadonlyArray<ProcessSample>,
72+): ReadonlyArray<ResourceTelemetryProcessSummary> {
73+ const groups = new Map<string, ProcessSample[]>();
74+ for (const sample of samples) {
75+ const identityKey = processIdentityKey(
76+ sample.process.identity.pid,
77+ sample.process.identity.startTimeMs,
78+ );
79+ const current = groups.get(identityKey) ?? [];
80+ current.push(sample);
81+ groups.set(identityKey, current);
82+ }
83+
84+ return [...groups.values()]
85+ .map((processSamples): ResourceTelemetryProcessSummary => {
86+ const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs);
87+ const first = sorted[0]!;
88+ const latest = sorted[sorted.length - 1]!;
89+ const cpuTotal = sorted.reduce((total, sample) => total + sample.process.cpuPercent, 0);
90+ return {
91+ identity: latest.process.identity,
92+ ppid: latest.process.ppid,
93+ depth: latest.process.depth,
94+ name: latest.process.name,
95+ command: latest.process.command,
96+ category: latest.process.category,
97+ firstSeenAt: first.process.firstSeenAt,
98+ lastSeenAt: latest.process.lastSeenAt,
99+ currentCpuPercent: latest.process.cpuPercent,
100+ avgCpuPercent: cpuTotal / sorted.length,
101+ maxCpuPercent: Math.max(...sorted.map((sample) => sample.process.cpuPercent)),
102+ cpuTimeMs: sorted.reduce((total, sample) => total + sample.cpuTimeMs, 0),
103+ currentRssBytes: latest.process.residentBytes,
104+ peakRssBytes: Math.max(...sorted.map((sample) => sample.process.peakResidentBytes)),
105+ ioReadBytes: sorted.reduce((total, sample) => total + sample.ioReadBytes, 0),
106+ ioWriteBytes: sorted.reduce((total, sample) => total + sample.ioWriteBytes, 0),
107+ ioSemantics: latest.process.ioSemantics,
108+ sampleCount: sorted.length,
109+ };
110+ })
111+ .toSorted(
112+ (left, right) => right.cpuTimeMs - left.cpuTimeMs || right.peakRssBytes - left.peakRssBytes,
113+ );
114+}
115+
116+function buildBuckets(input: {
117+ readonly samples: ReadonlyArray<AggregateSample>;
118+ readonly nowMs: number;
119+ readonly windowMs: number;
120+ readonly bucketMs: number;
121+}): ReadonlyArray<ResourceTelemetryHistoryBucket> {
122+ const windowStartMs = input.nowMs - input.windowMs;
123+ const buckets: ResourceTelemetryHistoryBucket[] = [];
124+ for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += input.bucketMs) {
125+ const endedAtMs = Math.min(input.nowMs, startedAtMs + input.bucketMs);
126+ const samples = input.samples.filter(
127+ (sample) =>
128+ sample.sampledAtMs >= startedAtMs &&
129+ (endedAtMs === input.nowMs
130+ ? sample.sampledAtMs <= endedAtMs
131+ : sample.sampledAtMs < endedAtMs),
132+ );
133+ const cpuTotal = samples.reduce((total, sample) => total + sample.cpuPercent, 0);
134+ buckets.push({
135+ startedAt: DateTime.makeUnsafe(startedAtMs),
136+ endedAt: DateTime.makeUnsafe(endedAtMs),
137+ avgCpuPercent: samples.length === 0 ? 0 : cpuTotal / samples.length,
138+ maxCpuPercent:
139+ samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.cpuPercent)),
140+ maxRssBytes: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.rssBytes)),
141+ ioReadBytes: samples.reduce((total, sample) => total + sample.ioReadBytes, 0),
142+ ioWriteBytes: samples.reduce((total, sample) => total + sample.ioWriteBytes, 0),
143+ maxProcessCount:
144+ samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.processCount)),
145+ });
146+ }
147+ return buckets;
148+}
149+
150+export const make = Effect.fn("resourceTelemetry.resourceTelemetryStore.make")(function* (
151+ initial: ResourceTelemetrySnapshot,
152+) {
153+ const state = yield* Ref.make<StoreState>({
154+ aggregateSamples: [],
155+ processSamples: [],
156+ latest: initial,
157+ });
158+
159+ const record: ResourceTelemetryStoreShape["record"] = (snapshot, deltas) =>
160+ Ref.update(state, (current) => {
161+ const sampledAtMs = DateTime.toEpochMillis(snapshot.readAt);
162+ const deltasByIdentity = new Map(
163+ deltas.map((processDelta) => [processDelta.identityKey, processDelta]),
164+ );
165+ const aggregateDelta = deltas.reduce(
166+ (total, process) => ({
167+ ioReadBytes: total.ioReadBytes + process.ioReadBytes,
168+ ioWriteBytes: total.ioWriteBytes + process.ioWriteBytes,
169+ }),
170+ { ioReadBytes: 0, ioWriteBytes: 0 },
171+ );
172+ return {
173+ latest: snapshot,
174+ aggregateSamples: trimByTime(
175+ [
176+ ...current.aggregateSamples,
177+ {
178+ sampledAtMs,
179+ cpuPercent: snapshot.groups.allT3.currentCpuPercent,
180+ rssBytes: snapshot.groups.allT3.currentRssBytes,
181+ processCount: snapshot.groups.allT3.processCount,
182+ ioReadBytes: aggregateDelta.ioReadBytes,
183+ ioWriteBytes: aggregateDelta.ioWriteBytes,
184+ },
185+ ],
186+ sampledAtMs,
187+ MAX_AGGREGATE_SAMPLES,
188+ ),
189+ processSamples: trimByTime(
190+ [
191+ ...current.processSamples,
192+ ...snapshot.processes.map((process) => {
193+ const processDelta = deltasByIdentity.get(
194+ processIdentityKey(process.identity.pid, process.identity.startTimeMs),
195+ );
196+ return {
197+ sampledAtMs,
198+ process,
199+ cpuTimeMs: processDelta?.cpuTimeMs ?? 0,
200+ ioReadBytes: processDelta?.ioReadBytes ?? 0,
201+ ioWriteBytes: processDelta?.ioWriteBytes ?? 0,
202+ };
203+ }),
204+ ],
205+ sampledAtMs,
206+ MAX_PROCESS_SAMPLES,
207+ ),
208+ };
209+ });
210+
211+ const updateLatest: ResourceTelemetryStoreShape["updateLatest"] = (snapshot) =>
212+ Ref.update(state, (current) => ({
213+ ...current,
214+ latest: snapshot,
215+ }));
216+
217+ const readHistory: ResourceTelemetryStoreShape["readHistory"] = (input) =>
218+ Effect.gen(function* () {
219+ const readAt = yield* DateTime.now;
220+ const readAtMs = DateTime.toEpochMillis(readAt);
221+ const windowMs = Math.max(1_000, Math.min(RETENTION_MS, input.windowMs));
222+ const bucketMs = Math.max(1_000, Math.min(windowMs, input.bucketMs));
223+ const current = yield* Ref.get(state);
224+ const minSampledAtMs = readAtMs - windowMs;
225+ const aggregateSamples = current.aggregateSamples.filter(
226+ (sample) => sample.sampledAtMs >= minSampledAtMs,
227+ );
228+ const processSamples = current.processSamples.filter(
229+ (sample) => sample.sampledAtMs >= minSampledAtMs,
230+ );
231+
232+ return {
233+ readAt,
234+ windowMs,
235+ bucketMs,
236+ sampleIntervalMs: current.latest.sampleIntervalMs,
237+ retainedSampleCount: current.aggregateSamples.length + current.processSamples.length,
238+ buckets: buildBuckets({
239+ samples: aggregateSamples,
240+ nowMs: readAtMs,
241+ windowMs,
242+ bucketMs,
243+ }),
244+ topProcesses: summarizeProcesses(processSamples),
245+ health: current.latest.health,
246+ };
247+ });
248+
249+ return ResourceTelemetryStore.of({ updateLatest, record, readHistory });
250+});
251+
252+export const layer = (initial: ResourceTelemetrySnapshot) =>
253+ Layer.effect(ResourceTelemetryStore, make(initial));
apps/server/src/server.test.ts+16−0
@@ -141,6 +141,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; · 140 unmodified lines
141141 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
142142 import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
143143 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
144+import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
145+import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";
146+import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
147+import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
144148 import * as Data from "effect/Data";
145149
146150 const defaultProjectId = ProjectId.make("project-default");
@@ -368,6 +372,8 @@ const buildAppUnderTest = (options?: { · 221 unmodified lines
368372 cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>;
369373 relayClient?: Partial<RelayClient.RelayClientShape>;
370374 cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManagerShape>;
375+ nativeTelemetryClient?: Partial<NativeTelemetryClient.NativeTelemetryClientShape>;
376+ desktopTelemetryReceiver?: Partial<DesktopTelemetryReceiver.DesktopTelemetryReceiverShape>;
371377 };
372378 }) =>
373379 Effect.gen(function* () {
@@ -540,6 +546,15 @@ const buildAppUnderTest = (options?: { · 166 unmodified lines
540546 ...options.layers.vcsStatusBroadcaster,
541547 })
542548 : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer));
549+ const resourceTelemetryLayer = ResourceTelemetry.layer.pipe(
550+ Layer.provide(
551+ Layer.mergeAll(
552+ NativeTelemetryClient.layerTest(options?.layers?.nativeTelemetryClient),
553+ DesktopTelemetryReceiver.layerTest(options?.layers?.desktopTelemetryReceiver),
554+ ResourceAttribution.layer,
555+ ),
556+ ),
557+ );
543558
544559 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
545560 disableListenLog: true,
@@ -751,6 +766,7 @@ const buildAppUnderTest = (options?: { · 205 unmodified lines
751766 );
752767
753768 const appLayer = servedRoutesLayer.pipe(
769+ Layer.provide(resourceTelemetryLayer),
754770 Layer.provide(
755771 Layer.mock(BrowserTraceCollector)({
756772 record: () => Effect.void,
apps/server/src/server.ts+31−4
@@ -83,6 +83,11 @@ import * as CloudCliState from "./cloud/CliState.ts"; · 82 unmodified lines
8383 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
8484 import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
8585 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
86+import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
87+import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";
88+import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
89+import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
90+import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
8691 import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
8792 import {
8893 clearPersistedServerRuntimeState,
@@ -99,6 +104,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale" · 10 unmodified lines
99104 // already closes the websocket gracefully. Do not add an artificial drain before
100105 // those finalizers get a chance to run.
101106 const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;
107+const ResourceAttributionLayerLive = ResourceAttribution.layer;
108+const ApplicationObservabilityLive = ObservabilityLive.pipe(
109+ Layer.provideMerge(ResourceAttributionLayerLive),
110+);
102111
103112 const PtyAdapterLive = Layer.unwrap(
104113 Effect.gen(function* () {
@@ -112,11 +121,30 @@ const PtyAdapterLive = Layer.unwrap( · 7 unmodified lines
112121 }),
113122 );
114123
124+const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe(
125+ Layer.provide(ResourceMonitorBinary.layer),
126+);
127+
128+const ResourceTelemetryLayerLive = ResourceTelemetry.layer.pipe(
129+ Layer.provideMerge(NativeTelemetryLayerLive),
130+ Layer.provideMerge(DesktopTelemetryReceiver.layer),
131+);
132+
133+const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe(
134+ Layer.provide(ResourceTelemetryLayerLive),
135+);
136+
115137 const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
116− Layer.provide(HostPowerMonitor.layer),
138+ Layer.provide(HostPowerMonitorLayerLive),
117139 Layer.provideMerge(ServerSettingsLive),
118140 );
119141
142+const ResourceDiagnosticsLayerLive = Layer.mergeAll(
143+ ResourceTelemetryLayerLive,
144+ ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
145+ ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
146+);
147+
120148 const RelayClientLive = Layer.unwrap(
121149 Effect.gen(function* () {
122150 const config = yield* ServerConfig;
@@ -337,8 +365,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 214 unmodified lines
337365 const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
338366 // Misc.
339367 Layer.provideMerge(BackgroundLayerLive),
340− Layer.provideMerge(ProcessDiagnostics.layer),
341− Layer.provideMerge(ProcessResourceMonitor.layer),
368+ Layer.provideMerge(ResourceDiagnosticsLayerLive),
342369 Layer.provideMerge(TraceDiagnostics.layer),
343370 Layer.provideMerge(AnalyticsServiceLayerLive),
344371 Layer.provideMerge(ExternalLauncher.layer),
@@ -488,7 +515,7 @@ export const makeServerLayer = Layer.unwrap( · 143 unmodified lines
488515 Layer.provideMerge(RuntimeServicesLive),
489516 Layer.provideMerge(serverRelayBrokerTracingLayer),
490517 Layer.provideMerge(HttpServerLive),
491− Layer.provide(ObservabilityLive),
518+ Layer.provide(ApplicationObservabilityLive),
492519 Layer.provideMerge(FetchHttpClient.layer),
493520 Layer.provideMerge(VcsProcess.layer),
494521 Layer.provideMerge(PlatformServicesLive),
apps/server/src/ws.ts+31−0
@@ -95,6 +95,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; · 94 unmodified lines
9595 import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts";
9696 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
9797 import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
98+import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
9899 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
99100 import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
100101 import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";
@@ -159,6 +160,9 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 58 unmodified lines
159160 [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope],
160161 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
161162 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
163+ [WS_METHODS.serverGetResourceTelemetry, AuthOrchestrationReadScope],
164+ [WS_METHODS.serverGetResourceTelemetryHistory, AuthOrchestrationReadScope],
165+ [WS_METHODS.serverRetryResourceTelemetry, AuthOrchestrationOperateScope],
162166 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
163167 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],
164168 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],
@@ -173,6 +177,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 8 unmodified lines
173177 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
174178 [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
175179 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
180+ [WS_METHODS.subscribeResourceTelemetry, AuthOrchestrationReadScope],
176181 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
177182 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
178183 [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope],
@@ -298,6 +303,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 119 unmodified lines
298303 const sessions = yield* SessionStore.SessionStore;
299304 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
300305 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
306+ const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
301307 const relayClient = yield* RelayClient.RelayClient;
302308 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
303309 new EnvironmentAuthorizationError({
@@ -1105,6 +1111,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 801 unmodified lines
11051111 "rpc.aggregate": "server",
11061112 },
11071113 ),
1114+ [WS_METHODS.serverGetResourceTelemetry]: (_input) =>
1115+ observeRpcEffect(WS_METHODS.serverGetResourceTelemetry, resourceTelemetry.latest, {
1116+ "rpc.aggregate": "server",
1117+ }),
1118+ [WS_METHODS.serverGetResourceTelemetryHistory]: (input) =>
1119+ observeRpcEffect(
1120+ WS_METHODS.serverGetResourceTelemetryHistory,
1121+ resourceTelemetry.readHistory(input),
1122+ {
1123+ "rpc.aggregate": "server",
1124+ },
1125+ ),
1126+ [WS_METHODS.serverRetryResourceTelemetry]: (_input) =>
1127+ observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {
1128+ "rpc.aggregate": "server",
1129+ }),
11081130 [WS_METHODS.serverSignalProcess]: (input) =>
11091131 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
11101132 "rpc.aggregate": "server",
@@ -1656,6 +1678,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 545 unmodified lines
16561678 ),
16571679 { "rpc.aggregate": "server" },
16581680 ),
1681+ [WS_METHODS.subscribeResourceTelemetry]: (_input) =>
1682+ observeRpcStream(
1683+ WS_METHODS.subscribeResourceTelemetry,
1684+ Stream.concat(
1685+ Stream.unwrap(Effect.map(resourceTelemetry.latest, Stream.make)),
1686+ resourceTelemetry.changes,
1687+ ),
1688+ { "rpc.aggregate": "server" },
1689+ ),
16591690 });
16601691 }),
16611692 );
apps/web/src/components/settings/DiagnosticsSettings.tsx+4−696
@@ -1,18 +1,5 @@
1−import {
2− AlertTriangleIcon,
3− ChevronDownIcon,
4− ChevronRightIcon,
5− CopyIcon,
6− FolderOpenIcon,
7− InfoIcon,
8− RefreshCwIcon,
9−} from "lucide-react";
10−import { useCallback, useMemo, useState, type ReactNode } from "react";
11−import type {
12− ServerProcessDiagnosticsEntry,
13− ServerProcessResourceHistorySummary,
14− ServerProcessSignal,
15−} from "@t3tools/contracts";
1+import { AlertTriangleIcon, CopyIcon, FolderOpenIcon, InfoIcon, RefreshCwIcon } from "lucide-react";
2+import { useCallback, useState, type ReactNode } from "react";
163 import * as DateTime from "effect/DateTime";
174 import * as Option from "effect/Option";
185
@@ -21,15 +8,11 @@ import { cn } from "../../lib/utils"; · 2 unmodified lines
218 import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
229 import { formatRelativeTime } from "../../timestampFormat";
2310 import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState";
24−import {
25− useProcessDiagnostics,
26− useProcessResourceHistory,
27−} from "../../lib/processDiagnosticsState";
2811 import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState";
2912 import { Button } from "../ui/button";
3013 import { ScrollArea } from "../ui/scroll-area";
3114 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
32−import { toastManager } from "../ui/toast";
15+import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
3316 import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
3417
3518 const NUMBER_FORMAT = new Intl.NumberFormat();
@@ -43,18 +26,6 @@ function formatDuration(value: number): string { · 7 unmodified lines
4326 return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)} s`;
4427 }
4528
46−function formatBytes(value: number): string {
47− if (value < 1024) return `${value} B`;
48− const units = ["KB", "MB", "GB"] as const;
49− let unitIndex = -1;
50− let next = value;
51− do {
52− next /= 1024;
53− unitIndex += 1;
54− } while (next >= 1024 && unitIndex < units.length - 1);
55− return `${next.toFixed(next >= 10 ? 1 : 2)} ${units[unitIndex]}`;
56−}
57−
5829 function formatRelative(value: DateTime.Utc | null): string {
5930 if (!value) return "No trace records";
6031 const relative = formatRelativeTime(DateTime.formatIso(value));
@@ -70,10 +41,6 @@ function shortenTraceId(traceId: string): string { · 9 unmodified lines
7041 return `${traceId.slice(0, 18)}...${traceId.slice(-10)}`;
7142 }
7243
73−function isStaleProcessSignalMessage(message: string | undefined): boolean {
74− return message?.includes("not a live descendant") ?? false;
75−}
76−
7744 function StatBlock({
7845 label,
7946 value,
@@ -286,471 +253,6 @@ function TraceIdCell({ traceId }: { traceId: string }) { · 206 unmodified lines
286253 );
287254 }
288255
289−function formatProcessName(command: string): string {
290− const firstToken = command.trim().split(/\s+/)[0];
291− if (!firstToken) return command;
292− const normalized = firstToken.replace(/^['"]|['"]$/g, "");
293− const segments = normalized.split(/[\\/]/).filter(Boolean);
294− return segments.at(-1) ?? normalized;
295−}
296−
297−function formatProcessType(process: ServerProcessDiagnosticsEntry): string {
298− if (process.depth > 0) return "Subprocess";
299− if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent";
300− return "Process";
301−}
302−
303−function ProcessNameCell({
304− process,
305− isExpanded,
306− onToggle,
307−}: {
308− process: ServerProcessDiagnosticsEntry;
309− isExpanded: boolean;
310− onToggle: (pid: number) => void;
311−}) {
312− const name = formatProcessName(process.command);
313− const hasChildren = process.childPids.length > 0;
314− const ChevronIcon = isExpanded ? ChevronDownIcon : ChevronRightIcon;
315−
316− return (
317− <div
318− className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
319− style={{ paddingLeft: `${Math.min(process.depth, 6) * 10}px` }}
320− >
321− {hasChildren ? (
322− <button
323− type="button"
324− className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
325− aria-label={isExpanded ? `Collapse ${name}` : `Expand ${name}`}
326− onClick={() => onToggle(process.pid)}
327− >
328− <ChevronIcon className="size-3.5" />
329− </button>
330− ) : (
331− <span className="size-5 shrink-0" aria-hidden="true" />
332− )}
333− <span className="size-1.5 shrink-0 rounded-full bg-emerald-500/80" />
334− <Tooltip>
335− <TooltipTrigger
336− render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>}
337− />
338− <TooltipPopup
339− side="top"
340− className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap"
341− >
342− {process.command}
343− </TooltipPopup>
344− </Tooltip>
345− </div>
346− );
347−}
348−
349−function ProcessSignalActions({
350− process,
351− isSignaling,
352− onSignal,
353−}: {
354− process: ServerProcessDiagnosticsEntry;
355− isSignaling: boolean;
356− onSignal: (pid: number, signal: ServerProcessSignal) => void;
357−}) {
358− return (
359− <div className="flex items-center justify-end gap-1.5">
360− <Tooltip>
361− <TooltipTrigger
362− render={
363− <button
364− type="button"
365− disabled={isSignaling}
366− className="text-[11px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50"
367− onClick={() => onSignal(process.pid, "SIGINT")}
368− >
369− INT
370− </button>
371− }
372− />
373− <TooltipPopup side="top">Send SIGINT</TooltipPopup>
374− </Tooltip>
375− <Tooltip>
376− <TooltipTrigger
377− render={
378− <button
379− type="button"
380− disabled={isSignaling}
381− className="text-[11px] font-medium text-destructive underline-offset-2 hover:underline disabled:pointer-events-none disabled:opacity-50"
382− onClick={() => onSignal(process.pid, "SIGKILL")}
383− >
384− KILL
385− </button>
386− }
387− />
388− <TooltipPopup side="top">Send SIGKILL</TooltipPopup>
389− </Tooltip>
390− </div>
391− );
392−}
393−
394−function ProcessDiagnosticsTable({
395− processes,
396− signalingPid,
397− onSignal,
398− emptyLabel,
399−}: {
400− processes: ReadonlyArray<ServerProcessDiagnosticsEntry>;
401− signalingPid: number | null;
402− onSignal: (pid: number, signal: ServerProcessSignal) => void;
403− emptyLabel?: string;
404−}) {
405− const [collapsedPids, setCollapsedPids] = useState<ReadonlySet<number>>(() => new Set());
406− const visibleProcesses = useMemo(() => {
407− const visible: ServerProcessDiagnosticsEntry[] = [];
408− let hiddenChildDepth: number | null = null;
409−
410− for (const process of processes) {
411− if (hiddenChildDepth !== null) {
412− if (process.depth > hiddenChildDepth) continue;
413− hiddenChildDepth = null;
414− }
415−
416− visible.push(process);
417− if (collapsedPids.has(process.pid)) {
418− hiddenChildDepth = process.depth;
419− }
420− }
421−
422− return visible;
423− }, [collapsedPids, processes]);
424−
425− const toggleProcess = useCallback((pid: number) => {
426− setCollapsedPids((previous) => {
427− const next = new Set(previous);
428− if (next.has(pid)) {
429− next.delete(pid);
430− } else {
431− next.add(pid);
432− }
433− return next;
434− });
435− }, []);
436−
437− return (
438− <ScrollArea
439− chainVerticalScroll
440− scrollFade
441− hideScrollbars
442− className="max-h-[min(64vh,44rem)] w-full max-w-full rounded-none border-t border-border/60"
443− >
444− <table className="w-full min-w-[1040px] table-fixed text-left text-xs">
445− <colgroup>
446− <col className="w-[24%]" />
447− <col className="w-[8%]" />
448− <col className="w-[10%]" />
449− <col className="w-[33%]" />
450− <col className="w-[8%]" />
451− <col className="w-[11%]" />
452− <col className="w-[6%]" />
453− </colgroup>
454− <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[11px] uppercase tracking-[0.08em] text-muted-foreground/70">
455− <tr>
456− <th className="px-4 py-2 font-semibold sm:pl-5">Name</th>
457− <th className="px-3 py-2 text-right font-semibold">CPU</th>
458− <th className="px-3 py-2 text-right font-semibold">Memory</th>
459− <th className="px-3 py-2 font-semibold">Command</th>
460− <th className="px-3 py-2 text-right font-semibold">PID</th>
461− <th className="px-3 py-2 font-semibold">Type</th>
462− <th className="p-2 text-right font-semibold sm:pr-4">Kill</th>
463− </tr>
464− </thead>
465− <tbody className="divide-y divide-border/50">
466− {visibleProcesses.length === 0 ? (
467− <tr>
468− <td colSpan={7} className="px-4 py-4 text-xs text-muted-foreground sm:px-5">
469− {emptyLabel ?? "No live descendant processes found."}
470− </td>
471− </tr>
472− ) : null}
473− {visibleProcesses.map((process) => (
474− <tr key={process.pid} className="hover:bg-muted/20">
475− <td className="px-4 py-2 align-middle sm:pl-5">
476− <ProcessNameCell
477− process={process}
478− isExpanded={!collapsedPids.has(process.pid)}
479− onToggle={toggleProcess}
480− />
481− </td>
482− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
483− {process.cpuPercent.toFixed(1)}%
484− </td>
485− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
486− {formatBytes(process.rssBytes)}
487− </td>
488− <td className="px-3 py-2 align-middle text-muted-foreground">
489− <Tooltip>
490− <TooltipTrigger
491− render={<span className="block truncate">{process.command}</span>}
492− />
493− <TooltipPopup
494− side="top"
495− className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap"
496− >
497− {process.command}
498− </TooltipPopup>
499− </Tooltip>
500− </td>
501− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums text-muted-foreground">
502− {process.pid}
503− </td>
504− <td className="truncate px-3 py-2 align-middle text-muted-foreground">
505− {formatProcessType(process)}
506− </td>
507− <td className="p-2 align-middle sm:pr-4">
508− <ProcessSignalActions
509− process={process}
510− isSignaling={signalingPid === process.pid}
511− onSignal={onSignal}
512− />
513− </td>
514− </tr>
515− ))}
516− </tbody>
517− </table>
518− </ScrollArea>
519− );
520−}
521−
522−const RESOURCE_HISTORY_WINDOWS = [
523− { label: "5m", windowMs: 5 * 60_000, bucketMs: 30_000 },
524− { label: "15m", windowMs: 15 * 60_000, bucketMs: 60_000 },
525− { label: "30m", windowMs: 30 * 60_000, bucketMs: 2 * 60_000 },
526− { label: "1h", windowMs: 60 * 60_000, bucketMs: 5 * 60_000 },
527−] as const;
528−
529−function formatCpuTime(seconds: number): string {
530− if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`;
531− const minutes = seconds / 60;
532− if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`;
533− return `${(minutes / 60).toFixed(2)}h`;
534−}
535−
536−function formatShortProcessName(command: string): string {
537− const name = formatProcessName(command);
538− return name.length > 42 ? `${name.slice(0, 39)}...` : name;
539−}
540−
541−function ResourceHistoryProcessNameCell({
542− process,
543− visualDepth,
544−}: {
545− process: ServerProcessResourceHistorySummary;
546− visualDepth: number;
547−}) {
548− const name = formatShortProcessName(process.command);
549−
550− return (
551− <div
552− className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
553− style={{ paddingLeft: `${Math.min(visualDepth, 6) * 10}px` }}
554− aria-label={`${process.isServerRoot ? "Root" : "Child"} process ${name}`}
555− >
556− <span className="size-5 shrink-0" aria-hidden="true" />
557− <span
558− className={cn(
559− "size-1.5 shrink-0 rounded-full",
560− process.isServerRoot ? "bg-amber-500/90" : "bg-emerald-500/80",
561− )}
562− />
563− <Tooltip>
564− <TooltipTrigger
565− render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>}
566− />
567− <TooltipPopup
568− side="top"
569− className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap"
570− >
571− {process.command}
572− </TooltipPopup>
573− </Tooltip>
574− </div>
575− );
576−}
577−
578−function ProcessResourceHistoryChart({
579− buckets,
580−}: {
581− buckets: ReadonlyArray<{
582− readonly startedAt: DateTime.Utc;
583− readonly avgCpuPercent: number;
584− readonly maxCpuPercent: number;
585− }>;
586−}) {
587− const maxCpuPercent = Math.max(1, ...buckets.map((bucket) => bucket.maxCpuPercent));
588−
589− return (
590− <div className="border-t border-border/60 px-4 py-3 sm:px-5">
591− <div className="flex h-28 items-end gap-1 overflow-hidden rounded-sm bg-muted/10 p-2">
592− {buckets.map((bucket) => {
593− const peakHeight = Math.max(2, (bucket.maxCpuPercent / maxCpuPercent) * 100);
594− const averageHeight = Math.max(2, (bucket.avgCpuPercent / maxCpuPercent) * 100);
595− return (
596− <Tooltip key={DateTime.formatIso(bucket.startedAt)}>
597− <TooltipTrigger
598− render={
599− <div className="flex h-full min-w-1 flex-1 items-end">
600− <div
601− className="relative h-full w-full"
602− aria-label={`Average CPU ${bucket.avgCpuPercent.toFixed(1)}%, peak CPU ${bucket.maxCpuPercent.toFixed(1)}%`}
603− >
604− <div
605− className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/15 transition-colors"
606− style={{ height: `${peakHeight}%` }}
607− />
608− <div
609− className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/60 transition-colors"
610− style={{ height: `${averageHeight}%` }}
611− />
612− </div>
613− </div>
614− }
615− />
616− <TooltipPopup side="top">
617− Avg {bucket.avgCpuPercent.toFixed(1)}%, peak {bucket.maxCpuPercent.toFixed(1)}%
618− </TooltipPopup>
619− </Tooltip>
620− );
621− })}
622− </div>
623− </div>
624− );
625−}
626−
627−function ResourceHistoryWindowSelector({
628− selectedWindowMs,
629− onSelect,
630−}: {
631− selectedWindowMs: number;
632− onSelect: (windowMs: number) => void;
633−}) {
634− return (
635− <div className="flex items-center rounded-md border border-border/60 p-0.5">
636− {RESOURCE_HISTORY_WINDOWS.map((option) => (
637− <button
638− key={option.windowMs}
639− type="button"
640− className={cn(
641− "h-6 rounded-sm px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground",
642− selectedWindowMs === option.windowMs && "bg-muted text-foreground",
643− )}
644− onClick={() => onSelect(option.windowMs)}
645− >
646− {option.label}
647− </button>
648− ))}
649− </div>
650− );
651−}
652−
653−function ProcessResourceHistoryTable({
654− processes,
655− emptyLabel,
656−}: {
657− processes: ReadonlyArray<ServerProcessResourceHistorySummary>;
658− emptyLabel: string;
659−}) {
660− const shallowestChildDepth = processes.reduce<number | null>((minDepth, process) => {
661− if (process.isServerRoot) return minDepth;
662− return minDepth === null ? process.depth : Math.min(minDepth, process.depth);
663− }, null);
664−
665− return (
666− <ScrollArea
667− chainVerticalScroll
668− scrollFade
669− hideScrollbars
670− className="max-h-[min(64vh,44rem)] w-full max-w-full border-t border-border/60"
671− >
672− <table className="w-full min-w-[980px] table-fixed text-left text-xs">
673− <colgroup>
674− <col className="w-[24%]" />
675− <col className="w-[10%]" />
676− <col className="w-[10%]" />
677− <col className="w-[10%]" />
678− <col className="w-[10%]" />
679− <col className="w-[10%]" />
680− <col className="w-[16%]" />
681− <col className="w-[10%]" />
682− </colgroup>
683− <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[11px] uppercase tracking-[0.08em] text-muted-foreground/70">
684− <tr>
685− <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
686− <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
687− <th className="px-3 py-2 text-right font-semibold">Current</th>
688− <th className="px-3 py-2 text-right font-semibold">Average</th>
689− <th className="px-3 py-2 text-right font-semibold">Peak</th>
690− <th className="px-3 py-2 text-right font-semibold">Max Mem</th>
691− <th className="px-3 py-2 font-semibold">Command</th>
692− <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th>
693− </tr>
694− </thead>
695− <tbody className="divide-y divide-border/50">
696− {processes.length === 0 ? (
697− <tr>
698− <td colSpan={8} className="px-4 py-4 text-xs text-muted-foreground sm:px-5">
699− {emptyLabel}
700− </td>
701− </tr>
702− ) : null}
703− {processes.map((process) => (
704− <tr key={process.processKey} className="hover:bg-muted/20">
705− <td className="px-4 py-2 align-middle sm:pl-5">
706− <ResourceHistoryProcessNameCell
707− process={process}
708− visualDepth={
709− process.isServerRoot || shallowestChildDepth === null
710− ? 0
711− : Math.max(1, process.depth - shallowestChildDepth + 1)
712− }
713− />
714− </td>
715− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
716− {formatCpuTime(process.cpuSecondsApprox)}
717− </td>
718− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
719− {process.currentCpuPercent.toFixed(1)}%
720− </td>
721− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
722− {process.avgCpuPercent.toFixed(1)}%
723− </td>
724− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
725− {process.maxCpuPercent.toFixed(1)}%
726− </td>
727− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
728− {formatBytes(process.maxRssBytes)}
729− </td>
730− <td className="px-3 py-2 align-middle text-muted-foreground">
731− <Tooltip>
732− <TooltipTrigger
733− render={<span className="block truncate">{process.command}</span>}
734− />
735− <TooltipPopup
736− side="top"
737− className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap"
738− >
739− {process.command}
740− </TooltipPopup>
741− </Tooltip>
742− </td>
743− <td className="px-3 py-2 text-right align-middle font-mono tabular-nums text-muted-foreground sm:pr-5">
744− {process.pid}
745− </td>
746− </tr>
747− ))}
748− </tbody>
749− </table>
750− </ScrollArea>
751− );
752−}
753−
754256 function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) {
755257 useRelativeTimeTick();
756258 const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null;
@@ -805,29 +307,9 @@ function DiagnosticsRefreshButton({ · 48 unmodified lines
805307 export function DiagnosticsSettingsPanel() {
806308 const observability = useServerObservability();
807309 const availableEditors = useServerAvailableEditors();
808− const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000);
809− const selectedResourceWindow =
810− RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
811− RESOURCE_HISTORY_WINDOWS[1];
812310 const { data, error, isPending, refresh } = useTraceDiagnostics();
813− const {
814− data: processData,
815− error: processError,
816− isPending: isProcessPending,
817− refresh: refreshProcesses,
818− } = useProcessDiagnostics();
819− const {
820− data: resourceData,
821− error: resourceError,
822− isPending: isResourcePending,
823− refresh: refreshResources,
824− } = useProcessResourceHistory({
825− windowMs: selectedResourceWindow.windowMs,
826− bucketMs: selectedResourceWindow.bucketMs,
827− });
828311 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);
829312 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);
830− const [signalingPid, setSignalingPid] = useState<number | null>(null);
831313
832314 const openLogsDirectory = useCallback(() => {
833315 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
@@ -854,58 +336,7 @@ export function DiagnosticsSettingsPanel() { · 20 unmodified lines
854336 }, [availableEditors, observability?.logsDirectoryPath]);
855337
856338 const isInitialLoading = isPending && data === null;
857− const isProcessInitialLoading = isProcessPending && processData === null;
858− const signalProcess = useCallback(
859− (pid: number, signal: ServerProcessSignal) => {
860− if (
861− signal === "SIGKILL" &&
862− !window.confirm(`Send SIGKILL to process ${pid}? This cannot be handled by the process.`)
863− ) {
864− return;
865− }
866−
867− setSignalingPid(pid);
868− void ensureLocalApi()
869− .server.signalProcess({ pid, signal })
870− .then((result) => {
871− if (!result.signaled) {
872− const message = Option.getOrUndefined(result.message);
873− refreshProcesses();
874− if (isStaleProcessSignalMessage(message)) {
875− toastManager.add({
876− type: "info",
877− title: "Process already exited",
878− description:
879− "The process is not a child of the T3 Server. It might already have exited.",
880− });
881− return;
882− }
883339
884− toastManager.add({
885− type: "error",
886− title: `Could not send ${signal}`,
887− description: message ?? `Failed to send ${signal}.`,
888− });
889− return;
890− }
891− refreshProcesses();
892− })
893− .catch((error: unknown) => {
894− toastManager.add({
895− type: "error",
896− title: `Could not send ${signal}`,
897− description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
898− });
899− })
900− .finally(() => {
901− setSignalingPid(null);
902− });
903− },
904− [refreshProcesses],
905− );
906−
907− const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;
908− const processResourceError = resourceData ? Option.getOrNull(resourceData.error) : null;
909340 const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null;
910341 const traceDiagnosticsPartialFailure = data
911342 ? Option.getOrElse(data.partialFailure, () => false)
@@ -913,130 +344,7 @@ export function DiagnosticsSettingsPanel() { · 1 unmodified line
913344
914345 return (
915346 <SettingsPageContainer>
916− <SettingsSection
917− title="Live Processes"
918− headerAction={
919− <div className="flex items-center gap-1.5">
920− <DiagnosticsLastChecked checkedAt={processData?.readAt ?? null} />
921− <DiagnosticsRefreshButton
922− isPending={isProcessPending}
923− label="Refresh process diagnostics"
924− onClick={refreshProcesses}
925− />
926− </div>
927− }
928− >
929− <StatsGrid>
930− <StatBlock
931− label="Child Processes"
932− value={processData ? formatCount(processData.processCount) : "..."}
933− />
934− <StatBlock
935− label="CPU"
936− value={processData ? `${processData.totalCpuPercent.toFixed(1)}%` : "..."}
937− tooltip="Total CPU across live child processes of the current server process. The desktop shell and other parent processes are not included."
938− />
939− <StatBlock
940− label="Memory"
941− value={processData ? formatBytes(processData.totalRssBytes) : "..."}
942− tooltip="Total resident memory across live child processes of the current server process. The desktop shell and other parent processes are not included."
943− />
944− <StatBlock
945− label="Server PID"
946− value={processData ? String(processData.serverPid) : "..."}
947− />
948− </StatsGrid>
949− {processDiagnosticsError || processError ? (
950− <div className="space-y-2 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-5">
951− {processDiagnosticsError ? (
952− <div className="flex items-start gap-2 text-destructive">
953− <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
954− <span>{processDiagnosticsError.message}</span>
955− </div>
956− ) : null}
957− {processError ? (
958− <div className="flex items-start gap-2 text-destructive">
959− <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
960− <span>{processError}</span>
961− </div>
962− ) : null}
963− </div>
964− ) : null}
965− <ProcessDiagnosticsTable
966− processes={processData?.processes ?? []}
967− signalingPid={signalingPid}
968− onSignal={signalProcess}
969− emptyLabel={
970− isProcessInitialLoading
971− ? "Loading live processes..."
972− : "No live descendant processes found."
973− }
974− />
975− </SettingsSection>
976−
977− <SettingsSection
978− title="Resource History"
979− headerAction={
980− <div className="flex items-center gap-1.5">
981− <ResourceHistoryWindowSelector
982− selectedWindowMs={resourceWindowMs}
983− onSelect={setResourceWindowMs}
984− />
985− <DiagnosticsLastChecked checkedAt={resourceData?.readAt ?? null} />
986− <DiagnosticsRefreshButton
987− isPending={isResourcePending}
988− label="Refresh resource history"
989− onClick={refreshResources}
990− />
991− </div>
992− }
993− >
994− <StatsGrid>
995− <StatBlock
996− label="CPU Time"
997− value={resourceData ? formatCpuTime(resourceData.totalCpuSecondsApprox) : "..."}
998− tooltip="Approximate active CPU time for the T3 server root process and its descendants during the selected window. It grows only while sampled processes use CPU and older samples leave as the window moves."
999− />
1000− <StatBlock
1001− label="Samples"
1002− value={resourceData ? formatCount(resourceData.retainedSampleCount) : "..."}
1003− tooltip="In-memory process samples retained by the server. This resets when the server restarts."
1004− />
1005− <StatBlock
1006− label="Interval"
1007− value={resourceData ? formatDuration(resourceData.sampleIntervalMs) : "..."}
1008− />
1009− <StatBlock
1010− label="Processes"
1011− value={resourceData ? formatCount(resourceData.topProcesses.length) : "..."}
1012− />
1013− </StatsGrid>
1014− {processResourceError || resourceError ? (
1015− <div className="space-y-2 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-5">
1016− {processResourceError ? (
1017− <div className="flex items-start gap-2 text-destructive">
1018− <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
1019− <span>{processResourceError.message}</span>
1020− </div>
1021− ) : null}
1022− {resourceError ? (
1023− <div className="flex items-start gap-2 text-destructive">
1024− <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
1025− <span>{resourceError}</span>
1026− </div>
1027− ) : null}
1028− </div>
1029− ) : null}
1030− <ProcessResourceHistoryChart buckets={resourceData?.buckets ?? []} />
1031− <ProcessResourceHistoryTable
1032− processes={resourceData?.topProcesses ?? []}
1033− emptyLabel={
1034− isResourcePending && resourceData === null
1035− ? "Collecting process resource samples..."
1036− : "No process resource samples found for this window."
1037− }
1038− />
1039− </SettingsSection>
347+ <ResourceTelemetryDiagnostics />
1040348
1041349 <SettingsSection
1042350 title="Trace Diagnostics"
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+1151−0
@@ -0,0 +1,1151 @@
1+import {
2+ ActivityIcon,
3+ AlertTriangleIcon,
4+ BatteryIcon,
5+ ChevronDownIcon,
6+ ChevronRightIcon,
7+ CpuIcon,
8+ DatabaseIcon,
9+ GaugeIcon,
10+ HardDriveIcon,
11+ MemoryStickIcon,
12+ RefreshCwIcon,
13+ RotateCcwIcon,
14+} from "lucide-react";
15+import type {
16+ BackgroundBooleanState,
17+ ResourceAttributionEntry,
18+ ResourceTelemetryAggregate,
19+ ResourceTelemetryHistoryBucket,
20+ ResourceTelemetryIoSemantics,
21+ ResourceTelemetryProcess,
22+ ResourceTelemetryProcessCategory,
23+ ResourceTelemetryProcessSummary,
24+ ResourceTelemetrySourceHealth,
25+ ResourceTelemetrySourceStatus,
26+ ServerProcessSignal,
27+} from "@t3tools/contracts";
28+import * as DateTime from "effect/DateTime";
29+import * as Option from "effect/Option";
30+import { useCallback, useMemo, useState, type ReactNode } from "react";
31+
32+import { ensureLocalApi } from "../../localApi";
33+import {
34+ useResourceTelemetry,
35+ useResourceTelemetryHistory,
36+} from "../../lib/resourceTelemetryState";
37+import { cn } from "../../lib/utils";
38+import { formatRelativeTime } from "../../timestampFormat";
39+import { Button } from "../ui/button";
40+import { ScrollArea } from "../ui/scroll-area";
41+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
42+import { toastManager } from "../ui/toast";
43+import { SettingsSection, useRelativeTimeTick } from "./settingsLayout";
44+
45+const HISTORY_WINDOWS = [
46+ { label: "5m", windowMs: 5 * 60_000, bucketMs: 15_000 },
47+ { label: "15m", windowMs: 15 * 60_000, bucketMs: 30_000 },
48+ { label: "30m", windowMs: 30 * 60_000, bucketMs: 60_000 },
49+ { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 },
50+] as const;
51+
52+function formatBytes(value: number): string {
53+ if (value < 1_024) return `${Math.round(value)} B`;
54+ const units = ["KB", "MB", "GB", "TB"] as const;
55+ let next = value;
56+ let unitIndex = -1;
57+ do {
58+ next /= 1_024;
59+ unitIndex += 1;
60+ } while (next >= 1_024 && unitIndex < units.length - 1);
61+ return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`;
62+}
63+
64+function formatRate(value: number): string {
65+ return `${formatBytes(value)}/s`;
66+}
67+
68+function formatCpuTime(valueMs: number): string {
69+ const seconds = valueMs / 1_000;
70+ if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`;
71+ const minutes = seconds / 60;
72+ if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`;
73+ return `${(minutes / 60).toFixed(2)}h`;
74+}
75+
76+function formatDurationMicros(value: number): string {
77+ if (value < 1_000) return `${Math.round(value)} µs`;
78+ if (value < 1_000_000) return `${(value / 1_000).toFixed(2)} ms`;
79+ return `${(value / 1_000_000).toFixed(2)} s`;
80+}
81+
82+function processIdentityKey(process: ResourceTelemetryProcess): string {
83+ return `${process.identity.pid}:${process.identity.startTimeMs}`;
84+}
85+
86+function processSummaryIdentityKey(process: ResourceTelemetryProcessSummary): string {
87+ return `${process.identity.pid}:${process.identity.startTimeMs}`;
88+}
89+
90+function formatProcessName(process: Pick<ResourceTelemetryProcess, "command" | "name">): string {
91+ if (process.name.trim()) return process.name;
92+ const firstToken = process.command.trim().split(/\s+/)[0] ?? process.command;
93+ const normalized = firstToken.replace(/^['"]|['"]$/g, "");
94+ return normalized.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? normalized;
95+}
96+
97+function categoryLabel(category: ResourceTelemetryProcessCategory): string {
98+ switch (category) {
99+ case "server":
100+ return "Server";
101+ case "server-child":
102+ return "Backend child";
103+ case "provider-root":
104+ return "Provider";
105+ case "terminal-root":
106+ return "Terminal";
107+ case "electron-main":
108+ return "Electron main";
109+ case "electron-renderer":
110+ return "Renderer";
111+ case "electron-gpu":
112+ return "GPU";
113+ case "electron-utility":
114+ return "Electron utility";
115+ case "resource-monitor":
116+ return "Monitor";
117+ case "unknown-t3":
118+ return "T3 process";
119+ }
120+}
121+
122+function categoryDotClass(category: ResourceTelemetryProcessCategory): string {
123+ if (category === "resource-monitor") return "bg-amber-500";
124+ if (category.startsWith("electron-")) return "bg-sky-500";
125+ if (category === "server") return "bg-violet-500";
126+ return "bg-emerald-500";
127+}
128+
129+function ioSemanticsLabel(semantics: ResourceTelemetryIoSemantics): string {
130+ switch (semantics) {
131+ case "storage":
132+ return "Storage bytes";
133+ case "logical":
134+ return "Logical bytes";
135+ case "all-io":
136+ return "All I/O bytes";
137+ case "unavailable":
138+ return "Unavailable";
139+ }
140+}
141+
142+function booleanStateLabel(
143+ value: BackgroundBooleanState,
144+ labels: { readonly true: string; readonly false: string },
145+): string {
146+ if (value === "true") return labels.true;
147+ if (value === "false") return labels.false;
148+ return "Unknown";
149+}
150+
151+function sourceStatusTone(status: ResourceTelemetrySourceStatus): "default" | "warning" | "danger" {
152+ if (status === "healthy") return "default";
153+ if (status === "starting" || status === "degraded") return "warning";
154+ return "danger";
155+}
156+
157+function SourceStatusBadge({
158+ label,
159+ status,
160+}: {
161+ label: string;
162+ status: ResourceTelemetrySourceStatus;
163+}) {
164+ const tone = sourceStatusTone(status);
165+ return (
166+ <span
167+ className={cn(
168+ "inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]",
169+ tone === "default" &&
170+ "border-emerald-500/20 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300",
171+ tone === "warning" &&
172+ "border-amber-500/25 bg-amber-500/8 text-amber-700 dark:text-amber-300",
173+ tone === "danger" && "border-destructive/25 bg-destructive/8 text-destructive",
174+ )}
175+ >
176+ <span
177+ className={cn(
178+ "size-1.5 rounded-full",
179+ tone === "default" && "bg-emerald-500",
180+ tone === "warning" && "bg-amber-500",
181+ tone === "danger" && "bg-destructive",
182+ )}
183+ />
184+ {label} {status}
185+ </span>
186+ );
187+}
188+
189+function LastSampleLabel({ sampledAt }: { sampledAt: DateTime.Utc | null }) {
190+ useRelativeTimeTick();
191+ if (!sampledAt) {
192+ return <span className="text-[11px] text-muted-foreground/55">Waiting for sample</span>;
193+ }
194+ const relative = formatRelativeTime(DateTime.formatIso(sampledAt));
195+ return (
196+ <span className="text-[11px] text-muted-foreground/60">
197+ Updated <span className="font-mono tabular-nums">{relative.value}</span>
198+ {relative.suffix ? ` ${relative.suffix}` : ""}
199+ </span>
200+ );
201+}
202+
203+function IconStat({
204+ icon,
205+ label,
206+ value,
207+ detail,
208+ tone = "default",
209+}: {
210+ icon: ReactNode;
211+ label: string;
212+ value: string;
213+ detail?: string | undefined;
214+ tone?: "default" | "warning" | "danger";
215+}) {
216+ return (
217+ <div className="min-w-0 border-border/60 px-4 py-3.5 sm:px-5">
218+ <div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/65">
219+ <span className="text-muted-foreground/50">{icon}</span>
220+ <span className="truncate">{label}</span>
221+ </div>
222+ <div
223+ className={cn(
224+ "mt-2 truncate font-mono text-xl font-semibold tracking-[-0.04em] tabular-nums text-foreground",
225+ tone === "warning" && "text-amber-600 dark:text-amber-300",
226+ tone === "danger" && "text-destructive",
227+ )}
228+ >
229+ {value}
230+ </div>
231+ {detail ? (
232+ <div className="mt-1 truncate text-[10px] text-muted-foreground/55">{detail}</div>
233+ ) : null}
234+ </div>
235+ );
236+}
237+
238+function AggregateCard({
239+ label,
240+ accentClass,
241+ aggregate,
242+}: {
243+ label: string;
244+ accentClass: string;
245+ aggregate: ResourceTelemetryAggregate;
246+}) {
247+ return (
248+ <div className="relative overflow-hidden border-t border-border/60 px-4 py-3.5 first:border-t-0 md:border-t-0 md:border-l md:first:border-l-0 sm:px-5">
249+ <span className={cn("absolute inset-x-0 top-0 h-px opacity-80", accentClass)} />
250+ <div className="flex items-center justify-between gap-3">
251+ <div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/65">
252+ {label}
253+ </div>
254+ <div className="font-mono text-[10px] tabular-nums text-muted-foreground/55">
255+ {aggregate.processCount} proc
256+ </div>
257+ </div>
258+ <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
259+ <MetricPair label="CPU" value={`${aggregate.currentCpuPercent.toFixed(1)}%`} />
260+ <MetricPair label="Memory" value={formatBytes(aggregate.currentRssBytes)} />
261+ <MetricPair label="Read" value={formatRate(aggregate.ioReadBytesPerSecond)} />
262+ <MetricPair label="Write" value={formatRate(aggregate.ioWriteBytesPerSecond)} />
263+ </div>
264+ </div>
265+ );
266+}
267+
268+function MetricPair({ label, value }: { label: string; value: string }) {
269+ return (
270+ <div className="min-w-0">
271+ <div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/45">
272+ {label}
273+ </div>
274+ <div className="truncate font-mono text-xs font-medium tabular-nums text-foreground/90">
275+ {value}
276+ </div>
277+ </div>
278+ );
279+}
280+
281+function HealthSource({ label, health }: { label: string; health: ResourceTelemetrySourceHealth }) {
282+ return (
283+ <div className="flex items-start justify-between gap-4 border-t border-border/50 py-2.5 first:border-t-0">
284+ <div className="min-w-0">
285+ <div className="text-xs font-medium text-foreground">{label}</div>
286+ <div className="mt-0.5 truncate text-[10px] text-muted-foreground/60">
287+ {Option.match(health.lastError, {
288+ onNone: () => "No reported errors",
289+ onSome: (error) => error,
290+ })}
291+ </div>
292+ </div>
293+ <SourceStatusBadge label="" status={health.status} />
294+ </div>
295+ );
296+}
297+
298+function DetailRow({
299+ label,
300+ value,
301+ valueClassName,
302+}: {
303+ label: string;
304+ value: ReactNode;
305+ valueClassName?: string | undefined;
306+}) {
307+ return (
308+ <div className="flex items-center justify-between gap-4 border-t border-border/50 py-2 first:border-t-0">
309+ <span className="text-[11px] text-muted-foreground/70">{label}</span>
310+ <span
311+ className={cn(
312+ "min-w-0 truncate text-right font-mono text-[11px] tabular-nums text-foreground/85",
313+ valueClassName,
314+ )}
315+ >
316+ {value}
317+ </span>
318+ </div>
319+ );
320+}
321+
322+function HistoryWindowSelector({
323+ selectedWindowMs,
324+ onSelect,
325+}: {
326+ selectedWindowMs: number;
327+ onSelect: (windowMs: number) => void;
328+}) {
329+ return (
330+ <div className="flex items-center rounded-md border border-border/60 p-0.5">
331+ {HISTORY_WINDOWS.map((option) => (
332+ <button
333+ key={option.windowMs}
334+ type="button"
335+ className={cn(
336+ "h-6 rounded-sm px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground",
337+ selectedWindowMs === option.windowMs && "bg-muted text-foreground",
338+ )}
339+ onClick={() => onSelect(option.windowMs)}
340+ >
341+ {option.label}
342+ </button>
343+ ))}
344+ </div>
345+ );
346+}
347+
348+function ResourceHistoryChart({
349+ buckets,
350+}: {
351+ buckets: ReadonlyArray<ResourceTelemetryHistoryBucket>;
352+}) {
353+ const maxCpu = Math.max(1, ...buckets.map((bucket) => bucket.maxCpuPercent));
354+ const maxIo = Math.max(1, ...buckets.map((bucket) => bucket.ioReadBytes + bucket.ioWriteBytes));
355+
356+ return (
357+ <div className="border-t border-border/60 px-4 py-4 sm:px-5">
358+ <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground/65">
359+ <span className="inline-flex items-center gap-1.5">
360+ <span className="h-1.5 w-3 rounded-full bg-foreground/70" /> CPU average
361+ </span>
362+ <span className="inline-flex items-center gap-1.5">
363+ <span className="h-1.5 w-3 rounded-full bg-sky-500/70" /> I/O reads
364+ </span>
365+ <span className="inline-flex items-center gap-1.5">
366+ <span className="h-1.5 w-3 rounded-full bg-amber-500/80" /> I/O writes
367+ </span>
368+ </div>
369+ <div className="flex h-32 items-end gap-1 overflow-hidden rounded-lg border border-border/40 bg-muted/8 px-2 pt-3 pb-2">
370+ {buckets.map((bucket) => {
371+ const cpuHeight = Math.max(2, (bucket.avgCpuPercent / maxCpu) * 100);
372+ const readHeight = Math.max(1, (bucket.ioReadBytes / maxIo) * 100);
373+ const writeHeight = Math.max(1, (bucket.ioWriteBytes / maxIo) * 100);
374+ return (
375+ <Tooltip key={DateTime.formatIso(bucket.startedAt)}>
376+ <TooltipTrigger
377+ render={
378+ <div className="grid h-full min-w-1 flex-1 grid-cols-3 items-end gap-px">
379+ <span
380+ className="block min-h-px rounded-t-sm bg-foreground/65"
381+ style={{ height: `${cpuHeight}%` }}
382+ />
383+ <span
384+ className="block min-h-px rounded-t-sm bg-sky-500/70"
385+ style={{ height: `${readHeight}%` }}
386+ />
387+ <span
388+ className="block min-h-px rounded-t-sm bg-amber-500/80"
389+ style={{ height: `${writeHeight}%` }}
390+ />
391+ </div>
392+ }
393+ />
394+ <TooltipPopup side="top" className="space-y-0.5 text-left">
395+ <div>CPU avg {bucket.avgCpuPercent.toFixed(1)}%</div>
396+ <div>CPU peak {bucket.maxCpuPercent.toFixed(1)}%</div>
397+ <div>Read {formatBytes(bucket.ioReadBytes)}</div>
398+ <div>Write {formatBytes(bucket.ioWriteBytes)}</div>
399+ </TooltipPopup>
400+ </Tooltip>
401+ );
402+ })}
403+ </div>
404+ </div>
405+ );
406+}
407+
408+function ProcessTreeName({
409+ process,
410+ collapsed,
411+ onToggle,
412+}: {
413+ process: ResourceTelemetryProcess;
414+ collapsed: boolean;
415+ onToggle: (process: ResourceTelemetryProcess) => void;
416+}) {
417+ const name = formatProcessName(process);
418+ const hasChildren = process.childPids.length > 0;
419+ const ChevronIcon = collapsed ? ChevronRightIcon : ChevronDownIcon;
420+ return (
421+ <div
422+ className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
423+ style={{ paddingLeft: `${Math.min(process.depth, 7) * 10}px` }}
424+ >
425+ {hasChildren ? (
426+ <button
427+ type="button"
428+ className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
429+ onClick={() => onToggle(process)}
430+ aria-label={collapsed ? `Expand ${name}` : `Collapse ${name}`}
431+ >
432+ <ChevronIcon className="size-3.5" />
433+ </button>
434+ ) : (
435+ <span className="size-5" aria-hidden />
436+ )}
437+ <span className={cn("size-1.5 rounded-full", categoryDotClass(process.category))} />
438+ <Tooltip>
439+ <TooltipTrigger
440+ render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>}
441+ />
442+ <TooltipPopup
443+ side="top"
444+ className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]"
445+ >
446+ {process.command || process.name}
447+ </TooltipPopup>
448+ </Tooltip>
449+ </div>
450+ );
451+}
452+
453+function canSignalProcess(process: ResourceTelemetryProcess): boolean {
454+ return (
455+ process.category === "server-child" ||
456+ process.category === "provider-root" ||
457+ process.category === "terminal-root"
458+ );
459+}
460+
461+function ProcessActions({
462+ process,
463+ signalingKey,
464+ onSignal,
465+}: {
466+ process: ResourceTelemetryProcess;
467+ signalingKey: string | null;
468+ onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
469+}) {
470+ if (!canSignalProcess(process)) {
471+ return <span className="text-[10px] text-muted-foreground/35">—</span>;
472+ }
473+ const isSignaling = signalingKey === processIdentityKey(process);
474+ return (
475+ <div className="flex items-center justify-end gap-1.5">
476+ <button
477+ type="button"
478+ disabled={isSignaling}
479+ className="text-[10px] font-semibold text-muted-foreground hover:text-foreground disabled:opacity-50"
480+ onClick={() => onSignal(process, "SIGINT")}
481+ >
482+ INT
483+ </button>
484+ <button
485+ type="button"
486+ disabled={isSignaling}
487+ className="text-[10px] font-semibold text-destructive hover:underline disabled:opacity-50"
488+ onClick={() => onSignal(process, "SIGKILL")}
489+ >
490+ KILL
491+ </button>
492+ </div>
493+ );
494+}
495+
496+function ProcessTable({
497+ processes,
498+ signalingKey,
499+ onSignal,
500+}: {
501+ processes: ReadonlyArray<ResourceTelemetryProcess>;
502+ signalingKey: string | null;
503+ onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
504+}) {
505+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
506+ const visible = useMemo(() => {
507+ const result: ResourceTelemetryProcess[] = [];
508+ let hiddenDepth: number | null = null;
509+ for (const process of processes) {
510+ if (hiddenDepth !== null) {
511+ if (process.depth > hiddenDepth) continue;
512+ hiddenDepth = null;
513+ }
514+ result.push(process);
515+ if (collapsed.has(processIdentityKey(process))) {
516+ hiddenDepth = process.depth;
517+ }
518+ }
519+ return result;
520+ }, [collapsed, processes]);
521+ const toggle = useCallback((process: ResourceTelemetryProcess) => {
522+ const identityKey = processIdentityKey(process);
523+ setCollapsed((current) => {
524+ const next = new Set(current);
525+ if (next.has(identityKey)) {
526+ next.delete(identityKey);
527+ } else {
528+ next.add(identityKey);
529+ }
530+ return next;
531+ });
532+ }, []);
533+
534+ return (
535+ <ScrollArea
536+ chainVerticalScroll
537+ scrollFade
538+ hideScrollbars
539+ className="max-h-[min(68vh,48rem)] w-full max-w-full border-t border-border/60"
540+ >
541+ <table className="w-full min-w-[1320px] table-fixed text-left text-xs">
542+ <colgroup>
543+ <col className="w-[20%]" />
544+ <col className="w-[10%]" />
545+ <col className="w-[7%]" />
546+ <col className="w-[8%]" />
547+ <col className="w-[9%]" />
548+ <col className="w-[9%]" />
549+ <col className="w-[9%]" />
550+ <col className="w-[10%]" />
551+ <col className="w-[8%]" />
552+ <col className="w-[6%]" />
553+ <col className="w-[4%]" />
554+ </colgroup>
555+ <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65">
556+ <tr>
557+ <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
558+ <th className="px-3 py-2 font-semibold">Category</th>
559+ <th className="px-3 py-2 text-right font-semibold">CPU</th>
560+ <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
561+ <th className="px-3 py-2 text-right font-semibold">Memory</th>
562+ <th className="px-3 py-2 text-right font-semibold">Read/s</th>
563+ <th className="px-3 py-2 text-right font-semibold">Write/s</th>
564+ <th className="px-3 py-2 text-right font-semibold">Read Total</th>
565+ <th className="px-3 py-2 text-right font-semibold">Write Total</th>
566+ <th className="px-3 py-2 text-right font-semibold">PID</th>
567+ <th className="px-2 py-2 text-right font-semibold sm:pr-4">Kill</th>
568+ </tr>
569+ </thead>
570+ <tbody className="divide-y divide-border/50">
571+ {visible.length === 0 ? (
572+ <tr>
573+ <td colSpan={11} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
574+ Waiting for the native process monitor.
575+ </td>
576+ </tr>
577+ ) : null}
578+ {visible.map((process) => (
579+ <tr key={processIdentityKey(process)} className="hover:bg-muted/20">
580+ <td className="px-4 py-2 sm:pl-5">
581+ <ProcessTreeName
582+ process={process}
583+ collapsed={collapsed.has(processIdentityKey(process))}
584+ onToggle={toggle}
585+ />
586+ </td>
587+ <td className="truncate px-3 py-2 text-[11px] text-muted-foreground">
588+ {categoryLabel(process.category)}
589+ </td>
590+ <td className="px-3 py-2 text-right font-mono tabular-nums">
591+ {process.cpuPercent.toFixed(1)}%
592+ </td>
593+ <td className="px-3 py-2 text-right font-mono tabular-nums">
594+ {formatCpuTime(process.cpuTimeMs)}
595+ </td>
596+ <td className="px-3 py-2 text-right font-mono tabular-nums">
597+ {formatBytes(process.residentBytes)}
598+ </td>
599+ <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300">
600+ {formatRate(process.ioReadBytesPerSecond)}
601+ </td>
602+ <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300">
603+ {formatRate(process.ioWriteBytesPerSecond)}
604+ </td>
605+ <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
606+ {formatBytes(process.ioReadBytes)}
607+ </td>
608+ <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
609+ <Tooltip>
610+ <TooltipTrigger render={<span>{formatBytes(process.ioWriteBytes)}</span>} />
611+ <TooltipPopup side="top">{ioSemanticsLabel(process.ioSemantics)}</TooltipPopup>
612+ </Tooltip>
613+ </td>
614+ <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
615+ {process.identity.pid}
616+ </td>
617+ <td className="px-2 py-2 text-right sm:pr-4">
618+ <ProcessActions process={process} signalingKey={signalingKey} onSignal={onSignal} />
619+ </td>
620+ </tr>
621+ ))}
622+ </tbody>
623+ </table>
624+ </ScrollArea>
625+ );
626+}
627+
628+function HistoryProcessTable({
629+ processes,
630+}: {
631+ processes: ReadonlyArray<ResourceTelemetryProcessSummary>;
632+}) {
633+ return (
634+ <ScrollArea
635+ chainVerticalScroll
636+ scrollFade
637+ hideScrollbars
638+ className="max-h-[28rem] w-full max-w-full border-t border-border/60"
639+ >
640+ <table className="w-full min-w-[1020px] table-fixed text-left text-xs">
641+ <colgroup>
642+ <col className="w-[24%]" />
643+ <col className="w-[11%]" />
644+ <col className="w-[10%]" />
645+ <col className="w-[10%]" />
646+ <col className="w-[11%]" />
647+ <col className="w-[11%]" />
648+ <col className="w-[11%]" />
649+ <col className="w-[7%]" />
650+ <col className="w-[5%]" />
651+ </colgroup>
652+ <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65">
653+ <tr>
654+ <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
655+ <th className="px-3 py-2 font-semibold">Category</th>
656+ <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
657+ <th className="px-3 py-2 text-right font-semibold">Peak CPU</th>
658+ <th className="px-3 py-2 text-right font-semibold">Peak Mem</th>
659+ <th className="px-3 py-2 text-right font-semibold">Read</th>
660+ <th className="px-3 py-2 text-right font-semibold">Write</th>
661+ <th className="px-3 py-2 text-right font-semibold">Samples</th>
662+ <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th>
663+ </tr>
664+ </thead>
665+ <tbody className="divide-y divide-border/50">
666+ {processes.length === 0 ? (
667+ <tr>
668+ <td colSpan={9} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
669+ No retained process samples in this window.
670+ </td>
671+ </tr>
672+ ) : null}
673+ {processes.map((process) => (
674+ <tr key={processSummaryIdentityKey(process)} className="hover:bg-muted/20">
675+ <td className="px-4 py-2 sm:pl-5">
676+ <Tooltip>
677+ <TooltipTrigger
678+ render={
679+ <span className="block truncate font-medium text-foreground">
680+ {process.name || process.command}
681+ </span>
682+ }
683+ />
684+ <TooltipPopup
685+ side="top"
686+ className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]"
687+ >
688+ {process.command || process.name}
689+ </TooltipPopup>
690+ </Tooltip>
691+ </td>
692+ <td className="truncate px-3 py-2 text-[11px] text-muted-foreground">
693+ {categoryLabel(process.category)}
694+ </td>
695+ <td className="px-3 py-2 text-right font-mono tabular-nums">
696+ {formatCpuTime(process.cpuTimeMs)}
697+ </td>
698+ <td className="px-3 py-2 text-right font-mono tabular-nums">
699+ {process.maxCpuPercent.toFixed(1)}%
700+ </td>
701+ <td className="px-3 py-2 text-right font-mono tabular-nums">
702+ {formatBytes(process.peakRssBytes)}
703+ </td>
704+ <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300">
705+ {formatBytes(process.ioReadBytes)}
706+ </td>
707+ <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300">
708+ {formatBytes(process.ioWriteBytes)}
709+ </td>
710+ <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
711+ {process.sampleCount}
712+ </td>
713+ <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5">
714+ {process.identity.pid}
715+ </td>
716+ </tr>
717+ ))}
718+ </tbody>
719+ </table>
720+ </ScrollArea>
721+ );
722+}
723+
724+function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttributionEntry> }) {
725+ return (
726+ <div className="overflow-x-auto border-t border-border/60">
727+ <table className="w-full min-w-[720px] table-fixed text-left text-xs">
728+ <colgroup>
729+ <col className="w-[22%]" />
730+ <col className="w-[28%]" />
731+ <col className="w-[14%]" />
732+ <col className="w-[14%]" />
733+ <col className="w-[10%]" />
734+ <col className="w-[12%]" />
735+ </colgroup>
736+ <thead className="border-b border-border/60 text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65">
737+ <tr>
738+ <th className="px-4 py-2 font-semibold sm:pl-5">Component</th>
739+ <th className="px-3 py-2 font-semibold">Operation</th>
740+ <th className="px-3 py-2 text-right font-semibold">Logical Read</th>
741+ <th className="px-3 py-2 text-right font-semibold">Logical Write</th>
742+ <th className="px-3 py-2 text-right font-semibold">Count</th>
743+ <th className="px-3 py-2 text-right font-semibold sm:pr-5">Time</th>
744+ </tr>
745+ </thead>
746+ <tbody className="divide-y divide-border/50">
747+ {entries.length === 0 ? (
748+ <tr>
749+ <td colSpan={6} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
750+ No instrumented application I/O has been recorded yet.
751+ </td>
752+ </tr>
753+ ) : null}
754+ {entries.map((entry) => (
755+ <tr key={`${entry.component}:${entry.operation}`} className="hover:bg-muted/20">
756+ <td className="truncate px-4 py-2 font-medium text-foreground sm:pl-5">
757+ {entry.component}
758+ </td>
759+ <td className="truncate px-3 py-2 text-muted-foreground">{entry.operation}</td>
760+ <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300">
761+ {formatBytes(entry.logicalReadBytes)}
762+ </td>
763+ <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300">
764+ {formatBytes(entry.logicalWriteBytes)}
765+ </td>
766+ <td className="px-3 py-2 text-right font-mono tabular-nums">{entry.count}</td>
767+ <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5">
768+ {(entry.durationMs / 1_000).toFixed(2)}s
769+ </td>
770+ </tr>
771+ ))}
772+ </tbody>
773+ </table>
774+ </div>
775+ );
776+}
777+
778+export function ResourceTelemetryDiagnostics() {
779+ const [windowMs, setWindowMs] = useState(15 * 60_000);
780+ const selectedWindow =
781+ HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
782+ const telemetry = useResourceTelemetry();
783+ const retryTelemetry = telemetry.retry;
784+ const history = useResourceTelemetryHistory({
785+ windowMs: selectedWindow.windowMs,
786+ bucketMs: selectedWindow.bucketMs,
787+ });
788+ const [signalingKey, setSignalingKey] = useState<string | null>(null);
789+ const [isRetrying, setIsRetrying] = useState(false);
790+ const snapshot = telemetry.data;
791+ const allT3 = snapshot?.groups.allT3;
792+
793+ const signalProcess = useCallback(
794+ (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
795+ if (
796+ signal === "SIGKILL" &&
797+ !window.confirm(
798+ `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`,
799+ )
800+ ) {
801+ return;
802+ }
803+ const identityKey = processIdentityKey(process);
804+ setSignalingKey(identityKey);
805+ void ensureLocalApi()
806+ .server.signalProcess({
807+ pid: process.identity.pid,
808+ startTimeMs: process.identity.startTimeMs,
809+ signal,
810+ })
811+ .then((result) => {
812+ if (result.signaled) return;
813+ toastManager.add({
814+ type: "error",
815+ title: `Could not send ${signal}`,
816+ description: Option.getOrElse(
817+ result.message,
818+ () => `Failed to send ${signal} to process ${process.identity.pid}.`,
819+ ),
820+ });
821+ })
822+ .catch((error: unknown) => {
823+ toastManager.add({
824+ type: "error",
825+ title: `Could not send ${signal}`,
826+ description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
827+ });
828+ })
829+ .finally(() => {
830+ setSignalingKey((current) => (current === identityKey ? null : current));
831+ });
832+ },
833+ [],
834+ );
835+
836+ const retryCollector = useCallback(() => {
837+ setIsRetrying(true);
838+ void retryTelemetry()
839+ .catch((error: unknown) => {
840+ toastManager.add({
841+ type: "error",
842+ title: "Could not restart resource monitor",
843+ description:
844+ error instanceof Error ? error.message : "The resource monitor retry failed.",
845+ });
846+ })
847+ .finally(() => {
848+ setIsRetrying(false);
849+ });
850+ }, [retryTelemetry]);
851+
852+ const speedLimit = snapshot ? Option.getOrNull(snapshot.speedLimitPercent) : null;
853+ const collectorNeedsRetry =
854+ snapshot?.health.native.status === "degraded" ||
855+ snapshot?.health.native.status === "unavailable" ||
856+ snapshot?.health.native.status === "stopped";
857+
858+ return (
859+ <>
860+ <SettingsSection
861+ title="Resource Overview"
862+ headerAction={
863+ <div className="flex items-center gap-2">
864+ {snapshot ? (
865+ <SourceStatusBadge label="Native" status={snapshot.health.native.status} />
866+ ) : null}
867+ <LastSampleLabel sampledAt={snapshot?.readAt ?? null} />
868+ <Tooltip>
869+ <TooltipTrigger
870+ render={
871+ <Button
872+ size="icon-xs"
873+ variant="ghost"
874+ className="size-5 rounded-sm p-0"
875+ disabled={telemetry.isPending}
876+ onClick={telemetry.refresh}
877+ aria-label="Refresh resource telemetry"
878+ >
879+ <RefreshCwIcon
880+ className={cn("size-3", telemetry.isPending && "animate-spin")}
881+ />
882+ </Button>
883+ }
884+ />
885+ <TooltipPopup side="top">Refresh telemetry snapshot</TooltipPopup>
886+ </Tooltip>
887+ </div>
888+ }
889+ >
890+ <div className="grid grid-cols-2 divide-x divide-y divide-border/60 md:grid-cols-3">
891+ <IconStat
892+ icon={<CpuIcon className="size-3.5" />}
893+ label="Current CPU"
894+ value={allT3 ? `${allT3.currentCpuPercent.toFixed(1)}%` : "..."}
895+ detail={allT3 ? `${formatCpuTime(allT3.cpuTimeMs)} observed CPU time` : undefined}
896+ />
897+ <IconStat
898+ icon={<MemoryStickIcon className="size-3.5" />}
899+ label="Resident Memory"
900+ value={allT3 ? formatBytes(allT3.currentRssBytes) : "..."}
901+ detail={allT3 ? `${formatBytes(allT3.peakRssBytes)} process peaks` : undefined}
902+ />
903+ <IconStat
904+ icon={<HardDriveIcon className="size-3.5" />}
905+ label="I/O Reads"
906+ value={allT3 ? formatRate(allT3.ioReadBytesPerSecond) : "..."}
907+ detail={allT3 ? `${formatBytes(allT3.ioReadBytes)} observed` : undefined}
908+ />
909+ <IconStat
910+ icon={<DatabaseIcon className="size-3.5" />}
911+ label="I/O Writes"
912+ value={allT3 ? formatRate(allT3.ioWriteBytesPerSecond) : "..."}
913+ detail={allT3 ? `${formatBytes(allT3.ioWriteBytes)} observed` : undefined}
914+ tone={
915+ allT3 && allT3.ioWriteBytesPerSecond >= 10 * 1_024 * 1_024
916+ ? "danger"
917+ : allT3 && allT3.ioWriteBytesPerSecond >= 1_024 * 1_024
918+ ? "warning"
919+ : "default"
920+ }
921+ />
922+ <IconStat
923+ icon={<ActivityIcon className="size-3.5" />}
924+ label="Processes"
925+ value={allT3 ? String(allT3.processCount) : "..."}
926+ detail={
927+ allT3 ? `${allT3.processStarts} starts · ${allT3.processExits} exits` : undefined
928+ }
929+ />
930+ <IconStat
931+ icon={<GaugeIcon className="size-3.5" />}
932+ label="CPU Speed Limit"
933+ value={
934+ snapshot ? (speedLimit === null ? "Unknown" : `${speedLimit.toFixed(0)}%`) : "..."
935+ }
936+ detail={snapshot ? `${snapshot.power.thermalState} thermal state` : undefined}
937+ tone={speedLimit !== null && speedLimit < 80 ? "warning" : "default"}
938+ />
939+ </div>
940+ {telemetry.error ? (
941+ <div className="flex items-start gap-2 border-t border-border/60 px-4 py-3 text-xs text-destructive sm:px-5">
942+ <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
943+ <span>{telemetry.error}</span>
944+ </div>
945+ ) : null}
946+ {snapshot ? (
947+ <div className="grid md:grid-cols-3">
948+ <AggregateCard
949+ label="Backend + Agents"
950+ accentClass="bg-emerald-500/80"
951+ aggregate={snapshot.groups.backend}
952+ />
953+ <AggregateCard
954+ label="Electron"
955+ accentClass="bg-sky-500/80"
956+ aggregate={snapshot.groups.electron}
957+ />
958+ <AggregateCard
959+ label="Monitor Overhead"
960+ accentClass="bg-amber-500/80"
961+ aggregate={snapshot.groups.monitor}
962+ />
963+ </div>
964+ ) : null}
965+ </SettingsSection>
966+
967+ <SettingsSection
968+ title="Power & Collector"
969+ headerAction={
970+ collectorNeedsRetry ? (
971+ <Button size="xs" variant="outline" disabled={isRetrying} onClick={retryCollector}>
972+ <RotateCcwIcon className={cn("size-3", isRetrying && "animate-spin")} />
973+ Retry monitor
974+ </Button>
975+ ) : null
976+ }
977+ >
978+ <div className="grid md:grid-cols-2 md:divide-x md:divide-border/60">
979+ <div className="px-4 py-3.5 sm:px-5">
980+ <div className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/60">
981+ <BatteryIcon className="size-3.5" /> Host state
982+ </div>
983+ <DetailRow
984+ label="Power source"
985+ value={
986+ snapshot
987+ ? booleanStateLabel(snapshot.power.onBattery, {
988+ true: "Battery",
989+ false: "External power",
990+ })
991+ : "Unknown"
992+ }
993+ />
994+ <DetailRow
995+ label="Low power mode"
996+ value={
997+ snapshot
998+ ? booleanStateLabel(snapshot.power.lowPowerMode, {
999+ true: "Enabled",
1000+ false: "Disabled",
1001+ })
1002+ : "Unknown"
1003+ }
1004+ />
1005+ <DetailRow
1006+ label="Idle"
1007+ value={
1008+ snapshot
1009+ ? `${booleanStateLabel(snapshot.power.idle, {
1010+ true: "Idle",
1011+ false: "Active",
1012+ })}${
1013+ snapshot.power.idleSeconds === null
1014+ ? ""
1015+ : ` · ${Math.round(snapshot.power.idleSeconds)}s`
1016+ }`
1017+ : "Unknown"
1018+ }
1019+ />
1020+ <DetailRow
1021+ label="Session"
1022+ value={
1023+ snapshot
1024+ ? snapshot.power.suspended
1025+ ? "Suspended"
1026+ : booleanStateLabel(snapshot.power.locked, {
1027+ true: "Locked",
1028+ false: "Unlocked",
1029+ })
1030+ : "Unknown"
1031+ }
1032+ />
1033+ <DetailRow
1034+ label="Thermal"
1035+ value={snapshot?.power.thermalState ?? "unknown"}
1036+ valueClassName={
1037+ snapshot?.power.thermalState === "serious" ||
1038+ snapshot?.power.thermalState === "critical"
1039+ ? "text-destructive"
1040+ : undefined
1041+ }
1042+ />
1043+ </div>
1044+ <div className="border-t border-border/60 px-4 py-3.5 md:border-t-0 sm:px-5">
1045+ <div className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/60">
1046+ <GaugeIcon className="size-3.5" /> Collection health
1047+ </div>
1048+ {snapshot ? (
1049+ <>
1050+ <HealthSource label="Native process monitor" health={snapshot.health.native} />
1051+ <HealthSource label="Electron main process" health={snapshot.health.desktop} />
1052+ <DetailRow
1053+ label="Collection time"
1054+ value={formatDurationMicros(snapshot.health.collectionDurationMicros)}
1055+ />
1056+ <DetailRow
1057+ label="Process scan"
1058+ value={`${snapshot.health.retainedProcessCount}/${snapshot.health.scannedProcessCount} retained`}
1059+ />
1060+ <DetailRow
1061+ label="Inaccessible"
1062+ value={String(snapshot.health.inaccessibleProcessCount)}
1063+ valueClassName={
1064+ snapshot.health.inaccessibleProcessCount > 0
1065+ ? "text-amber-600 dark:text-amber-300"
1066+ : undefined
1067+ }
1068+ />
1069+ <DetailRow
1070+ label="Sidecar"
1071+ value={Option.match(snapshot.health.sidecarVersion, {
1072+ onNone: () => "Unavailable",
1073+ onSome: (version) =>
1074+ `${version}${Option.match(snapshot.health.sidecarPid, {
1075+ onNone: () => "",
1076+ onSome: (pid) => ` · PID ${pid}`,
1077+ })}`,
1078+ })}
1079+ />
1080+ <DetailRow label="Restarts" value={String(snapshot.health.restartCount)} />
1081+ </>
1082+ ) : (
1083+ <div className="py-4 text-xs text-muted-foreground">
1084+ Waiting for collector health.
1085+ </div>
1086+ )}
1087+ </div>
1088+ </div>
1089+ </SettingsSection>
1090+
1091+ <SettingsSection
1092+ title="Resource Timeline"
1093+ headerAction={
1094+ <div className="flex items-center gap-2">
1095+ <HistoryWindowSelector selectedWindowMs={windowMs} onSelect={setWindowMs} />
1096+ <Button
1097+ size="icon-xs"
1098+ variant="ghost"
1099+ className="size-5 rounded-sm p-0"
1100+ disabled={history.isPending}
1101+ onClick={history.refresh}
1102+ aria-label="Refresh resource history"
1103+ >
1104+ <RefreshCwIcon className={cn("size-3", history.isPending && "animate-spin")} />
1105+ </Button>
1106+ </div>
1107+ }
1108+ >
1109+ {history.error ? (
1110+ <div className="flex items-start gap-2 px-4 py-3 text-xs text-destructive sm:px-5">
1111+ <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
1112+ <span>{history.error}</span>
1113+ </div>
1114+ ) : null}
1115+ <ResourceHistoryChart buckets={history.data?.buckets ?? []} />
1116+ <HistoryProcessTable processes={history.data?.topProcesses ?? []} />
1117+ </SettingsSection>
1118+
1119+ <SettingsSection
1120+ title="Live Process Tree"
1121+ headerAction={
1122+ snapshot ? (
1123+ <span className="text-[10px] text-muted-foreground/55">
1124+ Identity: <span className="font-mono">PID + start time</span>
1125+ </span>
1126+ ) : null
1127+ }
1128+ >
1129+ <ProcessTable
1130+ processes={snapshot?.processes ?? []}
1131+ signalingKey={signalingKey}
1132+ onSignal={signalProcess}
1133+ />
1134+ </SettingsSection>
1135+
1136+ <SettingsSection
1137+ title="Instrumented Application I/O"
1138+ headerAction={
1139+ <span className="text-[10px] text-muted-foreground/55">Logical bytes by operation</span>
1140+ }
1141+ >
1142+ <div className="px-4 py-3 text-[11px] leading-relaxed text-muted-foreground sm:px-5">
1143+ Native counters identify which process is reading or writing. These application-level
1144+ counters identify known T3 operations so process spikes can be correlated with specific
1145+ persistence and logging paths.
1146+ </div>
1147+ <AttributionTable entries={snapshot?.attribution.entries ?? []} />
1148+ </SettingsSection>
1149+ </>
1150+ );
1151+}
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+4−0
@@ -172,11 +172,15 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { · 171 unmodified lines
172172 getTraceDiagnostics: vi.fn(),
173173 getProcessDiagnostics: vi.fn(),
174174 getProcessResourceHistory: vi.fn(),
175+ getResourceTelemetry: vi.fn(),
176+ getResourceTelemetryHistory: vi.fn(),
177+ retryResourceTelemetry: vi.fn(),
175178 signalProcess: vi.fn(),
176179 reportClientActivity: vi.fn(),
177180 reportHostPowerState: vi.fn(),
178181 getBackgroundPolicy: vi.fn(),
179182 subscribeBackgroundPolicy: vi.fn(() => () => undefined),
183+ subscribeResourceTelemetry: vi.fn(() => () => undefined),
180184 },
181185 };
182186 return {
apps/web/src/lib/resourceTelemetryState.ts+228−0
@@ -0,0 +1,228 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import type {
3+ ResourceTelemetryHistory,
4+ ResourceTelemetryHistoryInput,
5+ ResourceTelemetrySnapshot,
6+} from "@t3tools/contracts";
7+import * as Cause from "effect/Cause";
8+import * as Effect from "effect/Effect";
9+import * as Option from "effect/Option";
10+import { AsyncResult, Atom } from "effect/unstable/reactivity";
11+import { useCallback, useEffect } from "react";
12+
13+import {
14+ getPrimaryEnvironmentConnection,
15+ subscribeEnvironmentConnections,
16+} from "../environments/runtime";
17+import { ensureLocalApi } from "../localApi";
18+import { appAtomRegistry } from "../rpc/atomRegistry";
19+
20+const RESOURCE_TELEMETRY_HISTORY_STALE_TIME_MS = 5_000;
21+const RESOURCE_TELEMETRY_IDLE_TTL_MS = 5 * 60_000;
22+const RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR = ":";
23+
24+interface ResourceTelemetryLiveState {
25+ readonly data: ResourceTelemetrySnapshot | null;
26+ readonly error: string | null;
27+ readonly isPending: boolean;
28+}
29+
30+const resourceTelemetryLiveAtom = Atom.make<ResourceTelemetryLiveState>({
31+ data: null,
32+ error: null,
33+ isPending: true,
34+}).pipe(Atom.keepAlive, Atom.withLabel("resource-telemetry-live"));
35+
36+function formatResourceTelemetryError(error: unknown): string {
37+ return error instanceof Error ? error.message : "Failed to load resource telemetry.";
38+}
39+
40+function updateLiveState(
41+ update: (current: ResourceTelemetryLiveState) => ResourceTelemetryLiveState,
42+): void {
43+ appAtomRegistry.set(
44+ resourceTelemetryLiveAtom,
45+ update(appAtomRegistry.get(resourceTelemetryLiveAtom)),
46+ );
47+}
48+
49+let retainCount = 0;
50+let requestGeneration = 0;
51+let activeClient: object | null = null;
52+let unsubscribeTelemetry: () => void = () => undefined;
53+let unsubscribeConnections: () => void = () => undefined;
54+
55+function applySnapshot(snapshot: ResourceTelemetrySnapshot): void {
56+ updateLiveState(() => ({
57+ data: snapshot,
58+ error: null,
59+ isPending: false,
60+ }));
61+}
62+
63+function refreshFromCurrentClient(): void {
64+ const generation = ++requestGeneration;
65+ updateLiveState((current) => ({
66+ ...current,
67+ error: null,
68+ isPending: true,
69+ }));
70+
71+ let server: ReturnType<typeof getPrimaryEnvironmentConnection>["client"]["server"];
72+ try {
73+ server = getPrimaryEnvironmentConnection().client.server;
74+ } catch (error) {
75+ if (generation !== requestGeneration) return;
76+ updateLiveState((current) => ({
77+ ...current,
78+ error: formatResourceTelemetryError(error),
79+ isPending: false,
80+ }));
81+ return;
82+ }
83+
84+ void server.getResourceTelemetry().then(
85+ (snapshot) => {
86+ if (generation !== requestGeneration) return;
87+ applySnapshot(snapshot);
88+ },
89+ (error: unknown) => {
90+ if (generation !== requestGeneration) return;
91+ updateLiveState((current) => ({
92+ ...current,
93+ error: formatResourceTelemetryError(error),
94+ isPending: false,
95+ }));
96+ },
97+ );
98+}
99+
100+function attachResourceTelemetry(): void {
101+ let client: ReturnType<typeof getPrimaryEnvironmentConnection>["client"];
102+ try {
103+ client = getPrimaryEnvironmentConnection().client;
104+ } catch (error) {
105+ activeClient = null;
106+ unsubscribeTelemetry();
107+ unsubscribeTelemetry = () => undefined;
108+ updateLiveState((current) => ({
109+ ...current,
110+ error: formatResourceTelemetryError(error),
111+ isPending: false,
112+ }));
113+ return;
114+ }
115+
116+ if (client === activeClient) {
117+ return;
118+ }
119+
120+ activeClient = client;
121+ unsubscribeTelemetry();
122+ refreshFromCurrentClient();
123+ unsubscribeTelemetry = client.server.subscribeResourceTelemetry(applySnapshot, {
124+ onResubscribe: refreshFromCurrentClient,
125+ });
126+}
127+
128+function retainResourceTelemetry(): () => void {
129+ retainCount += 1;
130+ if (retainCount === 1) {
131+ unsubscribeConnections = subscribeEnvironmentConnections(() => {
132+ activeClient = null;
133+ attachResourceTelemetry();
134+ });
135+ attachResourceTelemetry();
136+ }
137+
138+ return () => {
139+ retainCount = Math.max(0, retainCount - 1);
140+ if (retainCount !== 0) return;
141+ requestGeneration += 1;
142+ activeClient = null;
143+ unsubscribeTelemetry();
144+ unsubscribeTelemetry = () => undefined;
145+ unsubscribeConnections();
146+ unsubscribeConnections = () => undefined;
147+ };
148+}
149+
150+function formatHistoryKey(input: ResourceTelemetryHistoryInput): string {
151+ return `${input.windowMs}${RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`;
152+}
153+
154+function parseHistoryKey(key: string): ResourceTelemetryHistoryInput {
155+ const [windowMs = "0", bucketMs = "0"] = key.split(RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR);
156+ return {
157+ windowMs: Number(windowMs),
158+ bucketMs: Number(bucketMs),
159+ };
160+}
161+
162+const resourceTelemetryHistoryAtom = Atom.family((key: string) => {
163+ const input = parseHistoryKey(key);
164+ return Atom.make(
165+ Effect.promise(() => ensureLocalApi().server.getResourceTelemetryHistory(input)),
166+ ).pipe(
167+ Atom.swr({
168+ staleTime: RESOURCE_TELEMETRY_HISTORY_STALE_TIME_MS,
169+ revalidateOnMount: true,
170+ }),
171+ Atom.setIdleTTL(RESOURCE_TELEMETRY_IDLE_TTL_MS),
172+ Atom.withLabel(`resource-telemetry-history:${key}`),
173+ );
174+});
175+
176+export interface ResourceTelemetryState extends ResourceTelemetryLiveState {
177+ readonly refresh: () => void;
178+ readonly retry: () => Promise<ResourceTelemetrySnapshot>;
179+}
180+
181+export interface ResourceTelemetryHistoryState {
182+ readonly data: ResourceTelemetryHistory | null;
183+ readonly error: string | null;
184+ readonly isPending: boolean;
185+ readonly refresh: () => void;
186+}
187+
188+export function refreshResourceTelemetry(): void {
189+ refreshFromCurrentClient();
190+}
191+
192+export async function retryResourceTelemetry(): Promise<ResourceTelemetrySnapshot> {
193+ const result = await ensureLocalApi().server.retryResourceTelemetry();
194+ applySnapshot(result.snapshot);
195+ return result.snapshot;
196+}
197+
198+export function useResourceTelemetry(): ResourceTelemetryState {
199+ useEffect(retainResourceTelemetry, []);
200+ const state = useAtomValue(resourceTelemetryLiveAtom);
201+ const refresh = useCallback(refreshResourceTelemetry, []);
202+ const retry = useCallback(() => retryResourceTelemetry(), []);
203+
204+ return {
205+ ...state,
206+ refresh,
207+ retry,
208+ };
209+}
210+
211+export function useResourceTelemetryHistory(
212+ input: ResourceTelemetryHistoryInput,
213+): ResourceTelemetryHistoryState {
214+ const atom = resourceTelemetryHistoryAtom(formatHistoryKey(input));
215+ const result = useAtomValue(atom);
216+ const data = Option.getOrNull(AsyncResult.value(result));
217+ const refresh = useCallback(() => {
218+ appAtomRegistry.refresh(atom);
219+ }, [atom]);
220+
221+ return {
222+ data,
223+ error:
224+ result._tag === "Failure" ? formatResourceTelemetryError(Cause.squash(result.cause)) : null,
225+ isPending: result.waiting,
226+ refresh,
227+ };
228+}
apps/web/src/localApi.ts+12−0
@@ -159,6 +159,18 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { · 158 unmodified lines
159159 rpcClient
160160 ? rpcClient.server.getProcessResourceHistory(input)
161161 : Promise.reject(unavailableLocalBackendError()),
162+ getResourceTelemetry: () =>
163+ rpcClient
164+ ? rpcClient.server.getResourceTelemetry()
165+ : Promise.reject(unavailableLocalBackendError()),
166+ getResourceTelemetryHistory: (input) =>
167+ rpcClient
168+ ? rpcClient.server.getResourceTelemetryHistory(input)
169+ : Promise.reject(unavailableLocalBackendError()),
170+ retryResourceTelemetry: () =>
171+ rpcClient
172+ ? rpcClient.server.retryResourceTelemetry()
173+ : Promise.reject(unavailableLocalBackendError()),
162174 signalProcess: (input) =>
163175 rpcClient
164176 ? rpcClient.server.signalProcess(input)
docs/architecture/overview.md+4−0
@@ -37,6 +37,10 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS · 36 unmodified lines
3737
3838 - **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state.
3939
40+Related design:
41+
42+- [Resource telemetry architecture](./resource-telemetry.md)
43+
4044 ## Event Lifecycle
4145
4246 ### Startup and client connect
docs/architecture/resource-telemetry.md+335−0
@@ -0,0 +1,335 @@
1+# Resource telemetry architecture
2+
3+Status: implemented
4+
5+## Purpose
6+
7+Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset`
8+subprocess probes with two persistent, direct data sources:
9+
10+1. a standalone Rust resource-monitor executable that reads process counters
11+ through operating-system APIs via `sysinfo`;
12+2. Electron main-process APIs for Electron process metrics and host power state.
13+
14+The server merges both sources, computes rates from cumulative counters, keeps
15+bounded in-memory history, exposes typed RPCs, and drives the diagnostics page.
16+Telemetry history is not persisted to disk.
17+
18+## Why a standalone executable
19+
20+The monitor is intentionally not a Node native addon.
21+
22+- No N-API, `ffi-rs`, or dynamic-library ABI is loaded into the server process.
23+- A monitor crash cannot corrupt the Node runtime.
24+- The server can supervise, restart, version-check, and measure the monitor as a
25+ normal child process.
26+- The same protocol works for the desktop app and the published CLI.
27+- Packaging is a single platform executable instead of an addon toolchain plus
28+ Node/Electron ABI matrix.
29+
30+The cost is one persistent child process and NDJSON serialization. That is a
31+better failure boundary than repeatedly spawning shell utilities or loading
32+native code into Node.
33+
34+## Runtime topology
35+
36+### Desktop
37+
38+```text
39+Electron main
40+ ├─ powerMonitor
41+ ├─ app.getAppMetrics()
42+ └─ inherited fd 4, NDJSON
43+ │
44+ ▼
45+Node server ── stdin/stdout NDJSON ── Rust resource monitor
46+ │
47+ ├─ ResourceTelemetry Effect service
48+ ├─ bounded in-memory history
49+ ├─ background power policy projection
50+ └─ WebSocket RPC/subscription ── diagnostics UI
51+```
52+
53+### Web, headless, and remote server
54+
55+Electron telemetry is unavailable. The native monitor still runs beside the
56+server and tracks the server process tree. Power fields degrade to `unknown`
57+instead of invoking platform shell commands.
58+
59+## Native monitor
60+
61+The executable lives in `native/resource-monitor`.
62+
63+It receives schema-compatible commands on stdin and emits one JSON object per
64+line on stdout:
65+
66+- `configure`
67+- `setExternalProcesses`
68+- `sampleNow`
69+- `shutdown`
70+- `hello`
71+- `snapshot`
72+- `error`
73+
74+The protocol version is defined by
75+`RESOURCE_MONITOR_PROTOCOL_VERSION` in
76+`packages/contracts/src/resourceTelemetry.ts`.
77+
78+### Collection
79+
80+The monitor keeps one `sysinfo::System` instance and refreshes it on a one-second
81+interval. It collects:
82+
83+- PID and parent PID;
84+- process start time and run time;
85+- process name and command line;
86+- current and cumulative CPU usage;
87+- resident and virtual memory;
88+- cumulative process I/O counters.
89+
90+On Linux, task/thread enumeration is disabled. Command lines are loaded only
91+when first needed. This avoids the expensive default behavior of walking every
92+`/proc/<pid>/task/<tid>` directory on each refresh.
93+
94+### Process-tree selection
95+
96+Each sample scans the accessible process table, builds the PID/PPID graph, and
97+retains:
98+
99+- the server process;
100+- every descendant of the server, including provider-spawned grandchildren such
101+ as shells, `node`, `tsgo`, language servers, and other tools;
102+- Electron processes supplied as explicit external roots;
103+- descendants of those Electron roots;
104+- the resource monitor itself, because it is a server child.
105+
106+Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native
107+start times are matched with a two-second tolerance because native start times
108+can have coarser platform resolution.
109+
110+The process list is emitted in depth-first tree order so renderer collapse and
111+expansion preserves complete subtrees.
112+
113+### Sampling limits
114+
115+This is counter sampling, not syscall tracing.
116+
117+- A process that starts and exits entirely between samples may not be observed.
118+- Cumulative CPU and I/O counters still provide accurate deltas for processes
119+ that survive across samples.
120+- Exact file paths, individual write syscalls, ETW events, eBPF events, and
121+ Endpoint Security events are outside this implementation.
122+
123+Those deeper tracing systems can be added later as opt-in diagnostic modes
124+without changing the public `ResourceTelemetry` model.
125+
126+## I/O semantics
127+
128+The monitor preserves platform semantics instead of presenting all counters as
129+equivalent:
130+
131+- Unix-like platforms report storage I/O counters exposed by `sysinfo`.
132+- Windows reports all process I/O bytes, not only disk bytes.
133+- Operating-system caches can prevent logical application reads or writes from
134+ appearing as physical storage bytes.
135+
136+The UI therefore labels these values as I/O reads and writes and exposes the
137+per-process `ioSemantics` value.
138+
139+Group totals are observed deltas since telemetry startup. Per-process total
140+columns are the operating system's cumulative counters for that process.
141+
142+## Electron telemetry
143+
144+Electron main owns `DesktopTelemetryPublisher`.
145+
146+It samples once per second from:
147+
148+- `app.getAppMetrics()`;
149+- `powerMonitor.isOnBatteryPower()`;
150+- `powerMonitor.getSystemIdleTime()`;
151+- `powerMonitor.getSystemIdleState()`;
152+- `powerMonitor.getCurrentThermalState()`.
153+
154+It also listens for:
155+
156+- lock and unlock;
157+- suspend and resume;
158+- AC and battery transitions;
159+- thermal-state changes;
160+- CPU speed-limit changes.
161+
162+Electron does not expose a cross-platform low-power-mode getter, so that field
163+remains `unknown`.
164+
165+The desktop backend is spawned with:
166+
167+- fd 3 for the existing bootstrap payload;
168+- fd 4 for desktop telemetry NDJSON.
169+
170+This is a private Electron-main-to-server pipe. It does not use the renderer
171+WebSocket and is recreated for every backend restart.
172+
173+## Server Effect services
174+
175+The implementation is under `apps/server/src/resourceTelemetry`.
176+
177+### `ResourceMonitorBinary`
178+
179+Resolves an executable from:
180+
181+1. `T3CODE_RESOURCE_MONITOR_PATH`;
182+2. desktop bootstrap configuration;
183+3. bundled CLI resources;
184+4. local Cargo build outputs.
185+
186+Unsupported platforms, missing binaries, and non-executable binaries use
187+schema-backed tagged errors with descriptive messages.
188+
189+### `NativeTelemetryClient`
190+
191+Owns the resource-monitor process and protocol.
192+
193+- validates the hello/version handshake;
194+- sends configuration and external process roots;
195+- exposes automatic snapshots and `sampleNow`;
196+- serializes commands;
197+- supervises process exit and protocol failure;
198+- restarts with bounded exponential backoff;
199+- opens a circuit after repeated failures;
200+- supports explicit retry;
201+- publishes health changes immediately.
202+
203+Snapshot sequence numbers are scoped to a monitor generation. Server ingestion
204+uses the monitor restart count as the generation key, so sequence reset after a
205+restart cannot freeze telemetry.
206+
207+### `DesktopTelemetryReceiver`
208+
209+Reads fd 4, decodes schema-validated messages, stores the latest Electron
210+snapshot, and publishes desktop health. Decode errors, protocol mismatch,
211+stream failure, and normal stream closure are represented explicitly.
212+
213+### `ResourceTelemetry`
214+
215+Merges native and Electron data and owns public telemetry semantics.
216+
217+- calculates CPU and I/O rates from cumulative native counters;
218+- preserves the last native rates during desktop-only updates;
219+- classifies backend, Electron, and monitor processes;
220+- computes process depth and child relationships;
221+- tracks starts, exits, CPU time, and observed I/O;
222+- projects power data;
223+- publishes live snapshots;
224+- validates `(pid, startTimeMs)` before process signaling;
225+- updates history health even when no further native sample arrives.
226+
227+Electron and monitor processes are visible but are not valid targets for the
228+existing process-signal RPC.
229+
230+### `ResourceTelemetryStore`
231+
232+Keeps aggregate and process samples in memory for at most one hour, subject to
233+hard sample-count bounds. Aggregate history retains up to 3,600 samples.
234+Detailed process history retains up to 20,000 process samples, so high process
235+fan-out can shorten detailed per-process coverage while aggregate coverage
236+remains available.
237+
238+### `ResourceAttribution`
239+
240+Tracks known logical application I/O separately from OS counters. Current
241+integration points record successful writes for:
242+
243+- provider native and canonical event logs;
244+- the local server trace sink.
245+
246+Entries contain component, operation, logical bytes, count, and elapsed time.
247+Future persistence paths should call `ResourceAttribution.record` rather than
248+adding diagnostics-specific counters.
249+
250+## Background policy integration
251+
252+`HostPowerMonitor` now projects power state from `ResourceTelemetry`; it does not
253+spawn macOS shell probes.
254+
255+The monitor updates its latest timestamp on every Electron sample but only
256+publishes semantic state changes. Increasing idle seconds alone does not cause a
257+background-policy broadcast every second.
258+
259+## Public API and UI
260+
261+The WebSocket RPC surface provides:
262+
263+- current snapshot;
264+- bounded history;
265+- explicit monitor retry;
266+- a live snapshot subscription.
267+
268+The diagnostics page displays:
269+
270+- aggregate CPU, memory, I/O, and process counts;
271+- backend, Electron, and monitor overhead groups;
272+- power and thermal state;
273+- collector health and restart information;
274+- CPU and I/O history;
275+- a collapsible live process tree;
276+- safe process signaling for backend descendants;
277+- instrumented logical application I/O.
278+
279+Legacy process diagnostics RPCs are projected from the same service so they no
280+longer start recurring process-table commands.
281+
282+## Packaging
283+
284+Desktop artifact builds compile the Rust target, stage it as
285+`resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the
286+backend bootstrap.
287+
288+CLI release jobs upload each active platform monitor artifact and copy it into:
289+
290+```text
291+apps/server/dist/resource-monitor/<platform>-<arch>/
292+```
293+
294+The published server package already includes `dist`, so those executables ship
295+with the CLI. Missing platform artifacts degrade native telemetry to
296+`unavailable`; the server continues running.
297+
298+## Resource and failure behavior
299+
300+Steady state uses:
301+
302+- one native process;
303+- one native sample per second;
304+- one Electron sample per second in desktop mode;
305+- no telemetry database;
306+- no recurring shell probes;
307+- bounded PubSub queues and bounded history.
308+
309+The diagnostics page exposes the monitor's own process resource usage and
310+collection duration so the observer's cost is measurable.
311+
312+Failures are isolated:
313+
314+- native failure does not stop the server;
315+- Electron telemetry loss does not stop native telemetry;
316+- schema/version errors are visible in health;
317+- repeated native failures stop automatic restart churn until explicit retry;
318+- server and desktop shutdown close their respective streams and child process
319+ scopes.
320+
321+## Future integration points
322+
323+High-value follow-up work can use the existing service boundaries:
324+
325+- opt-in file-path attribution through platform-specific tracing;
326+- process lifecycle events to reduce the chance of missing very short-lived
327+ children;
328+- additional `ResourceAttribution` instrumentation for databases, checkpoints,
329+ caches, and file synchronization;
330+- exported diagnostic bundles;
331+- adaptive sample intervals based on diagnostics visibility and active work.
332+
333+These additions should preserve the current rules: direct platform APIs,
334+schema-validated boundaries, explicit metric semantics, bounded retention, and
335+no mandatory telemetry persistence.
native/resource-monitor/Cargo.lock+343−0
@@ -0,0 +1,343 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "bitflags"
7+version = "2.13.0"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
10+
11+[[package]]
12+name = "dispatch2"
13+version = "0.3.1"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
16+dependencies = [
17+ "bitflags",
18+ "objc2",
19+]
20+
21+[[package]]
22+name = "itoa"
23+version = "1.0.18"
24+source = "registry+https://github.com/rust-lang/crates.io-index"
25+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
26+
27+[[package]]
28+name = "libc"
29+version = "0.2.186"
30+source = "registry+https://github.com/rust-lang/crates.io-index"
31+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
32+
33+[[package]]
34+name = "memchr"
35+version = "2.8.2"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
38+
39+[[package]]
40+name = "ntapi"
41+version = "0.4.3"
42+source = "registry+https://github.com/rust-lang/crates.io-index"
43+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
44+dependencies = [
45+ "winapi",
46+]
47+
48+[[package]]
49+name = "objc2"
50+version = "0.6.4"
51+source = "registry+https://github.com/rust-lang/crates.io-index"
52+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
53+dependencies = [
54+ "objc2-encode",
55+]
56+
57+[[package]]
58+name = "objc2-core-foundation"
59+version = "0.3.2"
60+source = "registry+https://github.com/rust-lang/crates.io-index"
61+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
62+dependencies = [
63+ "bitflags",
64+ "dispatch2",
65+ "objc2",
66+]
67+
68+[[package]]
69+name = "objc2-encode"
70+version = "4.1.0"
71+source = "registry+https://github.com/rust-lang/crates.io-index"
72+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
73+
74+[[package]]
75+name = "objc2-foundation"
76+version = "0.3.2"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
79+dependencies = [
80+ "bitflags",
81+ "objc2",
82+]
83+
84+[[package]]
85+name = "objc2-io-kit"
86+version = "0.3.2"
87+source = "registry+https://github.com/rust-lang/crates.io-index"
88+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
89+dependencies = [
90+ "libc",
91+ "objc2-core-foundation",
92+]
93+
94+[[package]]
95+name = "objc2-open-directory"
96+version = "0.3.2"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
99+dependencies = [
100+ "objc2",
101+ "objc2-core-foundation",
102+ "objc2-foundation",
103+]
104+
105+[[package]]
106+name = "proc-macro2"
107+version = "1.0.106"
108+source = "registry+https://github.com/rust-lang/crates.io-index"
109+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
110+dependencies = [
111+ "unicode-ident",
112+]
113+
114+[[package]]
115+name = "quote"
116+version = "1.0.45"
117+source = "registry+https://github.com/rust-lang/crates.io-index"
118+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
119+dependencies = [
120+ "proc-macro2",
121+]
122+
123+[[package]]
124+name = "serde"
125+version = "1.0.228"
126+source = "registry+https://github.com/rust-lang/crates.io-index"
127+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
128+dependencies = [
129+ "serde_core",
130+ "serde_derive",
131+]
132+
133+[[package]]
134+name = "serde_core"
135+version = "1.0.228"
136+source = "registry+https://github.com/rust-lang/crates.io-index"
137+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
138+dependencies = [
139+ "serde_derive",
140+]
141+
142+[[package]]
143+name = "serde_derive"
144+version = "1.0.228"
145+source = "registry+https://github.com/rust-lang/crates.io-index"
146+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
147+dependencies = [
148+ "proc-macro2",
149+ "quote",
150+ "syn",
151+]
152+
153+[[package]]
154+name = "serde_json"
155+version = "1.0.150"
156+source = "registry+https://github.com/rust-lang/crates.io-index"
157+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
158+dependencies = [
159+ "itoa",
160+ "memchr",
161+ "serde",
162+ "serde_core",
163+ "zmij",
164+]
165+
166+[[package]]
167+name = "syn"
168+version = "2.0.118"
169+source = "registry+https://github.com/rust-lang/crates.io-index"
170+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
171+dependencies = [
172+ "proc-macro2",
173+ "quote",
174+ "unicode-ident",
175+]
176+
177+[[package]]
178+name = "sysinfo"
179+version = "0.39.3"
180+source = "registry+https://github.com/rust-lang/crates.io-index"
181+checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
182+dependencies = [
183+ "libc",
184+ "memchr",
185+ "ntapi",
186+ "objc2-core-foundation",
187+ "objc2-io-kit",
188+ "objc2-open-directory",
189+ "windows",
190+]
191+
192+[[package]]
193+name = "t3-resource-monitor"
194+version = "0.1.0"
195+dependencies = [
196+ "serde",
197+ "serde_json",
198+ "sysinfo",
199+]
200+
201+[[package]]
202+name = "unicode-ident"
203+version = "1.0.24"
204+source = "registry+https://github.com/rust-lang/crates.io-index"
205+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
206+
207+[[package]]
208+name = "winapi"
209+version = "0.3.9"
210+source = "registry+https://github.com/rust-lang/crates.io-index"
211+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
212+dependencies = [
213+ "winapi-i686-pc-windows-gnu",
214+ "winapi-x86_64-pc-windows-gnu",
215+]
216+
217+[[package]]
218+name = "winapi-i686-pc-windows-gnu"
219+version = "0.4.0"
220+source = "registry+https://github.com/rust-lang/crates.io-index"
221+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
222+
223+[[package]]
224+name = "winapi-x86_64-pc-windows-gnu"
225+version = "0.4.0"
226+source = "registry+https://github.com/rust-lang/crates.io-index"
227+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
228+
229+[[package]]
230+name = "windows"
231+version = "0.62.2"
232+source = "registry+https://github.com/rust-lang/crates.io-index"
233+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
234+dependencies = [
235+ "windows-collections",
236+ "windows-core",
237+ "windows-future",
238+ "windows-numerics",
239+]
240+
241+[[package]]
242+name = "windows-collections"
243+version = "0.3.2"
244+source = "registry+https://github.com/rust-lang/crates.io-index"
245+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
246+dependencies = [
247+ "windows-core",
248+]
249+
250+[[package]]
251+name = "windows-core"
252+version = "0.62.2"
253+source = "registry+https://github.com/rust-lang/crates.io-index"
254+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
255+dependencies = [
256+ "windows-implement",
257+ "windows-interface",
258+ "windows-link",
259+ "windows-result",
260+ "windows-strings",
261+]
262+
263+[[package]]
264+name = "windows-future"
265+version = "0.3.2"
266+source = "registry+https://github.com/rust-lang/crates.io-index"
267+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
268+dependencies = [
269+ "windows-core",
270+ "windows-link",
271+ "windows-threading",
272+]
273+
274+[[package]]
275+name = "windows-implement"
276+version = "0.60.2"
277+source = "registry+https://github.com/rust-lang/crates.io-index"
278+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
279+dependencies = [
280+ "proc-macro2",
281+ "quote",
282+ "syn",
283+]
284+
285+[[package]]
286+name = "windows-interface"
287+version = "0.59.3"
288+source = "registry+https://github.com/rust-lang/crates.io-index"
289+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
290+dependencies = [
291+ "proc-macro2",
292+ "quote",
293+ "syn",
294+]
295+
296+[[package]]
297+name = "windows-link"
298+version = "0.2.1"
299+source = "registry+https://github.com/rust-lang/crates.io-index"
300+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
301+
302+[[package]]
303+name = "windows-numerics"
304+version = "0.3.1"
305+source = "registry+https://github.com/rust-lang/crates.io-index"
306+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
307+dependencies = [
308+ "windows-core",
309+ "windows-link",
310+]
311+
312+[[package]]
313+name = "windows-result"
314+version = "0.4.1"
315+source = "registry+https://github.com/rust-lang/crates.io-index"
316+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
317+dependencies = [
318+ "windows-link",
319+]
320+
321+[[package]]
322+name = "windows-strings"
323+version = "0.5.1"
324+source = "registry+https://github.com/rust-lang/crates.io-index"
325+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
326+dependencies = [
327+ "windows-link",
328+]
329+
330+[[package]]
331+name = "windows-threading"
332+version = "0.2.1"
333+source = "registry+https://github.com/rust-lang/crates.io-index"
334+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
335+dependencies = [
336+ "windows-link",
337+]
338+
339+[[package]]
340+name = "zmij"
341+version = "1.0.21"
342+source = "registry+https://github.com/rust-lang/crates.io-index"
343+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
native/resource-monitor/Cargo.toml+17−0
@@ -0,0 +1,17 @@
1+[package]
2+name = "t3-resource-monitor"
3+version = "0.1.0"
4+edition = "2024"
5+license = "MIT"
6+publish = false
7+
8+[dependencies]
9+serde = { version = "1.0.228", features = ["derive"] }
10+serde_json = "1.0.150"
11+sysinfo = "0.39.3"
12+
13+[profile.release]
14+codegen-units = 1
15+lto = "thin"
16+panic = "abort"
17+strip = true
native/resource-monitor/src/main.rs+560−0
@@ -0,0 +1,560 @@
1+use serde::{Deserialize, Serialize};
2+use std::collections::{HashMap, HashSet, VecDeque};
3+use std::io::{self, BufRead, BufWriter, Write};
4+use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
5+use std::thread;
6+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7+use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
8+
9+const PROTOCOL_VERSION: u32 = 1;
10+const MIN_SAMPLE_INTERVAL_MS: u64 = 250;
11+const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000;
12+const EXTERNAL_PROCESS_START_TOLERANCE_MS: u64 = 2_000;
13+
14+#[derive(Debug, Clone, Deserialize)]
15+#[serde(rename_all = "camelCase")]
16+struct ExternalProcess {
17+ pid: u32,
18+ #[serde(default)]
19+ start_time_ms: Option<u64>,
20+}
21+
22+#[derive(Debug, Deserialize)]
23+#[serde(
24+ tag = "type",
25+ rename_all = "camelCase",
26+ rename_all_fields = "camelCase"
27+)]
28+enum Command {
29+ Configure {
30+ version: u32,
31+ root_pid: u32,
32+ sample_interval_ms: u64,
33+ #[serde(default)]
34+ external_processes: Vec<ExternalProcess>,
35+ },
36+ SetExternalProcesses {
37+ version: u32,
38+ processes: Vec<ExternalProcess>,
39+ },
40+ SampleNow {
41+ version: u32,
42+ request_id: String,
43+ },
44+ Shutdown {
45+ version: u32,
46+ },
47+}
48+
49+impl Command {
50+ fn version(&self) -> u32 {
51+ match self {
52+ Self::Configure { version, .. }
53+ | Self::SetExternalProcesses { version, .. }
54+ | Self::SampleNow { version, .. }
55+ | Self::Shutdown { version } => *version,
56+ }
57+ }
58+}
59+
60+enum Input {
61+ Command(Command),
62+ Invalid(String),
63+}
64+
65+#[derive(Debug, Serialize)]
66+#[serde(rename_all = "camelCase")]
67+struct Capabilities {
68+ cumulative_cpu_time: bool,
69+ current_cpu_percent: bool,
70+ resident_memory: bool,
71+ virtual_memory: bool,
72+ io_bytes: bool,
73+ process_start_time: bool,
74+ process_tree: bool,
75+}
76+
77+#[derive(Debug, Serialize)]
78+#[serde(rename_all = "camelCase")]
79+struct HelloEvent {
80+ version: u32,
81+ #[serde(rename = "type")]
82+ event_type: &'static str,
83+ sidecar_version: &'static str,
84+ sidecar_pid: u32,
85+ platform: &'static str,
86+ arch: &'static str,
87+ capabilities: Capabilities,
88+}
89+
90+#[derive(Debug, Clone, Copy, Serialize)]
91+#[serde(rename_all = "kebab-case")]
92+enum IoSemantics {
93+ Storage,
94+ AllIo,
95+}
96+
97+#[derive(Debug, Serialize)]
98+#[serde(rename_all = "camelCase")]
99+struct ProcessSample {
100+ pid: u32,
101+ ppid: u32,
102+ start_time_ms: u64,
103+ run_time_ms: u64,
104+ name: String,
105+ command: String,
106+ status: String,
107+ cpu_percent: f32,
108+ cpu_time_ms: u64,
109+ resident_bytes: u64,
110+ virtual_bytes: u64,
111+ io_read_bytes: u64,
112+ io_write_bytes: u64,
113+ io_semantics: IoSemantics,
114+}
115+
116+#[derive(Debug, Serialize)]
117+#[serde(rename_all = "camelCase")]
118+struct SnapshotEvent {
119+ version: u32,
120+ #[serde(rename = "type")]
121+ event_type: &'static str,
122+ sequence: u64,
123+ sampled_at_unix_ms: u64,
124+ collection_duration_micros: u64,
125+ scanned_process_count: usize,
126+ retained_process_count: usize,
127+ inaccessible_process_count: usize,
128+ #[serde(skip_serializing_if = "Option::is_none")]
129+ request_id: Option<String>,
130+ processes: Vec<ProcessSample>,
131+}
132+
133+#[derive(Debug, Serialize)]
134+#[serde(rename_all = "camelCase")]
135+struct ErrorEvent {
136+ version: u32,
137+ #[serde(rename = "type")]
138+ event_type: &'static str,
139+ code: &'static str,
140+ message: String,
141+ recoverable: bool,
142+}
143+
144+#[derive(Debug, Clone)]
145+struct CollectorConfig {
146+ root_pid: u32,
147+ sample_interval: Duration,
148+ external_processes: HashMap<u32, Option<u64>>,
149+}
150+
151+struct Collector {
152+ system: System,
153+ sequence: u64,
154+}
155+
156+impl Collector {
157+ fn new() -> Self {
158+ Self {
159+ system: System::new(),
160+ sequence: 0,
161+ }
162+ }
163+
164+ fn sample(&mut self, config: &CollectorConfig, request_id: Option<String>) -> SnapshotEvent {
165+ let collection_started = Instant::now();
166+ self.system.refresh_processes_specifics(
167+ ProcessesToUpdate::All,
168+ true,
169+ process_refresh_kind(),
170+ );
171+
172+ let rows = self
173+ .system
174+ .processes()
175+ .iter()
176+ .map(|(pid, process)| {
177+ let pid = pid.as_u32();
178+ let ppid = process.parent().map(Pid::as_u32).unwrap_or(0);
179+ (pid, ppid, process.start_time().saturating_mul(1_000))
180+ })
181+ .collect::<Vec<_>>();
182+ let mut roots = config
183+ .external_processes
184+ .iter()
185+ .filter_map(|(pid, expected_start_time_ms)| {
186+ let (_, _, actual_start_time_ms) = rows
187+ .iter()
188+ .find(|(candidate_pid, _, _)| candidate_pid == pid)?;
189+ matches_external_identity(*actual_start_time_ms, *expected_start_time_ms)
190+ .then_some(*pid)
191+ })
192+ .collect::<HashSet<_>>();
193+ roots.insert(config.root_pid);
194+ let tracked = select_tracked_pids(&rows, &roots);
195+ let mut processes = tracked
196+ .into_iter()
197+ .filter_map(|pid| {
198+ let process = self.system.process(Pid::from_u32(pid))?;
199+ let disk_usage = process.disk_usage();
200+ let command = if process.cmd().is_empty() {
201+ process.name().to_string_lossy().into_owned()
202+ } else {
203+ process
204+ .cmd()
205+ .iter()
206+ .map(|part| part.to_string_lossy())
207+ .collect::<Vec<_>>()
208+ .join(" ")
209+ };
210+
211+ Some(ProcessSample {
212+ pid,
213+ ppid: process.parent().map(Pid::as_u32).unwrap_or(0),
214+ start_time_ms: process.start_time().saturating_mul(1_000),
215+ run_time_ms: process.run_time().saturating_mul(1_000),
216+ name: process.name().to_string_lossy().into_owned(),
217+ command,
218+ status: format!("{:?}", process.status()),
219+ cpu_percent: process.cpu_usage(),
220+ cpu_time_ms: process.accumulated_cpu_time(),
221+ resident_bytes: process.memory(),
222+ virtual_bytes: process.virtual_memory(),
223+ io_read_bytes: disk_usage.total_read_bytes,
224+ io_write_bytes: disk_usage.total_written_bytes,
225+ io_semantics: io_semantics(),
226+ })
227+ })
228+ .collect::<Vec<_>>();
229+ processes.sort_by_key(|process| process.pid);
230+ self.sequence = self.sequence.saturating_add(1);
231+
232+ SnapshotEvent {
233+ version: PROTOCOL_VERSION,
234+ event_type: "snapshot",
235+ sequence: self.sequence,
236+ sampled_at_unix_ms: unix_time_ms(),
237+ collection_duration_micros: collection_started.elapsed().as_micros() as u64,
238+ scanned_process_count: self.system.processes().len(),
239+ retained_process_count: processes.len(),
240+ inaccessible_process_count: 0,
241+ request_id,
242+ processes,
243+ }
244+ }
245+}
246+
247+fn process_refresh_kind() -> ProcessRefreshKind {
248+ ProcessRefreshKind::nothing()
249+ .with_memory()
250+ .with_cpu()
251+ .with_disk_usage()
252+ .with_cmd(UpdateKind::OnlyIfNotSet)
253+ .without_tasks()
254+}
255+
256+fn matches_external_identity(
257+ actual_start_time_ms: u64,
258+ expected_start_time_ms: Option<u64>,
259+) -> bool {
260+ expected_start_time_ms.is_none_or(|expected| {
261+ actual_start_time_ms.abs_diff(expected) <= EXTERNAL_PROCESS_START_TOLERANCE_MS
262+ })
263+}
264+
265+fn select_tracked_pids(rows: &[(u32, u32, u64)], roots: &HashSet<u32>) -> HashSet<u32> {
266+ let mut children_by_parent = HashMap::<u32, Vec<u32>>::new();
267+ for (pid, ppid, _) in rows {
268+ children_by_parent.entry(*ppid).or_default().push(*pid);
269+ }
270+
271+ let known_pids = rows.iter().map(|(pid, _, _)| *pid).collect::<HashSet<_>>();
272+ let mut tracked = HashSet::new();
273+ let mut queue = roots
274+ .iter()
275+ .copied()
276+ .filter(|pid| known_pids.contains(pid))
277+ .collect::<VecDeque<_>>();
278+
279+ while let Some(pid) = queue.pop_front() {
280+ if !tracked.insert(pid) {
281+ continue;
282+ }
283+ if let Some(children) = children_by_parent.get(&pid) {
284+ queue.extend(children.iter().copied());
285+ }
286+ }
287+
288+ tracked
289+}
290+
291+fn io_semantics() -> IoSemantics {
292+ if cfg!(target_os = "windows") {
293+ IoSemantics::AllIo
294+ } else {
295+ IoSemantics::Storage
296+ }
297+}
298+
299+fn unix_time_ms() -> u64 {
300+ SystemTime::now()
301+ .duration_since(UNIX_EPOCH)
302+ .unwrap_or_default()
303+ .as_millis() as u64
304+}
305+
306+fn clamp_sample_interval(sample_interval_ms: u64) -> Duration {
307+ Duration::from_millis(sample_interval_ms.clamp(MIN_SAMPLE_INTERVAL_MS, MAX_SAMPLE_INTERVAL_MS))
308+}
309+
310+fn spawn_input_reader() -> Receiver<Input> {
311+ let (sender, receiver) = mpsc::channel();
312+ thread::spawn(move || {
313+ let stdin = io::stdin();
314+ for line in stdin.lock().lines() {
315+ let line = match line {
316+ Ok(line) => line,
317+ Err(error) => {
318+ let _ = sender.send(Input::Invalid(format!(
319+ "failed reading command stream: {error}"
320+ )));
321+ return;
322+ }
323+ };
324+ if line.trim().is_empty() {
325+ continue;
326+ }
327+ match serde_json::from_str::<Command>(&line) {
328+ Ok(command) => {
329+ if sender.send(Input::Command(command)).is_err() {
330+ return;
331+ }
332+ }
333+ Err(error) => {
334+ if sender
335+ .send(Input::Invalid(format!("invalid command: {error}")))
336+ .is_err()
337+ {
338+ return;
339+ }
340+ }
341+ }
342+ }
343+ });
344+ receiver
345+}
346+
347+fn write_event<T: Serialize>(writer: &mut impl Write, event: &T) -> io::Result<()> {
348+ serde_json::to_writer(&mut *writer, event)?;
349+ writer.write_all(b"\n")?;
350+ writer.flush()
351+}
352+
353+fn write_error(
354+ writer: &mut impl Write,
355+ code: &'static str,
356+ message: impl Into<String>,
357+ recoverable: bool,
358+) -> io::Result<()> {
359+ write_event(
360+ writer,
361+ &ErrorEvent {
362+ version: PROTOCOL_VERSION,
363+ event_type: "error",
364+ code,
365+ message: message.into(),
366+ recoverable,
367+ },
368+ )
369+}
370+
371+fn main() -> io::Result<()> {
372+ let mut writer = BufWriter::new(io::stdout().lock());
373+ write_event(
374+ &mut writer,
375+ &HelloEvent {
376+ version: PROTOCOL_VERSION,
377+ event_type: "hello",
378+ sidecar_version: env!("CARGO_PKG_VERSION"),
379+ sidecar_pid: std::process::id(),
380+ platform: std::env::consts::OS,
381+ arch: std::env::consts::ARCH,
382+ capabilities: Capabilities {
383+ cumulative_cpu_time: true,
384+ current_cpu_percent: true,
385+ resident_memory: true,
386+ virtual_memory: true,
387+ io_bytes: true,
388+ process_start_time: true,
389+ process_tree: true,
390+ },
391+ },
392+ )?;
393+
394+ let receiver = spawn_input_reader();
395+ let mut collector = Collector::new();
396+ let mut config: Option<CollectorConfig> = None;
397+ let mut next_sample_at: Option<Instant> = None;
398+
399+ loop {
400+ let timeout = next_sample_at
401+ .map(|deadline| deadline.saturating_duration_since(Instant::now()))
402+ .unwrap_or(Duration::from_secs(60));
403+
404+ match receiver.recv_timeout(timeout) {
405+ Ok(Input::Invalid(message)) => {
406+ write_error(&mut writer, "invalid-command", message, true)?;
407+ }
408+ Ok(Input::Command(command)) => {
409+ if command.version() != PROTOCOL_VERSION {
410+ write_error(
411+ &mut writer,
412+ "protocol-mismatch",
413+ format!(
414+ "unsupported protocol version {}; expected {PROTOCOL_VERSION}",
415+ command.version()
416+ ),
417+ false,
418+ )?;
419+ continue;
420+ }
421+
422+ match command {
423+ Command::Configure {
424+ root_pid,
425+ sample_interval_ms,
426+ external_processes,
427+ ..
428+ } => {
429+ let sample_interval = clamp_sample_interval(sample_interval_ms);
430+ config = Some(CollectorConfig {
431+ root_pid,
432+ sample_interval,
433+ external_processes: external_processes
434+ .into_iter()
435+ .map(|process| (process.pid, process.start_time_ms))
436+ .collect(),
437+ });
438+ next_sample_at = Some(Instant::now());
439+ }
440+ Command::SetExternalProcesses { processes, .. } => {
441+ if let Some(current) = config.as_mut() {
442+ current.external_processes = processes
443+ .into_iter()
444+ .map(|process| (process.pid, process.start_time_ms))
445+ .collect();
446+ } else {
447+ write_error(
448+ &mut writer,
449+ "not-configured",
450+ "configure must be sent before external processes",
451+ true,
452+ )?;
453+ }
454+ }
455+ Command::SampleNow { request_id, .. } => {
456+ if let Some(current) = config.as_ref() {
457+ let event = collector.sample(current, Some(request_id));
458+ write_event(&mut writer, &event)?;
459+ next_sample_at = Some(Instant::now() + current.sample_interval);
460+ } else {
461+ write_error(
462+ &mut writer,
463+ "not-configured",
464+ "configure must be sent before sampling",
465+ true,
466+ )?;
467+ }
468+ }
469+ Command::Shutdown { .. } => return Ok(()),
470+ }
471+ }
472+ Err(RecvTimeoutError::Timeout) => {
473+ if let Some(current) = config.as_ref() {
474+ let event = collector.sample(current, None);
475+ write_event(&mut writer, &event)?;
476+ next_sample_at = Some(Instant::now() + current.sample_interval);
477+ }
478+ }
479+ Err(RecvTimeoutError::Disconnected) => return Ok(()),
480+ }
481+ }
482+}
483+
484+#[cfg(test)]
485+mod tests {
486+ use super::*;
487+
488+ #[test]
489+ fn selects_roots_and_all_descendants() {
490+ let rows = vec![
491+ (10, 1, 1_000),
492+ (11, 10, 1_100),
493+ (12, 11, 1_200),
494+ (20, 1, 2_000),
495+ (21, 20, 2_100),
496+ (30, 99, 3_000),
497+ ];
498+ let tracked = select_tracked_pids(&rows, &HashSet::from([10, 20]));
499+
500+ assert_eq!(tracked, HashSet::from([10, 11, 12, 20, 21]));
501+ }
502+
503+ #[test]
504+ fn ignores_missing_roots() {
505+ let rows = vec![(10, 1, 1_000), (11, 10, 1_100)];
506+ let tracked = select_tracked_pids(&rows, &HashSet::from([99]));
507+
508+ assert!(tracked.is_empty());
509+ }
510+
511+ #[test]
512+ fn validates_external_process_start_identity() {
513+ assert!(matches_external_identity(10_000, None));
514+ assert!(matches_external_identity(10_000, Some(11_999)));
515+ assert!(!matches_external_identity(10_000, Some(12_001)));
516+ }
517+
518+ #[test]
519+ fn decodes_protocol_commands() {
520+ let configure = serde_json::from_str::<Command>(
521+ r#"{"version":1,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#,
522+ )
523+ .expect("configure command");
524+
525+ match configure {
526+ Command::Configure {
527+ root_pid,
528+ sample_interval_ms,
529+ external_processes,
530+ ..
531+ } => {
532+ assert_eq!(root_pid, 42);
533+ assert_eq!(sample_interval_ms, 1_000);
534+ assert_eq!(external_processes[0].pid, 7);
535+ assert_eq!(external_processes[0].start_time_ms, None);
536+ }
537+ _ => panic!("unexpected command"),
538+ }
539+ }
540+
541+ #[test]
542+ fn clamps_sample_interval() {
543+ assert_eq!(clamp_sample_interval(1), Duration::from_millis(250));
544+ assert_eq!(
545+ clamp_sample_interval(100_000),
546+ Duration::from_millis(60_000)
547+ );
548+ }
549+
550+ #[test]
551+ fn refreshes_commands_without_enumerating_linux_tasks() {
552+ let refresh_kind = process_refresh_kind();
553+
554+ assert_eq!(refresh_kind.cmd(), UpdateKind::OnlyIfNotSet);
555+ assert!(!refresh_kind.tasks());
556+ assert!(refresh_kind.cpu());
557+ assert!(refresh_kind.memory());
558+ assert!(refresh_kind.disk_usage());
559+ }
560+}
package.json+2−0
@@ -16,11 +16,13 @@ · 15 unmodified lines
1616 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
1717 "build:marketing": "vp run --filter @t3tools/marketing build",
1818 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
19+ "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml",
1920 "typecheck": "vp run -r --concurrency-limit 2 typecheck",
2021 "tc": "vp run -r --concurrency-limit 2 typecheck",
2122 "lint": "vp lint --report-unused-disable-directives",
2223 "lint:mobile": "node scripts/mobile-native-static-check.ts",
2324 "test": "vp run -r test",
25+ "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",
2426 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",
2527 "fmt": "vp fmt",
2628 "fmt:check": "vp fmt --check",
packages/client-runtime/src/wsRpcClient.ts+24−0
@@ -171,6 +171,18 @@ export interface WsRpcClient { · 170 unmodified lines
171171 readonly getProcessResourceHistory: RpcUnaryMethod<
172172 typeof WS_METHODS.serverGetProcessResourceHistory
173173 >;
174+ readonly getResourceTelemetry: RpcUnaryNoArgMethod<
175+ typeof WS_METHODS.serverGetResourceTelemetry
176+ >;
177+ readonly getResourceTelemetryHistory: RpcUnaryMethod<
178+ typeof WS_METHODS.serverGetResourceTelemetryHistory
179+ >;
180+ readonly retryResourceTelemetry: RpcUnaryNoArgMethod<
181+ typeof WS_METHODS.serverRetryResourceTelemetry
182+ >;
183+ readonly subscribeResourceTelemetry: RpcStreamMethod<
184+ typeof WS_METHODS.subscribeResourceTelemetry
185+ >;
174186 readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>;
175187 readonly reportClientActivity: RpcUnaryMethod<typeof WS_METHODS.serverReportClientActivity>;
176188 readonly reportHostPowerState: RpcUnaryMethod<typeof WS_METHODS.serverReportHostPowerState>;
@@ -395,6 +407,18 @@ export function createWsRpcClient( · 218 unmodified lines
395407 transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})),
396408 getProcessResourceHistory: (input) =>
397409 transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)),
410+ getResourceTelemetry: () =>
411+ transport.request((client) => client[WS_METHODS.serverGetResourceTelemetry]({})),
412+ getResourceTelemetryHistory: (input) =>
413+ transport.request((client) => client[WS_METHODS.serverGetResourceTelemetryHistory](input)),
414+ retryResourceTelemetry: () =>
415+ transport.request((client) => client[WS_METHODS.serverRetryResourceTelemetry]({})),
416+ subscribeResourceTelemetry: (listener, options) =>
417+ transport.subscribe(
418+ (client) => client[WS_METHODS.subscribeResourceTelemetry]({}),
419+ listener,
420+ subscriptionOptions(options, WS_METHODS.subscribeResourceTelemetry),
421+ ),
398422 signalProcess: (input) =>
399423 transport.request((client) => client[WS_METHODS.serverSignalProcess](input)),
400424 reportClientActivity: (input) =>
packages/contracts/src/desktopBootstrap.ts+3−1
@@ -1,6 +1,6 @@
11 import * as Schema from "effect/Schema";
22
3−import { PortSchema } from "./baseSchemas.ts";
3+import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
44
55 export const DesktopBackendBootstrap = Schema.Struct({
66 mode: Schema.Literal("desktop"),
@@ -13,6 +13,8 @@ export const DesktopBackendBootstrap = Schema.Struct({ · 6 unmodified lines
1313 tailscaleServePort: PortSchema,
1414 otlpTracesUrl: Schema.optional(Schema.String),
1515 otlpMetricsUrl: Schema.optional(Schema.String),
16+ desktopTelemetryFd: Schema.optionalKey(PositiveInt),
17+ resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
1618 });
1719
1820 export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;
packages/contracts/src/index.ts+1−0
@@ -26,4 +26,5 @@ export * from "./assets.ts"; · 25 unmodified lines
2626 export * from "./review.ts";
2727 export * from "./preview.ts";
2828 export * from "./previewAutomation.ts";
29+export * from "./resourceTelemetry.ts";
2930 export * from "./rpc.ts";
packages/contracts/src/ipc.ts+11−0
@@ -32,6 +32,12 @@ import type { · 31 unmodified lines
3232 ProjectWriteFileResult,
3333 } from "./project.ts";
3434 import type { ProviderInstanceId } from "./providerInstance.ts";
35+import type {
36+ ResourceTelemetryHistory,
37+ ResourceTelemetryHistoryInput,
38+ ResourceTelemetryRetryResult,
39+ ResourceTelemetrySnapshot,
40+} from "./resourceTelemetry.ts";
3541 import type {
3642 ServerConfig,
3743 ServerProcessDiagnosticsResult,
@@ -1078,6 +1084,11 @@ export interface LocalApi { · 1040 unmodified lines
10781084 getProcessResourceHistory: (
10791085 input: ServerProcessResourceHistoryInput,
10801086 ) => Promise<ServerProcessResourceHistoryResult>;
1087+ getResourceTelemetry: () => Promise<ResourceTelemetrySnapshot>;
1088+ getResourceTelemetryHistory: (
1089+ input: ResourceTelemetryHistoryInput,
1090+ ) => Promise<ResourceTelemetryHistory>;
1091+ retryResourceTelemetry: () => Promise<ResourceTelemetryRetryResult>;
10811092 signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>;
10821093 };
10831094 }
packages/contracts/src/resourceTelemetry.ts+365−0
@@ -0,0 +1,365 @@
1+import * as Schema from "effect/Schema";
2+
3+import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
4+import { HostPowerSnapshot } from "./background.ts";
5+
6+export const RESOURCE_MONITOR_PROTOCOL_VERSION = 1 as const;
7+
8+export const ResourceTelemetryIoSemantics = Schema.Literals([
9+ "storage",
10+ "logical",
11+ "all-io",
12+ "unavailable",
13+]);
14+export type ResourceTelemetryIoSemantics = typeof ResourceTelemetryIoSemantics.Type;
15+
16+export const ResourceTelemetryProcessCategory = Schema.Literals([
17+ "server",
18+ "server-child",
19+ "provider-root",
20+ "terminal-root",
21+ "electron-main",
22+ "electron-renderer",
23+ "electron-gpu",
24+ "electron-utility",
25+ "resource-monitor",
26+ "unknown-t3",
27+]);
28+export type ResourceTelemetryProcessCategory = typeof ResourceTelemetryProcessCategory.Type;
29+
30+export const ResourceTelemetrySourceStatus = Schema.Literals([
31+ "starting",
32+ "healthy",
33+ "degraded",
34+ "unavailable",
35+ "stopped",
36+]);
37+export type ResourceTelemetrySourceStatus = typeof ResourceTelemetrySourceStatus.Type;
38+
39+export const ResourceTelemetryProcessIdentity = Schema.Struct({
40+ pid: PositiveInt,
41+ startTimeMs: NonNegativeInt,
42+});
43+export type ResourceTelemetryProcessIdentity = typeof ResourceTelemetryProcessIdentity.Type;
44+
45+export const ResourceMonitorExternalProcess = Schema.Struct({
46+ pid: PositiveInt,
47+ startTimeMs: Schema.optionalKey(NonNegativeInt),
48+});
49+export type ResourceMonitorExternalProcess = typeof ResourceMonitorExternalProcess.Type;
50+
51+export const ResourceMonitorCapabilities = Schema.Struct({
52+ cumulativeCpuTime: Schema.Boolean,
53+ currentCpuPercent: Schema.Boolean,
54+ residentMemory: Schema.Boolean,
55+ virtualMemory: Schema.Boolean,
56+ ioBytes: Schema.Boolean,
57+ processStartTime: Schema.Boolean,
58+ processTree: Schema.Boolean,
59+});
60+export type ResourceMonitorCapabilities = typeof ResourceMonitorCapabilities.Type;
61+
62+export const ResourceMonitorProcessSample = Schema.Struct({
63+ pid: PositiveInt,
64+ ppid: NonNegativeInt,
65+ startTimeMs: NonNegativeInt,
66+ runTimeMs: NonNegativeInt,
67+ name: Schema.String,
68+ command: Schema.String,
69+ status: Schema.String,
70+ cpuPercent: Schema.Number,
71+ cpuTimeMs: NonNegativeInt,
72+ residentBytes: NonNegativeInt,
73+ virtualBytes: NonNegativeInt,
74+ ioReadBytes: NonNegativeInt,
75+ ioWriteBytes: NonNegativeInt,
76+ ioSemantics: Schema.Literals(["storage", "all-io"]),
77+});
78+export type ResourceMonitorProcessSample = typeof ResourceMonitorProcessSample.Type;
79+
80+export const ResourceMonitorConfigureCommand = Schema.Struct({
81+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
82+ type: Schema.Literal("configure"),
83+ rootPid: PositiveInt,
84+ sampleIntervalMs: NonNegativeInt,
85+ externalProcesses: Schema.Array(ResourceMonitorExternalProcess),
86+});
87+export type ResourceMonitorConfigureCommand = typeof ResourceMonitorConfigureCommand.Type;
88+
89+export const ResourceMonitorSetExternalProcessesCommand = Schema.Struct({
90+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
91+ type: Schema.Literal("setExternalProcesses"),
92+ processes: Schema.Array(ResourceMonitorExternalProcess),
93+});
94+export type ResourceMonitorSetExternalProcessesCommand =
95+ typeof ResourceMonitorSetExternalProcessesCommand.Type;
96+
97+export const ResourceMonitorSampleNowCommand = Schema.Struct({
98+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
99+ type: Schema.Literal("sampleNow"),
100+ requestId: TrimmedNonEmptyString,
101+});
102+export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type;
103+
104+export const ResourceMonitorShutdownCommand = Schema.Struct({
105+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
106+ type: Schema.Literal("shutdown"),
107+});
108+export type ResourceMonitorShutdownCommand = typeof ResourceMonitorShutdownCommand.Type;
109+
110+export const ResourceMonitorCommand = Schema.Union([
111+ ResourceMonitorConfigureCommand,
112+ ResourceMonitorSetExternalProcessesCommand,
113+ ResourceMonitorSampleNowCommand,
114+ ResourceMonitorShutdownCommand,
115+]);
116+export type ResourceMonitorCommand = typeof ResourceMonitorCommand.Type;
117+
118+export const ResourceMonitorHelloEvent = Schema.Struct({
119+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
120+ type: Schema.Literal("hello"),
121+ sidecarVersion: TrimmedNonEmptyString,
122+ sidecarPid: PositiveInt,
123+ platform: TrimmedNonEmptyString,
124+ arch: TrimmedNonEmptyString,
125+ capabilities: ResourceMonitorCapabilities,
126+});
127+export type ResourceMonitorHelloEvent = typeof ResourceMonitorHelloEvent.Type;
128+
129+export const ResourceMonitorSnapshotEvent = Schema.Struct({
130+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
131+ type: Schema.Literal("snapshot"),
132+ sequence: NonNegativeInt,
133+ sampledAtUnixMs: NonNegativeInt,
134+ collectionDurationMicros: NonNegativeInt,
135+ scannedProcessCount: NonNegativeInt,
136+ retainedProcessCount: NonNegativeInt,
137+ inaccessibleProcessCount: NonNegativeInt,
138+ requestId: Schema.optionalKey(TrimmedNonEmptyString),
139+ processes: Schema.Array(ResourceMonitorProcessSample),
140+});
141+export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type;
142+
143+export const ResourceMonitorErrorEvent = Schema.Struct({
144+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
145+ type: Schema.Literal("error"),
146+ code: TrimmedNonEmptyString,
147+ message: TrimmedNonEmptyString,
148+ recoverable: Schema.Boolean,
149+});
150+export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type;
151+
152+export const ResourceMonitorEvent = Schema.Union([
153+ ResourceMonitorHelloEvent,
154+ ResourceMonitorSnapshotEvent,
155+ ResourceMonitorErrorEvent,
156+]);
157+export type ResourceMonitorEvent = typeof ResourceMonitorEvent.Type;
158+
159+export const DesktopElectronProcessType = Schema.Literals([
160+ "Browser",
161+ "Tab",
162+ "Utility",
163+ "Zygote",
164+ "Sandbox helper",
165+ "GPU",
166+ "Pepper Plugin",
167+ "Pepper Plugin Broker",
168+ "Unknown",
169+]);
170+export type DesktopElectronProcessType = typeof DesktopElectronProcessType.Type;
171+
172+export const DesktopElectronProcessMetric = Schema.Struct({
173+ pid: PositiveInt,
174+ creationTimeMs: NonNegativeInt,
175+ type: DesktopElectronProcessType,
176+ name: Schema.optionalKey(Schema.String),
177+ serviceName: Schema.optionalKey(Schema.String),
178+ cpuPercent: Schema.Number,
179+ cumulativeCpuSeconds: Schema.optionalKey(Schema.Number),
180+ idleWakeupsPerSecond: Schema.Number,
181+ workingSetBytes: NonNegativeInt,
182+ peakWorkingSetBytes: NonNegativeInt,
183+});
184+export type DesktopElectronProcessMetric = typeof DesktopElectronProcessMetric.Type;
185+
186+export const DesktopHostTelemetrySnapshot = Schema.Struct({
187+ version: Schema.Literal(1),
188+ type: Schema.Literal("desktopTelemetry"),
189+ sequence: NonNegativeInt,
190+ sampledAtUnixMs: NonNegativeInt,
191+ power: HostPowerSnapshot,
192+ speedLimitPercent: Schema.Option(Schema.Number),
193+ electronProcesses: Schema.Array(DesktopElectronProcessMetric),
194+});
195+export type DesktopHostTelemetrySnapshot = typeof DesktopHostTelemetrySnapshot.Type;
196+
197+export const DesktopHostTelemetryHello = Schema.Struct({
198+ version: Schema.Literal(1),
199+ type: Schema.Literal("desktopTelemetryHello"),
200+ electronPid: PositiveInt,
201+});
202+export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type;
203+
204+export const DesktopHostTelemetryMessage = Schema.Union([
205+ DesktopHostTelemetryHello,
206+ DesktopHostTelemetrySnapshot,
207+]);
208+export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type;
209+
210+export const ResourceTelemetryProcess = Schema.Struct({
211+ identity: ResourceTelemetryProcessIdentity,
212+ ppid: NonNegativeInt,
213+ childPids: Schema.Array(PositiveInt),
214+ depth: NonNegativeInt,
215+ name: Schema.String,
216+ command: Schema.String,
217+ status: Schema.String,
218+ category: ResourceTelemetryProcessCategory,
219+ electronType: Schema.optionalKey(DesktopElectronProcessType),
220+ electronServiceName: Schema.optionalKey(Schema.String),
221+ cpuPercent: Schema.Number,
222+ cpuTimeMs: NonNegativeInt,
223+ residentBytes: NonNegativeInt,
224+ peakResidentBytes: NonNegativeInt,
225+ virtualBytes: NonNegativeInt,
226+ ioReadBytes: NonNegativeInt,
227+ ioWriteBytes: NonNegativeInt,
228+ ioReadBytesPerSecond: Schema.Number,
229+ ioWriteBytesPerSecond: Schema.Number,
230+ ioSemantics: ResourceTelemetryIoSemantics,
231+ idleWakeupsPerSecond: Schema.optionalKey(Schema.Number),
232+ runTimeMs: NonNegativeInt,
233+ firstSeenAt: Schema.DateTimeUtc,
234+ lastSeenAt: Schema.DateTimeUtc,
235+});
236+export type ResourceTelemetryProcess = typeof ResourceTelemetryProcess.Type;
237+
238+export const ResourceTelemetryAggregate = Schema.Struct({
239+ processCount: NonNegativeInt,
240+ currentCpuPercent: Schema.Number,
241+ cpuTimeMs: NonNegativeInt,
242+ currentRssBytes: NonNegativeInt,
243+ peakRssBytes: NonNegativeInt,
244+ ioReadBytes: NonNegativeInt,
245+ ioWriteBytes: NonNegativeInt,
246+ ioReadBytesPerSecond: Schema.Number,
247+ ioWriteBytesPerSecond: Schema.Number,
248+ processStarts: NonNegativeInt,
249+ processExits: NonNegativeInt,
250+});
251+export type ResourceTelemetryAggregate = typeof ResourceTelemetryAggregate.Type;
252+
253+export const ResourceTelemetryGroups = Schema.Struct({
254+ backend: ResourceTelemetryAggregate,
255+ electron: ResourceTelemetryAggregate,
256+ monitor: ResourceTelemetryAggregate,
257+ allT3: ResourceTelemetryAggregate,
258+});
259+export type ResourceTelemetryGroups = typeof ResourceTelemetryGroups.Type;
260+
261+export const ResourceTelemetrySourceHealth = Schema.Struct({
262+ status: ResourceTelemetrySourceStatus,
263+ lastSampleAt: Schema.Option(Schema.DateTimeUtc),
264+ lastError: Schema.Option(TrimmedNonEmptyString),
265+});
266+export type ResourceTelemetrySourceHealth = typeof ResourceTelemetrySourceHealth.Type;
267+
268+export const ResourceTelemetryHealth = Schema.Struct({
269+ native: ResourceTelemetrySourceHealth,
270+ desktop: ResourceTelemetrySourceHealth,
271+ sidecarVersion: Schema.Option(TrimmedNonEmptyString),
272+ sidecarPid: Schema.Option(PositiveInt),
273+ restartCount: NonNegativeInt,
274+ collectionDurationMicros: NonNegativeInt,
275+ scannedProcessCount: NonNegativeInt,
276+ retainedProcessCount: NonNegativeInt,
277+ inaccessibleProcessCount: NonNegativeInt,
278+});
279+export type ResourceTelemetryHealth = typeof ResourceTelemetryHealth.Type;
280+
281+export const ResourceAttributionEntry = Schema.Struct({
282+ component: TrimmedNonEmptyString,
283+ operation: TrimmedNonEmptyString,
284+ logicalReadBytes: NonNegativeInt,
285+ logicalWriteBytes: NonNegativeInt,
286+ count: NonNegativeInt,
287+ durationMs: NonNegativeInt,
288+});
289+export type ResourceAttributionEntry = typeof ResourceAttributionEntry.Type;
290+
291+export const ResourceAttributionSnapshot = Schema.Struct({
292+ readAt: Schema.DateTimeUtc,
293+ entries: Schema.Array(ResourceAttributionEntry),
294+});
295+export type ResourceAttributionSnapshot = typeof ResourceAttributionSnapshot.Type;
296+
297+export const ResourceTelemetrySnapshot = Schema.Struct({
298+ readAt: Schema.DateTimeUtc,
299+ sampleIntervalMs: NonNegativeInt,
300+ processes: Schema.Array(ResourceTelemetryProcess),
301+ groups: ResourceTelemetryGroups,
302+ power: HostPowerSnapshot,
303+ speedLimitPercent: Schema.Option(Schema.Number),
304+ attribution: ResourceAttributionSnapshot,
305+ health: ResourceTelemetryHealth,
306+});
307+export type ResourceTelemetrySnapshot = typeof ResourceTelemetrySnapshot.Type;
308+
309+export const ResourceTelemetryHistoryInput = Schema.Struct({
310+ windowMs: NonNegativeInt,
311+ bucketMs: NonNegativeInt,
312+});
313+export type ResourceTelemetryHistoryInput = typeof ResourceTelemetryHistoryInput.Type;
314+
315+export const ResourceTelemetryHistoryBucket = Schema.Struct({
316+ startedAt: Schema.DateTimeUtc,
317+ endedAt: Schema.DateTimeUtc,
318+ avgCpuPercent: Schema.Number,
319+ maxCpuPercent: Schema.Number,
320+ maxRssBytes: NonNegativeInt,
321+ ioReadBytes: NonNegativeInt,
322+ ioWriteBytes: NonNegativeInt,
323+ maxProcessCount: NonNegativeInt,
324+});
325+export type ResourceTelemetryHistoryBucket = typeof ResourceTelemetryHistoryBucket.Type;
326+
327+export const ResourceTelemetryProcessSummary = Schema.Struct({
328+ identity: ResourceTelemetryProcessIdentity,
329+ ppid: NonNegativeInt,
330+ depth: NonNegativeInt,
331+ name: Schema.String,
332+ command: Schema.String,
333+ category: ResourceTelemetryProcessCategory,
334+ firstSeenAt: Schema.DateTimeUtc,
335+ lastSeenAt: Schema.DateTimeUtc,
336+ currentCpuPercent: Schema.Number,
337+ avgCpuPercent: Schema.Number,
338+ maxCpuPercent: Schema.Number,
339+ cpuTimeMs: NonNegativeInt,
340+ currentRssBytes: NonNegativeInt,
341+ peakRssBytes: NonNegativeInt,
342+ ioReadBytes: NonNegativeInt,
343+ ioWriteBytes: NonNegativeInt,
344+ ioSemantics: ResourceTelemetryIoSemantics,
345+ sampleCount: NonNegativeInt,
346+});
347+export type ResourceTelemetryProcessSummary = typeof ResourceTelemetryProcessSummary.Type;
348+
349+export const ResourceTelemetryHistory = Schema.Struct({
350+ readAt: Schema.DateTimeUtc,
351+ windowMs: NonNegativeInt,
352+ bucketMs: NonNegativeInt,
353+ sampleIntervalMs: NonNegativeInt,
354+ retainedSampleCount: NonNegativeInt,
355+ buckets: Schema.Array(ResourceTelemetryHistoryBucket),
356+ topProcesses: Schema.Array(ResourceTelemetryProcessSummary),
357+ health: ResourceTelemetryHealth,
358+});
359+export type ResourceTelemetryHistory = typeof ResourceTelemetryHistory.Type;
360+
361+export const ResourceTelemetryRetryResult = Schema.Struct({
362+ accepted: Schema.Boolean,
363+ snapshot: ResourceTelemetrySnapshot,
364+});
365+export type ResourceTelemetryRetryResult = typeof ResourceTelemetryRetryResult.Type;
packages/contracts/src/rpc.ts+42−0
@@ -134,6 +134,12 @@ import { · 133 unmodified lines
134134 ServerUpsertKeybindingInput,
135135 ServerUpsertKeybindingResult,
136136 } from "./server.ts";
137+import {
138+ ResourceTelemetryHistory,
139+ ResourceTelemetryHistoryInput,
140+ ResourceTelemetryRetryResult,
141+ ResourceTelemetrySnapshot,
142+} from "./resourceTelemetry.ts";
137143 import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
138144 import {
139145 SourceControlCloneRepositoryInput,
@@ -215,6 +221,9 @@ export const WS_METHODS = { · 75 unmodified lines
215221 serverGetTraceDiagnostics: "server.getTraceDiagnostics",
216222 serverGetProcessDiagnostics: "server.getProcessDiagnostics",
217223 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
224+ serverGetResourceTelemetry: "server.getResourceTelemetry",
225+ serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
226+ serverRetryResourceTelemetry: "server.retryResourceTelemetry",
218227 serverSignalProcess: "server.signalProcess",
219228 serverReportClientActivity: "server.reportClientActivity",
220229 serverReportHostPowerState: "server.reportHostPowerState",
@@ -239,6 +248,7 @@ export const WS_METHODS = { · 18 unmodified lines
239248 subscribeServerLifecycle: "subscribeServerLifecycle",
240249 subscribeAuthAccess: "subscribeAuthAccess",
241250 subscribeBackgroundPolicy: "subscribeBackgroundPolicy",
251+ subscribeResourceTelemetry: "subscribeResourceTelemetry",
242252 } as const;
243253
244254 export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
@@ -318,6 +328,27 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( · 73 unmodified lines
318328 },
319329 );
320330
331+export const WsServerGetResourceTelemetryRpc = Rpc.make(WS_METHODS.serverGetResourceTelemetry, {
332+ payload: Schema.Struct({}),
333+ success: ResourceTelemetrySnapshot,
334+ error: EnvironmentAuthorizationError,
335+});
336+
337+export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make(
338+ WS_METHODS.serverGetResourceTelemetryHistory,
339+ {
340+ payload: ResourceTelemetryHistoryInput,
341+ success: ResourceTelemetryHistory,
342+ error: EnvironmentAuthorizationError,
343+ },
344+);
345+
346+export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, {
347+ payload: Schema.Struct({}),
348+ success: ResourceTelemetryRetryResult,
349+ error: EnvironmentAuthorizationError,
350+});
351+
321352 export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
322353 payload: ServerSignalProcessInput,
323354 success: ServerSignalProcessResult,
@@ -710,6 +741,13 @@ export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackg · 386 unmodified lines
710741 stream: true,
711742 });
712743
744+export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, {
745+ payload: Schema.Struct({}),
746+ success: ResourceTelemetrySnapshot,
747+ error: EnvironmentAuthorizationError,
748+ stream: true,
749+});
750+
713751 export const WsRpcGroup = RpcGroup.make(
714752 WsServerGetConfigRpc,
715753 WsServerRefreshProvidersRpc,
@@ -722,6 +760,9 @@ export const WsRpcGroup = RpcGroup.make( · 6 unmodified lines
722760 WsServerGetTraceDiagnosticsRpc,
723761 WsServerGetProcessDiagnosticsRpc,
724762 WsServerGetProcessResourceHistoryRpc,
763+ WsServerGetResourceTelemetryRpc,
764+ WsServerGetResourceTelemetryHistoryRpc,
765+ WsServerRetryResourceTelemetryRpc,
725766 WsServerSignalProcessRpc,
726767 WsServerReportClientActivityRpc,
727768 WsServerReportHostPowerStateRpc,
@@ -776,6 +817,7 @@ export const WsRpcGroup = RpcGroup.make( · 48 unmodified lines
776817 WsSubscribeServerLifecycleRpc,
777818 WsSubscribeAuthAccessRpc,
778819 WsSubscribeBackgroundPolicyRpc,
820+ WsSubscribeResourceTelemetryRpc,
779821 WsOrchestrationDispatchCommandRpc,
780822 WsOrchestrationGetTurnDiffRpc,
781823 WsOrchestrationGetFullThreadDiffRpc,
packages/contracts/src/server.ts+2−0
@@ -302,6 +302,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type; · 301 unmodified lines
302302
303303 export const ServerProcessDiagnosticsEntry = Schema.Struct({
304304 pid: PositiveInt,
305+ startTimeMs: NonNegativeInt,
305306 ppid: NonNegativeInt,
306307 pgid: Schema.Option(Schema.Int),
307308 status: TrimmedNonEmptyString,
@@ -383,6 +384,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis · 75 unmodified lines
383384
384385 export const ServerSignalProcessInput = Schema.Struct({
385386 pid: PositiveInt,
387+ startTimeMs: NonNegativeInt,
386388 signal: ServerProcessSignal,
387389 });
388390 export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type;
packages/shared/src/observability.test.ts+30−0
@@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; · 7 unmodified lines
88 import * as Logger from "effect/Logger";
99 import * as Order from "effect/Order";
1010 import * as Path from "effect/Path";
11+import * as Ref from "effect/Ref";
1112 import * as References from "effect/References";
1213 import * as Schema from "effect/Schema";
1314 import * as Tracer from "effect/Tracer";
@@ -18,6 +19,7 @@ import { · 4 unmodified lines
1819 makeLocalFileTracer,
1920 makeTraceSink,
2021 type TraceRecord,
22+ type TraceSinkFlushStats,
2123 } from "./observability.ts";
2224
2325 describe("causeErrorTag", () => {
@@ -167,6 +169,34 @@ describe("observability", () => { · 143 unmodified lines
167169 ),
168170 );
169171
172+ it.effect("reports successful logical trace writes", () =>
173+ Effect.scoped(
174+ Effect.gen(function* () {
175+ const fileSystem = yield* FileSystem.FileSystem;
176+ const path = yield* Path.Path;
177+ const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" });
178+ const tracePath = path.join(tempDir, "shared.trace.ndjson");
179+ const reported = yield* Ref.make<ReadonlyArray<TraceSinkFlushStats>>([]);
180+
181+ const sink = yield* makeTraceSink({
182+ filePath: tracePath,
183+ maxBytes: 1024,
184+ maxFiles: 2,
185+ batchWindowMs: 10_000,
186+ onFlush: (stats) => Ref.update(reported, (current) => [...current, stats]),
187+ });
188+
189+ sink.push(makeRecord("attributed"));
190+ yield* sink.flush;
191+
192+ const stats = yield* Ref.get(reported);
193+ assert.equal(stats.length, 1);
194+ assert.equal(stats[0]?.count, 1);
195+ assert.isAbove(stats[0]?.logicalWriteBytes ?? 0, 0);
196+ }),
197+ ),
198+ );
199+
170200 it.effect("rotates the trace file when the configured max size is exceeded", () =>
171201 Effect.scoped(
172202 Effect.gen(function* () {
packages/shared/src/observability.ts+38−3
@@ -9,6 +9,7 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; · 8 unmodified lines
99 import { RotatingFileSink } from "./logging.ts";
1010
1111 const FLUSH_BUFFER_THRESHOLD = 32;
12+const textEncoder = new TextEncoder();
1213
1314 export type TraceAttributes = Readonly<Record<string, unknown>>;
1415
@@ -94,6 +95,13 @@ export interface TraceSinkOptions { · 79 unmodified lines
9495 readonly maxBytes: number;
9596 readonly maxFiles: number;
9697 readonly batchWindowMs: number;
98+ readonly onFlush?: (stats: TraceSinkFlushStats) => Effect.Effect<void>;
99+}
100+
101+export interface TraceSinkFlushStats {
102+ readonly logicalWriteBytes: number;
103+ readonly count: number;
104+ readonly durationMs: number;
97105 }
98106
99107 export interface TraceSink {
@@ -263,23 +271,50 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac · 163 unmodified lines
263271 });
264272
265273 let buffer: Array<string> = [];
274+ let pendingFlushStats: TraceSinkFlushStats = {
275+ logicalWriteBytes: 0,
276+ count: 0,
277+ durationMs: 0,
278+ };
266279
267280 const flushUnsafe = () => {
268281 if (buffer.length === 0) {
269282 return;
270283 }
271284
272− const chunk = buffer.join("");
285+ const records = buffer;
286+ const chunk = records.join("");
273287 buffer = [];
288+ const startedAt = performance.now();
274289
275290 try {
276291 sink.write(chunk);
292+ pendingFlushStats = {
293+ logicalWriteBytes:
294+ pendingFlushStats.logicalWriteBytes + textEncoder.encode(chunk).byteLength,
295+ count: pendingFlushStats.count + records.length,
296+ durationMs: pendingFlushStats.durationMs + Math.max(0, performance.now() - startedAt),
297+ };
277298 } catch {
278− buffer.unshift(chunk);
299+ buffer.unshift(...records);
279300 }
280301 };
281302
282− const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false));
303+ const flush = Effect.sync(() => {
304+ flushUnsafe();
305+ const stats = pendingFlushStats;
306+ pendingFlushStats = {
307+ logicalWriteBytes: 0,
308+ count: 0,
309+ durationMs: 0,
310+ };
311+ return stats;
312+ }).pipe(
313+ Effect.flatMap((stats) =>
314+ stats.count > 0 && options.onFlush ? options.onFlush(stats).pipe(Effect.ignore) : Effect.void,
315+ ),
316+ Effect.withTracerEnabled(false),
317+ );
283318
284319 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
285320 yield* Effect.forkScoped(
scripts/build-desktop-artifact.test.ts+24−0
@@ -9,12 +9,15 @@ import { · 8 unmodified lines
99 createStageWorkspaceConfig,
1010 createStagePnpmConfig,
1111 DESKTOP_ASAR_UNPACK,
12+ DESKTOP_EXTRA_RESOURCES,
1213 resolveDesktopRuntimeDependencies,
1314 resolveFffNativeDependencies,
1415 resolveBuildOptions,
1516 resolveDesktopBuildIconAssets,
1617 resolveDesktopProductName,
1718 resolveDesktopUpdateChannel,
19+ resolveResourceMonitorRustTargets,
20+ resourceMonitorExecutableName,
1821 resolveGitHubPublishConfig,
1922 resolveMockUpdateServerPort,
2023 resolveMockUpdateServerUrl,
@@ -175,6 +178,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { · 154 unmodified lines
175178 assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]);
176179 });
177180
181+ it("stages the resource monitor as an external executable resource", () => {
182+ assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [
183+ {
184+ from: "apps/desktop/prod-resources/resource-monitor",
185+ to: "resource-monitor",
186+ },
187+ ]);
188+ assert.deepStrictEqual(resolveResourceMonitorRustTargets("mac", "universal"), [
189+ "aarch64-apple-darwin",
190+ "x86_64-apple-darwin",
191+ ]);
192+ assert.deepStrictEqual(resolveResourceMonitorRustTargets("linux", "x64"), [
193+ "x86_64-unknown-linux-gnu",
194+ ]);
195+ assert.deepStrictEqual(resolveResourceMonitorRustTargets("win", "arm64"), [
196+ "aarch64-pc-windows-msvc",
197+ ]);
198+ assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor");
199+ assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");
200+ });
201+
178202 it("promotes target fff binaries to direct staged dependencies", () => {
179203 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {
180204 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
scripts/build-desktop-artifact.ts+106−0
@@ -72,6 +72,26 @@ interface PlatformConfig { · 71 unmodified lines
7272 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;
7373 }
7474
75+export function resolveResourceMonitorRustTargets(
76+ platform: typeof BuildPlatform.Type,
77+ arch: typeof BuildArch.Type,
78+): ReadonlyArray<string> {
79+ if (platform === "mac") {
80+ if (arch === "universal") {
81+ return ["aarch64-apple-darwin", "x86_64-apple-darwin"];
82+ }
83+ return [arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"];
84+ }
85+ if (platform === "linux") {
86+ return [arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"];
87+ }
88+ return [arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"];
89+}
90+
91+export function resourceMonitorExecutableName(platform: typeof BuildPlatform.Type): string {
92+ return platform === "win" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
93+}
94+
7595 const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {
7696 mac: {
7797 cliFlag: "--mac",
@@ -292,6 +312,12 @@ interface StagePackageJson { · 214 unmodified lines
292312
293313 export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const;
294314 export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const;
315+export const DESKTOP_EXTRA_RESOURCES = [
316+ {
317+ from: "apps/desktop/prod-resources/resource-monitor",
318+ to: "resource-monitor",
319+ },
320+] as const;
295321
296322 export function resolveFffNativeDependencies(
297323 platform: typeof BuildPlatform.Type,
@@ -497,6 +523,78 @@ const runCommand = Effect.fn("runCommand")(function* ( · 199 unmodified lines
497523 }
498524 });
499525
526+const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: {
527+ readonly repoRoot: string;
528+ readonly stageResourcesDir: string;
529+ readonly platform: typeof BuildPlatform.Type;
530+ readonly arch: typeof BuildArch.Type;
531+ readonly verbose: boolean;
532+}) {
533+ const fs = yield* FileSystem.FileSystem;
534+ const path = yield* Path.Path;
535+ const manifestPath = path.join(input.repoRoot, "native/resource-monitor/Cargo.toml");
536+ const executableName = resourceMonitorExecutableName(input.platform);
537+ const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch);
538+ const builtBinaries: string[] = [];
539+
540+ for (const rustTarget of rustTargets) {
541+ const spawnCommand = yield* resolveSpawnCommand("cargo", [
542+ "build",
543+ "--locked",
544+ "--release",
545+ "--manifest-path",
546+ manifestPath,
547+ "--target",
548+ rustTarget,
549+ ]);
550+ yield* runCommand(
551+ ChildProcess.make(spawnCommand.command, spawnCommand.args, {
552+ cwd: input.repoRoot,
553+ shell: spawnCommand.shell,
554+ }),
555+ {
556+ label: `cargo build resource monitor (${rustTarget})`,
557+ verbose: input.verbose,
558+ },
559+ );
560+
561+ const binaryPath = path.join(
562+ input.repoRoot,
563+ "native/resource-monitor/target",
564+ rustTarget,
565+ "release",
566+ executableName,
567+ );
568+ if (!(yield* fs.exists(binaryPath))) {
569+ return yield* new BuildScriptError({
570+ message: `Resource monitor build did not produce ${binaryPath}.`,
571+ });
572+ }
573+ builtBinaries.push(binaryPath);
574+ }
575+
576+ const destinationDirectory = path.join(input.stageResourcesDir, "resource-monitor");
577+ const destinationPath = path.join(destinationDirectory, executableName);
578+ yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore);
579+ yield* fs.makeDirectory(destinationDirectory, { recursive: true });
580+
581+ if (builtBinaries.length === 1) {
582+ yield* fs.copyFile(builtBinaries[0]!, destinationPath);
583+ } else {
584+ yield* runCommand(
585+ ChildProcess.make("lipo", ["-create", ...builtBinaries, "-output", destinationPath]),
586+ {
587+ label: "lipo resource monitor universal binary",
588+ verbose: input.verbose,
589+ },
590+ );
591+ }
592+
593+ if (input.platform !== "win") {
594+ yield* fs.chmod(destinationPath, 0o755);
595+ }
596+});
597+
500598 function generateMacIconSet(
501599 sourcePng: string,
502600 targetIcns: string,
@@ -755,6 +853,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 252 unmodified lines
755853 directories: {
756854 buildResources: "apps/desktop/resources",
757855 },
856+ extraResources: DESKTOP_EXTRA_RESOURCES,
758857 };
759858 const updateChannel = resolveDesktopUpdateChannel(version);
760859 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
@@ -941,6 +1040,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 180 unmodified lines
9411040 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
9421041 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
9431042 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
1043+ yield* stageResourceMonitor({
1044+ repoRoot,
1045+ stageResourcesDir,
1046+ platform: options.platform,
1047+ arch: options.arch,
1048+ verbose: options.verbose,
1049+ });
9441050
9451051 yield* assertPlatformBuildResources(
9461052 options.platform,