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
22 cache: true22 cache: true
23 run-install: true23 run-install: true
24 24
25 - name: Setup Rust
26 uses: dtolnay/rust-toolchain@stable
27
25 - name: Ensure Electron runtime is installed28 - name: Ensure Electron runtime is installed
26 run: vp run --filter @t3tools/desktop ensure:electron29 run: vp run --filter @t3tools/desktop ensure:electron
27 30
@@ -31,6 +34,9 @@ jobs: · 3 unmodified lines
31 - name: Typecheck34 - name: Typecheck
32 run: vpr typecheck35 run: vpr typecheck
33 36
37 - name: Check resource monitor formatting
38 run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check
39
34 - name: Build desktop pipeline40 - name: Build desktop pipeline
35 run: vp run build:desktop41 run: vp run build:desktop
36 42
@@ -54,12 +60,18 @@ jobs: · 17 unmodified lines
54 cache: true60 cache: true
55 run-install: true61 run-install: true
56 62
63 - name: Setup Rust
64 uses: dtolnay/rust-toolchain@stable
65
57 - name: Ensure Electron runtime is installed66 - name: Ensure Electron runtime is installed
58 run: vp run --filter @t3tools/desktop ensure:electron67 run: vp run --filter @t3tools/desktop ensure:electron
59 68
60 - name: Test69 - name: Test
61 run: vp run test70 run: vp run test
62 71
72 - name: Test resource monitor
73 run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml
74
63 test_browser:75 test_browser:
64 name: Test Browser76 name: Test Browser
65 runs-on: blacksmith-8vcpu-ubuntu-240477 runs-on: blacksmith-8vcpu-ubuntu-2404
.github/workflows/release.yml+51−0
@@ -272,21 +272,29 @@ jobs: · 271 unmodified lines
272 platform: mac272 platform: mac
273 target: dmg273 target: dmg
274 arch: arm64274 arch: arm64
275 rust_target: aarch64-apple-darwin
276 resource_key: darwin-arm64
275 - label: macOS x64277 - label: macOS x64
276 runner: blacksmith-12vcpu-macos-26278 runner: blacksmith-12vcpu-macos-26
277 platform: mac279 platform: mac
278 target: dmg280 target: dmg
279 arch: x64281 arch: x64
282 rust_target: x86_64-apple-darwin
283 resource_key: darwin-x64
280 - label: Linux x64284 - label: Linux x64
281 runner: blacksmith-32vcpu-ubuntu-2404285 runner: blacksmith-32vcpu-ubuntu-2404
282 platform: linux286 platform: linux
283 target: AppImage287 target: AppImage
284 arch: x64288 arch: x64
289 rust_target: x86_64-unknown-linux-gnu
290 resource_key: linux-x64
285 - label: Windows x64291 - label: Windows x64
286 runner: blacksmith-32vcpu-windows-2025292 runner: blacksmith-32vcpu-windows-2025
287 platform: win293 platform: win
288 target: nsis294 target: nsis
289 arch: x64295 arch: x64
296 rust_target: x86_64-pc-windows-msvc
297 resource_key: win32-x64
290 # - label: Windows arm64298 # - label: Windows arm64
291 # runner: windows-11-arm299 # runner: windows-11-arm
292 # platform: win300 # platform: win
@@ -306,6 +314,11 @@ jobs: · 13 unmodified lines
306 cache: true314 cache: true
307 run-install: true315 run-install: true
308 316
317 - name: Setup Rust
318 uses: dtolnay/rust-toolchain@stable
319 with:
320 targets: ${{ matrix.rust_target }}
321
309 - name: Download relay client tracing config322 - name: Download relay client tracing config
310 uses: actions/download-artifact@v8323 uses: actions/download-artifact@v8
311 with:324 with:
@@ -518,6 +531,19 @@ jobs: · 206 unmodified lines
518 # done531 # done
519 # fi532 # fi
520 533
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
521 - name: Upload build artifacts547 - name: Upload build artifacts
522 uses: actions/upload-artifact@v7548 uses: actions/upload-artifact@v7
523 with:549 with:
@@ -525,6 +551,13 @@ jobs: · 1 unmodified line
525 path: release-publish/*551 path: release-publish/*
526 if-no-files-found: error552 if-no-files-found: error
527 553
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
528 publish_cli:561 publish_cli:
529 name: Publish CLI to npm562 name: Publish CLI to npm
530 needs: [preflight, relay_public_config, build]563 needs: [preflight, relay_public_config, build]
@@ -579,6 +612,24 @@ jobs: · 48 unmodified lines
579 - name: Build CLI package612 - name: Build CLI package
580 run: vp run --filter t3 build613 run: vp run --filter t3 build
581 614
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
582 - name: Publish CLI package633 - name: Publish CLI package
583 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose634 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
584 635
.gitignore+1−0
@@ -27,6 +27,7 @@ squashfs-root/ · 26 unmodified lines
27.gstack/27.gstack/
28dist-electron/28dist-electron/
29.electron-runtime/29.electron-runtime/
30native/**/target/
30node_modules/31node_modules/
31.alchemy/32.alchemy/
32*.log33*.log
apps/desktop/src/app/DesktopAppIdentity.test.ts+1−0
@@ -54,6 +54,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => · 53 unmodified lines
54 }),54 }),
55 setAppUserModelId: () => Effect.void,55 setAppUserModelId: () => Effect.void,
56 requestSingleInstanceLock: Effect.succeed(true),56 requestSingleInstanceLock: Effect.succeed(true),
57 getAppMetrics: Effect.succeed([]),
57 isDefaultProtocolClient: () => Effect.succeed(false),58 isDefaultProtocolClient: () => Effect.succeed(false),
58 setAsDefaultProtocolClient: () => Effect.succeed(true),59 setAsDefaultProtocolClient: () => Effect.succeed(true),
59 setDesktopName: () => Effect.void,60 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
54 setAboutPanelOptions: () => Effect.void,54 setAboutPanelOptions: () => Effect.void,
55 setAppUserModelId: () => Effect.void,55 setAppUserModelId: () => Effect.void,
56 requestSingleInstanceLock: Effect.succeed(true),56 requestSingleInstanceLock: Effect.succeed(true),
57 getAppMetrics: Effect.succeed([]),
57 isDefaultProtocolClient: () => Effect.succeed(false),58 isDefaultProtocolClient: () => Effect.succeed(false),
58 setAsDefaultProtocolClient: (protocol, path, args) =>59 setAsDefaultProtocolClient: (protocol, path, args) =>
59 Effect.sync(() => {60 Effect.sync(() => {
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+36−1
@@ -41,6 +41,7 @@ function makeEnvironmentLayer( · 40 unmodified lines
41 options?: {41 options?: {
42 readonly isPackaged?: boolean;42 readonly isPackaged?: boolean;
43 readonly devServerUrl?: string;43 readonly devServerUrl?: string;
44 readonly resourcesPath?: string;
44 },45 },
45) {46) {
46 return DesktopEnvironment.layer({47 return DesktopEnvironment.layer({
@@ -51,7 +52,7 @@ function makeEnvironmentLayer( · 4 unmodified lines
51 appVersion: "1.2.3",52 appVersion: "1.2.3",
52 appPath: "/repo",53 appPath: "/repo",
53 isPackaged: options?.isPackaged ?? true,54 isPackaged: options?.isPackaged ?? true,
54 resourcesPath: "/missing/resources",55 resourcesPath: options?.resourcesPath ?? "/missing/resources",
55 runningUnderArm64Translation: false,56 runningUnderArm64Translation: false,
56 }).pipe(57 }).pipe(
57 Layer.provide(58 Layer.provide(
@@ -192,4 +193,38 @@ describe("DesktopBackendConfiguration", () => { · 134 unmodified lines
192 );193 );
193 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),194 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
194 );195 );
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 );
195});230});
apps/desktop/src/backend/DesktopBackendConfiguration.ts+47−0
@@ -56,6 +56,42 @@ const { logWarning: logBackendConfigurationWarning } = DesktopObservability.make · 55 unmodified lines
56 "desktop-backend-configuration",56 "desktop-backend-configuration",
57);57);
58 58
59function resourceMonitorBinaryName(platform: NodeJS.Platform): string {
60 return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
61}
62
63const 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
59const readPersistedBackendObservabilitySettings: Effect.Effect<95const readPersistedBackendObservabilitySettings: Effect.Effect<
60 BackendObservabilitySettings,96 BackendObservabilitySettings,
61 never,97 never,
@@ -89,6 +125,7 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 27 unmodified lines
89 function* (input: {125 function* (input: {
90 readonly bootstrapToken: string;126 readonly bootstrapToken: string;
91 readonly observabilitySettings: BackendObservabilitySettings;127 readonly observabilitySettings: BackendObservabilitySettings;
128 readonly resourceMonitorPath: Option.Option<string>;
92 }): Effect.fn.Return<129 }): Effect.fn.Return<
93 DesktopBackendManager.DesktopBackendStartConfig,130 DesktopBackendManager.DesktopBackendStartConfig,
94 never,131 never,
@@ -115,6 +152,11 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 20 unmodified lines
115 desktopBootstrapToken: input.bootstrapToken,152 desktopBootstrapToken: input.bootstrapToken,
116 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,153 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
117 tailscaleServePort: backendExposure.tailscaleServePort,154 tailscaleServePort: backendExposure.tailscaleServePort,
155 desktopTelemetryFd: 4,
156 ...Option.match(input.resourceMonitorPath, {
157 onNone: () => ({}),
158 onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
159 }),
118 ...Option.match(input.observabilitySettings.otlpTracesUrl, {160 ...Option.match(input.observabilitySettings.otlpTracesUrl, {
119 onNone: () => ({}),161 onNone: () => ({}),
120 onSome: (otlpTracesUrl) => ({ otlpTracesUrl }),162 onSome: (otlpTracesUrl) => ({ otlpTracesUrl }),
@@ -156,9 +198,14 @@ export const layer = Layer.effect( · 35 unmodified lines
156 Effect.provideService(FileSystem.FileSystem, fileSystem),198 Effect.provideService(FileSystem.FileSystem, fileSystem),
157 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),199 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
158 );200 );
201 const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe(
202 Effect.provideService(FileSystem.FileSystem, fileSystem),
203 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
204 );
159 return yield* resolveBackendStartConfig({205 return yield* resolveBackendStartConfig({
160 bootstrapToken,206 bootstrapToken,
161 observabilitySettings,207 observabilitySettings,
208 resourceMonitorPath,
162 }).pipe(209 }).pipe(
163 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),210 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
164 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),211 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
23import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";23import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
24import * as DesktopObservability from "../app/DesktopObservability.ts";24import * as DesktopObservability from "../app/DesktopObservability.ts";
25import * as DesktopState from "../app/DesktopState.ts";25import * as DesktopState from "../app/DesktopState.ts";
26import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
26import * as DesktopWindow from "../window/DesktopWindow.ts";27import * as DesktopWindow from "../window/DesktopWindow.ts";
27 28
28const decodeDesktopBackendBootstrap = Schema.decodeEffect(29const decodeDesktopBackendBootstrap = Schema.decodeEffect(
@@ -120,6 +121,11 @@ function makeManagerLayer(input: { · 91 unmodified lines
120 }),121 }),
121 input.spawnerLayer,122 input.spawnerLayer,
122 input.httpClientLayer ?? healthyHttpClientLayer,123 input.httpClientLayer ?? healthyHttpClientLayer,
124 Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {
125 latest: Effect.succeed(Option.none()),
126 changes: Stream.empty,
127 encoded: Stream.empty,
128 }),
123 input.desktopState129 input.desktopState
124 ? Layer.succeed(DesktopState.DesktopState, input.desktopState)130 ? Layer.succeed(DesktopState.DesktopState, input.desktopState)
125 : DesktopState.layer,131 : DesktopState.layer,
apps/desktop/src/backend/DesktopBackendManager.ts+8−0
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
27import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";27import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
28import * as DesktopObservability from "../app/DesktopObservability.ts";28import * as DesktopObservability from "../app/DesktopObservability.ts";
29import * as DesktopState from "../app/DesktopState.ts";29import * as DesktopState from "../app/DesktopState.ts";
30import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
30import * as DesktopWindow from "../window/DesktopWindow.ts";31import * as DesktopWindow from "../window/DesktopWindow.ts";
31 32
32const INITIAL_RESTART_DELAY = Duration.millis(500);33const INITIAL_RESTART_DELAY = Duration.millis(500);
@@ -88,6 +89,7 @@ class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnErro · 55 unmodified lines
88type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;89type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
89 90
90interface RunBackendProcessOptions extends DesktopBackendStartConfig {91interface RunBackendProcessOptions extends DesktopBackendStartConfig {
92 readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
91 readonly readinessTimeout?: Duration.Duration;93 readonly readinessTimeout?: Duration.Duration;
92 readonly onStarted?: (pid: number) => Effect.Effect<void>;94 readonly onStarted?: (pid: number) => Effect.Effect<void>;
93 readonly onReady?: () => Effect.Effect<void>;95 readonly onReady?: () => Effect.Effect<void>;
@@ -252,6 +254,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 158 unmodified lines
252 type: "input",254 type: "input",
253 stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)),255 stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)),
254 },256 },
257 fd4: {
258 type: "input",
259 stream: options.desktopTelemetryStream,
260 },
255 },261 },
256 },262 },
257 );263 );
@@ -283,6 +289,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 25 unmodified lines
283 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;289 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
284 const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog;290 const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog;
285 const desktopState = yield* DesktopState.DesktopState;291 const desktopState = yield* DesktopState.DesktopState;
292 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
286 const desktopWindow = yield* DesktopWindow.DesktopWindow;293 const desktopWindow = yield* DesktopWindow.DesktopWindow;
287 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;294 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
288 const httpClient = yield* HttpClient.HttpClient;295 const httpClient = yield* HttpClient.HttpClient;
@@ -436,6 +443,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 147 unmodified lines
436 443
437 const program = runBackendProcess({444 const program = runBackendProcess({
438 ...config.value,445 ...config.value,
446 desktopTelemetryStream: desktopTelemetryPublisher.encoded,
439 onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) {447 onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) {
440 yield* updateActiveRun(runId, (run) => ({448 yield* updateActiveRun(runId, (run) => ({
441 ...run,449 ...run,
apps/desktop/src/electron/ElectronApp.ts+2−0
@@ -30,6 +30,7 @@ export interface ElectronAppShape { · 29 unmodified lines
30 ) => Effect.Effect<void>;30 ) => Effect.Effect<void>;
31 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;31 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
32 readonly requestSingleInstanceLock: Effect.Effect<boolean>;32 readonly requestSingleInstanceLock: Effect.Effect<boolean>;
33 readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;
33 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;34 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
34 readonly setAsDefaultProtocolClient: (35 readonly setAsDefaultProtocolClient: (
35 protocol: string,36 protocol: string,
@@ -101,6 +102,7 @@ const make = ElectronApp.of({ · 65 unmodified lines
101 Electron.app.setAppUserModelId(id);102 Electron.app.setAppUserModelId(id);
102 }),103 }),
103 requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),104 requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),
105 getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
104 isDefaultProtocolClient: (protocol) =>106 isDefaultProtocolClient: (protocol) =>
105 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),107 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
106 setAsDefaultProtocolClient: (protocol, path, args) =>108 setAsDefaultProtocolClient: (protocol, path, args) =>
apps/desktop/src/electron/ElectronPowerMonitor.ts+89−0
@@ -0,0 +1,89 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Scope from "effect/Scope";
5
6import * as Electron from "electron";
7
8export type ElectronThermalState = ReturnType<Electron.PowerMonitor["getCurrentThermalState"]>;
9export type ElectronIdleState = ReturnType<Electron.PowerMonitor["getSystemIdleState"]>;
10
11export 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
28export class ElectronPowerMonitor extends Context.Service<
29 ElectronPowerMonitor,
30 ElectronPowerMonitorShape
31>()("@t3tools/desktop/electron/ElectronPowerMonitor") {}
32
33const 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
44const 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
61const 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
78export 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
89export 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
19import * as ElectronApp from "./electron/ElectronApp.ts";19import * as ElectronApp from "./electron/ElectronApp.ts";
20import * as ElectronDialog from "./electron/ElectronDialog.ts";20import * as ElectronDialog from "./electron/ElectronDialog.ts";
21import * as ElectronMenu from "./electron/ElectronMenu.ts";21import * as ElectronMenu from "./electron/ElectronMenu.ts";
22import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts";
22import * as ElectronProtocol from "./electron/ElectronProtocol.ts";23import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
23import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts";24import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts";
24import * as ElectronShell from "./electron/ElectronShell.ts";25import * as ElectronShell from "./electron/ElectronShell.ts";
@@ -44,6 +45,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; · 19 unmodified lines
44import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";45import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
45import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";46import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
46import * as DesktopState from "./app/DesktopState.ts";47import * as DesktopState from "./app/DesktopState.ts";
48import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
47import * as DesktopUpdates from "./updates/DesktopUpdates.ts";49import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
48import * as PreviewBrowserSession from "./preview/BrowserSession.ts";50import * as PreviewBrowserSession from "./preview/BrowserSession.ts";
49import * as PreviewManager from "./preview/Manager.ts";51import * as PreviewManager from "./preview/Manager.ts";
@@ -103,6 +105,7 @@ const electronLayer = Layer.mergeAll( · 53 unmodified lines
103 ElectronApp.layer,105 ElectronApp.layer,
104 ElectronDialog.layer,106 ElectronDialog.layer,
105 ElectronMenu.layer,107 ElectronMenu.layer,
108 ElectronPowerMonitor.layer,
106 ElectronProtocol.layer,109 ElectronProtocol.layer,
107 DesktopSecretStorage.layer,110 DesktopSecretStorage.layer,
108 ElectronShell.layer,111 ElectronShell.layer,
@@ -145,6 +148,7 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( · 36 unmodified lines
145const desktopBackendLayer = DesktopBackendManager.layer.pipe(148const desktopBackendLayer = DesktopBackendManager.layer.pipe(
146 Layer.provideMerge(DesktopAppIdentity.layer),149 Layer.provideMerge(DesktopAppIdentity.layer),
147 Layer.provideMerge(DesktopBackendConfiguration.layer),150 Layer.provideMerge(DesktopBackendConfiguration.layer),
151 Layer.provideMerge(DesktopTelemetryPublisher.layer),
148 Layer.provideMerge(desktopWindowLayer),152 Layer.provideMerge(desktopWindowLayer),
149);153);
150 154
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+122−0
@@ -0,0 +1,122 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Ref from "effect/Ref";
8import * as Stream from "effect/Stream";
9import * as TestClock from "effect/testing/TestClock";
10
11import type * as Electron from "electron";
12
13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
15import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts";
16
17function 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
40describe("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 @@
1import {
2 DesktopHostTelemetryMessage,
3 type DesktopHostTelemetrySnapshot,
4 type HostPowerSnapshot,
5} from "@t3tools/contracts";
6import * as Context from "effect/Context";
7import * as DateTime from "effect/DateTime";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as PubSub from "effect/PubSub";
13import * as Queue from "effect/Queue";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";
17
18import * as ElectronApp from "../electron/ElectronApp.ts";
19import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
20
21const SAMPLE_INTERVAL = Duration.seconds(1);
22const IDLE_THRESHOLD_SECONDS = 60;
23const encodeMessage = Schema.encodeSync(Schema.fromJsonString(DesktopHostTelemetryMessage));
24const textEncoder = new TextEncoder();
25
26type 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
33interface 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
41export interface DesktopTelemetryPublisherShape {
42 readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;
43 readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
44 readonly encoded: Stream.Stream<Uint8Array>;
45}
46
47export class DesktopTelemetryPublisher extends Context.Service<
48 DesktopTelemetryPublisher,
49 DesktopTelemetryPublisherShape
50>()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {}
51
52function booleanState(value: boolean): HostPowerSnapshot["onBattery"] {
53 return value ? "true" : "false";
54}
55
56function 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
68function 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
83export 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
215export 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
40 setAboutPanelOptions: () => Effect.void,40 setAboutPanelOptions: () => Effect.void,
41 setAppUserModelId: () => Effect.void,41 setAppUserModelId: () => Effect.void,
42 requestSingleInstanceLock: Effect.succeed(true),42 requestSingleInstanceLock: Effect.succeed(true),
43 getAppMetrics: Effect.succeed([]),
43 isDefaultProtocolClient: () => Effect.succeed(false),44 isDefaultProtocolClient: () => Effect.succeed(false),
44 setAsDefaultProtocolClient: () => Effect.succeed(true),45 setAsDefaultProtocolClient: () => Effect.succeed(true),
45 setDesktopName: () => Effect.void,46 setDesktopName: () => Effect.void,
apps/server/src/background/HostPowerMonitor.test.ts+44−0
@@ -0,0 +1,44 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Option from "effect/Option";
6import * as Stream from "effect/Stream";
7
8import * as HostPowerMonitor from "./HostPowerMonitor.ts";
9
10describe("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 @@
1import {1import type { HostPowerSnapshot } from "@t3tools/contracts";
2 type BackgroundBooleanState,
3 type HostPowerSnapshot,
4 type HostPowerThermalState,
5} from "@t3tools/contracts";
6import {
7 getBackgroundActivityPresetSettings,
8 resolveServerBackgroundActivitySettings,
9} from "@t3tools/shared/backgroundActivitySettings";
10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
11import * as Context from "effect/Context";2import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
13import * as Duration from "effect/Duration";
14import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
16import * as PubSub from "effect/PubSub";6import * as PubSub from "effect/PubSub";
17import * as Ref from "effect/Ref";7import * as Ref from "effect/Ref";
18import * as Stream from "effect/Stream";8import * as Stream from "effect/Stream";
19 9
20import * as ProcessRunner from "../processRunner.ts";10import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
21import { ServerSettingsService } from "../serverSettings.ts";
22 11
23export interface HostPowerMonitorShape {12export interface HostPowerMonitorShape {
24 readonly snapshot: Effect.Effect<HostPowerSnapshot>;13 readonly snapshot: Effect.Effect<HostPowerSnapshot>;
@@ -31,8 +20,6 @@ export class HostPowerMonitor extends Context.Service<HostPowerMonitor, HostPowe · 6 unmodified lines
31 "t3/background/HostPowerMonitor",20 "t3/background/HostPowerMonitor",
32) {}21) {}
33 22
34const COMMAND_TIMEOUT = Duration.seconds(3);
35
36export const makeUnknownSnapshot = (23export const makeUnknownSnapshot = (
37 source: HostPowerSnapshot["source"],24 source: HostPowerSnapshot["source"],
38 updatedAt: HostPowerSnapshot["updatedAt"],25 updatedAt: HostPowerSnapshot["updatedAt"],
@@ -49,163 +36,52 @@ export const makeUnknownSnapshot = ( · 10 unmodified lines
49 updatedAt,36 updatedAt,
50});37});
51 38
52function boolState(value: boolean | null): BackgroundBooleanState {39function samePowerState(left: HostPowerSnapshot, right: HostPowerSnapshot): boolean {
53 if (value === null) return "unknown";40 return (
54 return value ? "true" : "false";41 left.source === right.source &&
55}42 left.idle === right.idle &&
56 43 left.locked === right.locked &&
57function parseIdleSeconds(ioregOutput: string): number | null {44 left.suspended === right.suspended &&
58 const match = /"HIDIdleTime"\s*=\s*(\d+)/u.exec(ioregOutput);45 left.onBattery === right.onBattery &&
59 if (!match) return null;46 left.lowPowerMode === right.lowPowerMode &&
60 const nanoseconds = Number(match[1]);47 left.thermalState === right.thermalState &&
61 return Number.isFinite(nanoseconds) ? Math.floor(nanoseconds / 1_000_000_000) : null;48 left.stale === right.stale
62}
63
64function 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
70function 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
76function 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
82function 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
99const 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" },
109 );49 );
110 50}
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});
130 51
131export const make = Effect.fn("background.hostPower.make")(function* (52export const make = Effect.fn("background.hostPower.make")(function* (
132 initialSource: HostPowerSnapshot["source"] = "unknown",53 initialSource: HostPowerSnapshot["source"] = "unknown",
133) {54) {
134 const initial = makeUnknownSnapshot(initialSource, yield* DateTime.now);55 const initial = makeUnknownSnapshot(initialSource, yield* DateTime.now);
135 const latestRef = yield* Ref.make(initial);56 const latestRef = yield* Ref.make(initial);
136 const demandActiveRef = yield* Ref.make(false);
137 const changes = yield* PubSub.sliding<HostPowerSnapshot>(1);57 const changes = yield* PubSub.sliding<HostPowerSnapshot>(1);
138 58
139 const report: HostPowerMonitorShape["report"] = (snapshot) =>59 const report: HostPowerMonitorShape["report"] = (snapshot) =>
140 Ref.set(latestRef, snapshot).pipe(60 Ref.modify(latestRef, (current) => [!samePowerState(current, snapshot), snapshot]).pipe(
141 Effect.andThen(PubSub.publish(changes, snapshot)),61 Effect.flatMap((changed) => (changed ? PubSub.publish(changes, snapshot) : Effect.void)),
142 Effect.asVoid,62 Effect.asVoid,
143 );63 );
144 64
145 return HostPowerMonitor.of({65 return HostPowerMonitor.of({
146 snapshot: Ref.get(latestRef),66 snapshot: Ref.get(latestRef),
147 report,67 report,
148 setDemandActive: (active) => Ref.set(demandActiveRef, active),68 setDemandActive: () => Effect.void,
149 streamChanges: Stream.fromPubSub(changes),69 streamChanges: Stream.fromPubSub(changes),
150 });70 });
151});71});
152 72
153const unknownLayer = Layer.effect(HostPowerMonitor, make("unknown"));73export const layer = Layer.effect(
154const linuxLayer = Layer.effect(HostPowerMonitor, make("node-linux"));
155const windowsLayer = Layer.effect(HostPowerMonitor, make("node-windows"));
156
157const macShellLayer = Layer.effect(
158 HostPowerMonitor,74 HostPowerMonitor,
159 Effect.gen(function* () {75 Effect.gen(function* () {
160 const serverSettings = yield* ServerSettingsService;76 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
161 const monitor = yield* make("node-macos-shell");77 const initial = yield* telemetry.latest;
162 const demandActiveRef = yield* Ref.make(true);78 const monitor = yield* make(initial.power.source);
163 const setDemandActive: HostPowerMonitorShape["setDemandActive"] = (active) =>79 yield* monitor.report(initial.power);
164 Ref.set(demandActiveRef, active);80 yield* telemetry.changes.pipe(
165 const getPollInterval = Effect.gen(function* () {81 Stream.map((snapshot) => snapshot.power),
166 const demandActive = yield* Ref.get(demandActiveRef);82 Stream.runForEach(monitor.report),
167 const settings = yield* serverSettings.getSettings.pipe(83 Effect.forkScoped,
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 }),
184 );84 );
185 yield* Effect.forever(85 return monitor;
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
197export 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 }
210 }),86 }),
211);87);
apps/server/src/cli/config.ts+4−0
@@ -298,6 +298,8 @@ export const resolveServerConfig = ( · 297 unmodified lines
298 () => mode === "desktop",298 () => mode === "desktop",
299 );299 );
300 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;300 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
301 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
302 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
301 const autoBootstrapProjectFromCwd = Option.getOrElse(303 const autoBootstrapProjectFromCwd = Option.getOrElse(
302 resolveOptionPrecedence(304 resolveOptionPrecedence(
303 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),305 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
@@ -370,6 +372,8 @@ export const resolveServerConfig = ( · 66 unmodified lines
370 noBrowser,372 noBrowser,
371 startupPresentation,373 startupPresentation,
372 desktopBootstrapToken,374 desktopBootstrapToken,
375 desktopTelemetryFd,
376 resourceMonitorPath,
373 autoBootstrapProjectFromCwd,377 autoBootstrapProjectFromCwd,
374 logWebSocketEvents,378 logWebSocketEvents,
375 tailscaleServeEnabled,379 tailscaleServeEnabled,
apps/server/src/config.ts+4−0
@@ -69,6 +69,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { · 68 unmodified lines
69 readonly noBrowser: boolean;69 readonly noBrowser: boolean;
70 readonly startupPresentation: StartupPresentation;70 readonly startupPresentation: StartupPresentation;
71 readonly desktopBootstrapToken: string | undefined;71 readonly desktopBootstrapToken: string | undefined;
72 readonly desktopTelemetryFd?: number | undefined;
73 readonly resourceMonitorPath?: string | undefined;
72 readonly autoBootstrapProjectFromCwd: boolean;74 readonly autoBootstrapProjectFromCwd: boolean;
73 readonly logWebSocketEvents: boolean;75 readonly logWebSocketEvents: boolean;
74 readonly tailscaleServeEnabled: boolean;76 readonly tailscaleServeEnabled: boolean;
@@ -171,6 +173,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap · 96 unmodified lines
171 port: 0,173 port: 0,
172 host: undefined,174 host: undefined,
173 desktopBootstrapToken: undefined,175 desktopBootstrapToken: undefined,
176 desktopTelemetryFd: undefined,
177 resourceMonitorPath: undefined,
174 staticDir: undefined,178 staticDir: undefined,
175 devUrl,179 devUrl,
176 noBrowser: false,180 noBrowser: false,
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+172−206
@@ -1,251 +1,217 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type {
3 DesktopHostTelemetrySnapshot,
4 ResourceMonitorSnapshotEvent,
5} from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";9import * as Option from "effect/Option";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import { ChildProcessSpawner } from "effect/unstable/process";
9 10
11import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
12import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
13import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts";
14import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
10import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";15import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
11 16
12const encoder = new TextEncoder();17function 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}
13 32
14function mockHandle(result: {33function makeTelemetryLayer(
15 readonly stdout?: string;34 snapshot: ResourceMonitorSnapshotEvent,
16 readonly stderr?: string;35 desktopSnapshot?: DesktopHostTelemetrySnapshot,
17 readonly code?: number;36) {
18}) {37 const nativeLayer = NativeTelemetryClient.layerTest({
19 return ChildProcessSpawner.makeHandle({38 sampleNow: Effect.succeed(snapshot),
20 pid: ChildProcessSpawner.ProcessId(1),39 health: Effect.succeed({
21 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),40 status: "healthy",
22 isRunning: Effect.succeed(false),41 hello: Option.none(),
23 kill: () => Effect.void,42 lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)),
24 unref: Effect.succeed(Effect.void),43 lastError: Option.none(),
25 stdin: Sink.drain,44 restartCount: 0,
26 stdout: Stream.make(encoder.encode(result.stdout ?? "")),45 }),
27 stderr: Stream.make(encoder.encode(result.stderr ?? "")),
28 all: Stream.empty,
29 getInputFd: () => Sink.drain,
30 getOutputFd: () => Stream.empty,
31 });46 });
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 );
32}60}
33 61
34describe("ProcessDiagnostics", () => {62describe("ProcessDiagnostics", () => {
35 it.effect("parses POSIX ps rows with full commands", () =>63 it.effect("projects live process data from resource telemetry", () =>
36 Effect.sync(() => {64 Effect.gen(function* () {
37 const rows = ProcessDiagnostics.parsePosixProcessRows(65 const snapshot = makeNativeSnapshot([
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([
45 {66 {
46 pid: 10,67 pid: process.pid,
47 ppid: 1,68 ppid: 1,
48 pgid: 10,69 startTimeMs: 1_000,
49 status: "Ss",70 runTimeMs: 60_000,
71 name: "node",
72 command: "t3 server",
73 status: "Running",
50 cpuPercent: 0,74 cpuPercent: 0,
51 rssBytes: 1024 * 1024,75 cpuTimeMs: 100,
52 elapsed: "01:02.03",76 residentBytes: 1_024,
53 command: "/usr/bin/node server.js",77 virtualBytes: 2_048,
78 ioReadBytes: 100,
79 ioWriteBytes: 200,
80 ioSemantics: "storage",
54 },81 },
55 {82 {
56 pid: 11,83 pid: 4_242,
57 ppid: 10,84 ppid: process.pid,
58 pgid: 10,85 startTimeMs: 2_000,
59 status: "S+",86 runTimeMs: 4_000,
60 cpuPercent: 12.5,87 name: "agent",
61 rssBytes: 20480 * 1024,88 command: "codex app-server",
62 elapsed: "00:04",89 status: "Running",
63 command: "codex app-server --config /tmp/one two",90 cpuPercent: 1.5,
91 cpuTimeMs: 60,
92 residentBytes: 2_048,
93 virtualBytes: 4_096,
94 ioReadBytes: 300,
95 ioWriteBytes: 400,
96 ioSemantics: "storage",
64 },97 },
65 ]);98 ]);
66 }),99 const telemetryLayer = makeTelemetryLayer(snapshot);
67 );100 const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer));
68 101
69 it.effect("aggregates only descendants of the server process", () =>102 const diagnostics = yield* Effect.gen(function* () {
70 Effect.sync(() => {103 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
71 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({104 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
72 serverPid: 100,105 yield* telemetry.refresh;
73 readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),106 return yield* processDiagnostics.read;
74 rows: [107 }).pipe(Effect.provide(layer));
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 });
178 108
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);
180 }),113 }),
181 );114 );
182 115
183 it.effect("queries processes through the ChildProcessSpawner service", () =>116 it.effect("rejects stale process identities before signaling", () =>
184 Effect.gen(function* () {117 Effect.gen(function* () {
185 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =118 const snapshot = makeNativeSnapshot([]);
186 [];119 const telemetryLayer = makeTelemetryLayer(snapshot);
187 const spawnerLayer = Layer.succeed(120 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
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));
206 121
207 const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(122 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
208 Effect.flatMap((pd) => pd.read),123 Effect.flatMap((processDiagnostics) =>
124 processDiagnostics.signal({
125 pid: 4_242,
126 startTimeMs: 2_000,
127 signal: "SIGINT",
128 }),
129 ),
209 Effect.provide(layer),130 Effect.provide(layer),
210 );131 );
211 132
212 expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);133 expect(result).toEqual({
213 expect(commands).toEqual([134 pid: 4242,
214 {135 signal: "SIGINT",
215 command: "ps",136 signaled: false,
216 args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],137 message: Option.some("Process 4242 no longer matches the selected process identity."),
217 },138 });
218 ]);
219 }),139 }),
220 );140 );
221 141
222 it.effect("does not allow signaling the diagnostics query process", () =>142 it.effect("rejects Electron processes as signal targets", () =>
223 Effect.gen(function* () {143 Effect.gen(function* () {
224 const spawnerLayer = Layer.succeed(144 const sampledAtUnixMs = DateTime.toEpochMillis(
225 ChildProcessSpawner.ChildProcessSpawner,145 DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
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 ),
236 );146 );
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));
238 198
239 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(199 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 ),
241 Effect.provide(layer),207 Effect.provide(layer),
242 );208 );
243 209
244 expect(result).toEqual({210 expect(result).toEqual({
245 pid: 4242,211 pid: 4_242,
246 signal: "SIGINT",212 signal: "SIGKILL",
247 signaled: false,213 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."),
249 });215 });
250 }),216 }),
251 );217 );
apps/server/src/diagnostics/ProcessDiagnostics.ts+137−413
@@ -1,40 +1,23 @@
1import type {1import type {
2 ResourceTelemetryProcessCategory,
2 ServerProcessDiagnosticsEntry,3 ServerProcessDiagnosticsEntry,
3 ServerProcessDiagnosticsResult,4 ServerProcessDiagnosticsResult,
4 ServerProcessSignal,5 ServerProcessSignal,
5 ServerSignalProcessResult,6 ServerSignalProcessResult,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as Context from "effect/Context";8import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";11import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
16 13
17import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";14import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
18
19export 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
30const PROCESS_QUERY_TIMEOUT_MS = 1_000;
31const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
32const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
33 15
34export interface ProcessDiagnosticsShape {16export interface ProcessDiagnosticsShape {
35 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;17 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
36 readonly signal: (input: {18 readonly signal: (input: {
37 readonly pid: number;19 readonly pid: number;
20 readonly startTimeMs: number;
38 readonly signal: ServerProcessSignal;21 readonly signal: ServerProcessSignal;
39 }) => Effect.Effect<ServerSignalProcessResult>;22 }) => Effect.Effect<ServerSignalProcessResult>;
40}23}
@@ -44,420 +27,161 @@ export class ProcessDiagnostics extends Context.Service< · 3 unmodified lines
44 ProcessDiagnosticsShape27 ProcessDiagnosticsShape
45>()("t3/diagnostics/ProcessDiagnostics") {}28>()("t3/diagnostics/ProcessDiagnostics") {}
46 29
47class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnosticsError>()(30export class ProcessIdentityChanged extends Schema.TaggedErrorClass<ProcessIdentityChanged>()(
48 "ProcessDiagnosticsError",31 "ProcessIdentityChanged",
49 {32 {
50 message: Schema.String,33 pid: Schema.Number,
51 cause: Schema.optional(Schema.Defect()),34 startTimeMs: Schema.Number,
52 },35 },
53) {}36) {
54const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);37 override get message(): string {
55 38 return `Process ${this.pid} no longer matches start time ${this.startTimeMs}.`;
56function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError {
57 return new ProcessDiagnosticsError({
58 message,
59 ...(cause === undefined ? {} : { cause }),
60 });
61}
62
63function parsePositiveInt(value: string): number | null {
64 const parsed = Number.parseInt(value, 10);
65 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
66}
67
68function parseNonNegativeInt(value: string): number | null {
69 const parsed = Number.parseInt(value, 10);
70 return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
71}
72
73function parseNumber(value: string): number | null {
74 const parsed = Number.parseFloat(value);
75 return Number.isFinite(parsed) ? parsed : null;
76}
77
78export 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 });
138 }39 }
139
140 return rows;
141}
142
143function 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 };
174}40}
175 41
176function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {42export class ProcessSignalFailed extends Schema.TaggedErrorClass<ProcessSignalFailed>()(
177 if (output.trim().length === 0) return [];43 "ProcessSignalFailed",
178 try {44 {
179 const parsed = JSON.parse(output) as unknown;45 pid: Schema.Number,
180 const records = Array.isArray(parsed) ? parsed : [parsed];46 signal: Schema.String,
181 return records.flatMap((record) => {47 cause: Schema.Defect(),
182 const row = normalizeWindowsProcessRow(record);48 },
183 return row ? [row] : [];49) {
184 });50 override get message(): string {
185 } catch {51 return `Failed to signal process ${this.pid} with ${this.signal}.`;
186 return [];
187 }52 }
188}53}
189 54
190export function buildDescendantEntries(55export type ProcessDiagnosticsError = ProcessIdentityChanged | ProcessSignalFailed;
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 }
230 56
231 return entries;57function 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")}`;
232}65}
233 66
234export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean {67function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean {
235 if (row.ppid !== serverPid) return false;
236
237 const command = row.command.trim();
238 return (68 return (
239 /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) ||69 category === "server-child" || category === "provider-root" || category === "terminal-root"
240 (/\bpowershell(?:\.exe)?\b/i.test(command) &&
241 /\bGet-CimInstance\s+Win32_Process\b/i.test(command))
242 );
243}
244
245function 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
268interface ProcessOutput {
269 readonly exitCode: number;
270 readonly stdout: string;
271 readonly stderr: string;
272}
273
274const 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
330function 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
348function 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
376export const readProcessRows = Effect.gen(function* () {
377 const platform = yield* HostProcessPlatform;
378 return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();
379});
380
381export 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
389function 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 }),
408 );70 );
409}71}
410 72
411export const make = Effect.fn("makeProcessDiagnostics")(function* () {73export const make = Effect.fn("makeProcessDiagnostics")(function* () {
412 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;74 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
413 75 const read: ProcessDiagnosticsShape["read"] = telemetry.latest.pipe(
414 const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () {76 Effect.map((snapshot) => {
415 const readAt = yield* DateTime.now;77 const processes = snapshot.processes
416 const rows = yield* readProcessRows.pipe(78 .filter((entry) => entry.identity.pid !== process.pid)
417 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),79 .map(
418 );80 (entry): ServerProcessDiagnosticsEntry => ({
419 return makeResult({ serverPid: process.pid, rows, readAt });81 pid: entry.identity.pid,
420 }).pipe(82 startTimeMs: entry.identity.startTimeMs,
421 Effect.catch((error: ProcessDiagnosticsError) =>83 ppid: entry.ppid,
422 DateTime.now.pipe(84 pgid: Option.none(),
423 Effect.map((readAt) =>85 status: entry.status || "Unknown",
424 makeResult({ serverPid: process.pid, rows: [], readAt, error: error.message }),86 cpuPercent: entry.cpuPercent,
425 ),87 rssBytes: entry.residentBytes,
426 ),88 elapsed: formatElapsed(entry.runTimeMs),
427 ),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 }),
428 );104 );
429 105
430 const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")(106 const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")(
431 function* (input) {107 function* (input) {
432 return yield* assertDescendantPid(input.pid).pipe(108 if (input.pid === process.pid) {
433 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),109 return {
434 Effect.flatMap(() =>110 pid: input.pid,
435 Effect.try({111 signal: input.signal,
436 try: () => {112 signaled: false,
437 process.kill(input.pid, input.signal);113 message: Option.some("Refusing to signal the T3 server process."),
438 return {114 };
439 pid: input.pid,115 }
440 signal: input.signal,116 const current = yield* telemetry.latest;
441 signaled: true,117 const selected = current.processes.find(
442 message: Option.none(),118 (entry) =>
443 };119 entry.identity.pid === input.pid && entry.identity.startTimeMs === input.startTimeMs,
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 ),
460 );120 );
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 );
461 },185 },
462 );186 );
463 187
apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+112−211
@@ -1,231 +1,132 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type { ResourceTelemetryHistory } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
5 8
6import {9import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
7 aggregateProcessResourceHistory,10import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
8 collectMonitoredSamples,
9} from "./ProcessResourceMonitor.ts";
10 11
11describe("ProcessResourceMonitor", () => {12describe("ProcessResourceMonitor", () => {
12 it.effect("samples the server root process and descendants", () =>13 it.effect("projects resource telemetry history into the legacy diagnostics contract", () =>
13 Effect.sync(() => {14 Effect.gen(function* () {
14 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");15 const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
15 const samples = collectMonitoredSamples({16 const history: ResourceTelemetryHistory = {
16 serverPid: 100,17 readAt,
17 sampledAt,18 windowMs: 60_000,
18 sampledAtMs: DateTime.toEpochMillis(sampledAt),19 bucketMs: 10_000,
19 rows: [20 sampleIntervalMs: 1_000,
20 {21 retainedSampleCount: 2,
21 pid: 100,22 buckets: [
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 },
50 {23 {
51 pid: 200,24 startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
52 ppid: 1,25 endedAt: readAt,
53 pgid: 200,26 avgCpuPercent: 15,
54 status: "R",27 maxCpuPercent: 25,
55 cpuPercent: 99,28 maxRssBytes: 4_096,
56 rssBytes: 9_000,29 ioReadBytes: 1_024,
57 elapsed: "00:05",30 ioWriteBytes: 2_048,
58 command: "unrelated",31 maxProcessCount: 2,
59 },32 },
60 ],33 ],
61 });34 topProcesses: [
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: [
195 {35 {
196 pid: 100,36 identity: { pid: process.pid, startTimeMs: 100 },
197 ppid: 1,37 ppid: 1,
198 pgid: 100,38 depth: 0,
199 status: "S",39 name: "node",
200 cpuPercent: 1,
201 rssBytes: 1_000,
202 elapsed: "01:00",
203 command: "t3 server",40 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,
204 },54 },
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 })),
215 ],55 ],
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 );
217 92
218 const result = aggregateProcessResourceHistory({93 const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe(
219 samples,94 Effect.flatMap((monitor) =>
220 readAt: sampledAt,95 monitor.readHistory({
221 readAtMs: DateTime.toEpochMillis(sampledAt),96 windowMs: 60_000,
222 windowMs: 60_000,97 bucketMs: 10_000,
223 bucketMs: 10_000,98 }),
224 lastError: null,99 ),
225 });100 Effect.provide(layer),
101 );
226 102
227 expect(result.topProcesses).toHaveLength(36);103 expect(result.totalCpuSecondsApprox).toBe(1.5);
228 expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true);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" }));
229 }),130 }),
230 );131 );
231});132});
apps/server/src/diagnostics/ProcessResourceMonitor.ts+44−271
@@ -1,45 +1,13 @@
1import type {1import type {
2 ServerProcessResourceHistoryBucket,
3 ServerProcessResourceHistoryInput,2 ServerProcessResourceHistoryInput,
4 ServerProcessResourceHistoryResult,3 ServerProcessResourceHistoryResult,
5 ServerProcessResourceHistorySummary,
6} from "@t3tools/contracts";4} from "@t3tools/contracts";
7import * as Context from "effect/Context";5import * as Context from "effect/Context";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";8import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";
13import { ChildProcessSpawner } from "effect/unstable/process";
14 9
15import {10import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
16 buildDescendantEntries,
17 isDiagnosticsQueryProcess,
18 type ProcessRow,
19 readProcessRows,
20} from "./ProcessDiagnostics.ts";
21
22const SAMPLE_INTERVAL_MS = 5_000;
23const RETENTION_MS = 60 * 60_000;
24const MAX_RETAINED_SAMPLES = 20_000;
25
26export 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
39interface MonitorState {
40 readonly samples: ReadonlyArray<ProcessResourceSample>;
41 readonly lastError: string | null;
42}
43 11
44export interface ProcessResourceMonitorShape {12export interface ProcessResourceMonitorShape {
45 readonly readHistory: (13 readonly readHistory: (
@@ -52,246 +20,51 @@ export class ProcessResourceMonitor extends Context.Service< · 6 unmodified lines
52 ProcessResourceMonitorShape20 ProcessResourceMonitorShape
53>()("t3/diagnostics/ProcessResourceMonitor") {}21>()("t3/diagnostics/ProcessResourceMonitor") {}
54 22
55function dateTimeFromMillis(ms: number): DateTime.Utc {
56 return DateTime.makeUnsafe(ms);
57}
58
59function sampleKey(row: Pick<ProcessRow, "pid" | "command">): string {
60 return `${row.pid}:${row.command}`;
61}
62
63function findServerRootRow(rows: ReadonlyArray<ProcessRow>, serverPid: number): ProcessRow | null {
64 return rows.find((row) => row.pid === serverPid) ?? null;
65}
66
67export 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
111function 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
122function 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
166function 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
217export 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
248export const make = Effect.fn("makeProcessResourceMonitor")(function* () {23export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
249 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;24 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
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
281 const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) =>25 const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) =>
282 Effect.gen(function* () {26 telemetry.readHistory(input).pipe(
283 const readAt = yield* DateTime.now;27 Effect.map((history) => {
284 const readAtMs = DateTime.toEpochMillis(readAt);28 const topProcesses = history.topProcesses.map((entry) => ({
285 const current = yield* Ref.get(state);29 processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`,
286 return aggregateProcessResourceHistory({30 pid: entry.identity.pid,
287 samples: current.samples,31 ppid: entry.ppid,
288 readAt,32 command: entry.command || entry.name || "unknown",
289 readAtMs,33 depth: entry.depth,
290 windowMs: input.windowMs,34 isServerRoot: entry.category === "server",
291 bucketMs: input.bucketMs,35 firstSeenAt: entry.firstSeenAt,
292 lastError: current.lastError,36 lastSeenAt: entry.lastSeenAt,
293 });37 currentCpuPercent: entry.currentCpuPercent,
294 });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 );
295 68
296 return ProcessResourceMonitor.of({ readHistory });69 return ProcessResourceMonitor.of({ readHistory });
297});70});
apps/server/src/observability/Layers/Observability.ts+10−0
@@ -7,6 +7,7 @@ import * as Tracer from "effect/Tracer"; · 6 unmodified lines
7import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";7import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
8 8
9import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
10import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
10import { ServerLoggerLive } from "../../serverLogger.ts";11import { ServerLoggerLive } from "../../serverLogger.ts";
11import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";12import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
12 13
@@ -15,6 +16,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson; · 2 unmodified lines
15export const ObservabilityLive = Layer.unwrap(16export const ObservabilityLive = Layer.unwrap(
16 Effect.gen(function* () {17 Effect.gen(function* () {
17 const config = yield* ServerConfig;18 const config = yield* ServerConfig;
19 const attribution = yield* ResourceAttribution.ResourceAttribution;
18 20
19 const traceReferencesLayer = Layer.mergeAll(21 const traceReferencesLayer = Layer.mergeAll(
20 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),22 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
@@ -29,6 +31,14 @@ export const ObservabilityLive = Layer.unwrap( · 8 unmodified lines
29 maxBytes: config.traceMaxBytes,31 maxBytes: config.traceMaxBytes,
30 maxFiles: config.traceMaxFiles,32 maxFiles: config.traceMaxFiles,
31 batchWindowMs: config.traceBatchWindowMs,33 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 }),
32 });42 });
33 const delegate =43 const delegate =
34 config.otlpTracesUrl === undefined44 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
7import { assert, describe, it } from "@effect/vitest";7import { assert, describe, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9 9
10import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
10import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";11import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
11 12
12function parseLogLine(line: string) {13function parseLogLine(line: string) {
@@ -207,4 +208,36 @@ describe("EventNdjsonLogger", () => { · 194 unmodified lines
207 }208 }
208 }),209 }),
209 );210 );
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 );
210});243});
apps/server/src/provider/Layers/EventNdjsonLogger.ts+28−1
@@ -11,6 +11,7 @@ import path from "node:path"; · 10 unmodified lines
11 11
12import type { ThreadId } from "@t3tools/contracts";12import type { ThreadId } from "@t3tools/contracts";
13import { RotatingFileSink } from "@t3tools/shared/logging";13import { RotatingFileSink } from "@t3tools/shared/logging";
14import * as Clock from "effect/Clock";
14import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";16import * as Exit from "effect/Exit";
16import * as Logger from "effect/Logger";17import * as Logger from "effect/Logger";
@@ -19,6 +20,7 @@ import * as Scope from "effect/Scope"; · 2 unmodified lines
19import * as SynchronizedRef from "effect/SynchronizedRef";20import * as SynchronizedRef from "effect/SynchronizedRef";
20 21
21import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";22import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
23import type { ResourceAttributionShape } from "../../resourceTelemetry/ResourceAttribution.ts";
22 24
23const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;25const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
24const DEFAULT_MAX_FILES = 10;26const DEFAULT_MAX_FILES = 10;
@@ -26,6 +28,7 @@ const DEFAULT_BATCH_WINDOW_MS = 200; · 1 unmodified line
26const GLOBAL_THREAD_SEGMENT = "_global";28const GLOBAL_THREAD_SEGMENT = "_global";
27const LOG_SCOPE = "provider-observability";29const LOG_SCOPE = "provider-observability";
28const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);30const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);
31const textEncoder = new TextEncoder();
29 32
30export type EventNdjsonStream = "native" | "canonical" | "orchestration";33export type EventNdjsonStream = "native" | "canonical" | "orchestration";
31 34
@@ -40,6 +43,7 @@ export interface EventNdjsonLoggerOptions { · 8 unmodified lines
40 readonly maxBytes?: number;43 readonly maxBytes?: number;
41 readonly maxFiles?: number;44 readonly maxFiles?: number;
42 readonly batchWindowMs?: number;45 readonly batchWindowMs?: number;
46 readonly attribution?: ResourceAttributionShape;
43}47}
44 48
45interface ThreadWriter {49interface ThreadWriter {
@@ -104,6 +108,8 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 58 unmodified lines
104 readonly maxFiles: number;108 readonly maxFiles: number;
105 readonly batchWindowMs: number;109 readonly batchWindowMs: number;
106 readonly streamLabel: string;110 readonly streamLabel: string;
111 readonly stream: EventNdjsonStream;
112 readonly attribution?: ResourceAttributionShape;
107}): Effect.fn.Return<ThreadWriter | undefined> {113}): Effect.fn.Return<ThreadWriter | undefined> {
108 const sinkResult = yield* Effect.sync(() => {114 const sinkResult = yield* Effect.sync(() => {
109 try {115 try {
@@ -135,12 +141,19 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 25 unmodified lines
135 const batchedLogger = yield* Logger.batched(lineLogger, {141 const batchedLogger = yield* Logger.batched(lineLogger, {
136 window: input.batchWindowMs,142 window: input.batchWindowMs,
137 flush: Effect.fn("makeThreadWriter.flush")(function* (messages) {143 flush: Effect.fn("makeThreadWriter.flush")(function* (messages) {
144 const startedAt = yield* Clock.currentTimeMillis;
138 const flushResult = yield* Effect.sync(() => {145 const flushResult = yield* Effect.sync(() => {
139 try {146 try {
147 let logicalWriteBytes = 0;
140 for (const message of messages) {148 for (const message of messages) {
141 sink.write(message);149 sink.write(message);
150 logicalWriteBytes += textEncoder.encode(message).byteLength;
142 }151 }
143 return { ok: true as const };152 return {
153 ok: true as const,
154 logicalWriteBytes,
155 count: messages.length,
156 };
144 } catch (error) {157 } catch (error) {
145 return { ok: false as const, error };158 return { ok: false as const, error };
146 }159 }
@@ -151,6 +164,18 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 4 unmodified lines
151 filePath: input.filePath,164 filePath: input.filePath,
152 error: flushResult.error,165 error: flushResult.error,
153 });166 });
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 });
154 }179 }
155 }),180 }),
156 }).pipe(Effect.provideService(Scope.Scope, scope));181 }).pipe(Effect.provideService(Scope.Scope, scope));
@@ -216,6 +241,8 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function · 59 unmodified lines
216 maxFiles,241 maxFiles,
217 batchWindowMs,242 batchWindowMs,
218 streamLabel,243 streamLabel,
244 stream: options.stream,
245 ...(options.attribution ? { attribution: options.attribution } : {}),
219 }).pipe(246 }).pipe(
220 Effect.map((writer) => {247 Effect.map((writer) => {
221 if (!writer) {248 if (!writer) {
apps/server/src/provider/Layers/ProviderEventLoggers.ts+4−0
@@ -32,6 +32,7 @@ import * as Effect from "effect/Effect"; · 31 unmodified lines
32import * as Layer from "effect/Layer";32import * as Layer from "effect/Layer";
33 33
34import { ServerConfig } from "../../config.ts";34import { ServerConfig } from "../../config.ts";
35import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
35import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";36import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
36 37
37export interface ProviderEventLoggersShape {38export interface ProviderEventLoggersShape {
@@ -71,11 +72,14 @@ export const ProviderEventLoggersLive = Layer.effect( · 33 unmodified lines
71 ProviderEventLoggers,72 ProviderEventLoggers,
72 Effect.gen(function* () {73 Effect.gen(function* () {
73 const { providerEventLogPath } = yield* ServerConfig;74 const { providerEventLogPath } = yield* ServerConfig;
75 const attribution = yield* ResourceAttribution.ResourceAttribution;
74 const native = yield* makeEventNdjsonLogger(providerEventLogPath, {76 const native = yield* makeEventNdjsonLogger(providerEventLogPath, {
75 stream: "native",77 stream: "native",
78 attribution,
76 });79 });
77 const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, {80 const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, {
78 stream: "canonical",81 stream: "canonical",
82 attribution,
79 });83 });
80 return {84 return {
81 native,85 native,
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts+279−0
@@ -0,0 +1,279 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFileSystem from "node:fs";
3
4import * as NodeStream from "@effect/platform-node/NodeStream";
5import {
6 DesktopHostTelemetryMessage,
7 type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue,
8 type DesktopHostTelemetrySnapshot,
9 type ResourceTelemetrySourceStatus,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as PubSub from "effect/PubSub";
17import * as Ref from "effect/Ref";
18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
20import * as Ndjson from "effect/unstable/encoding/Ndjson";
21
22import { ServerConfig } from "../config.ts";
23
24export 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
35export 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
47export 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
58export 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
70export 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
81export type DesktopTelemetryReceiverError =
82 | DesktopTelemetryDescriptorUnavailable
83 | DesktopTelemetryProtocolMismatch
84 | DesktopTelemetryDecodeFailed
85 | DesktopTelemetryStreamFailed
86 | DesktopTelemetryStreamClosed;
87
88export interface DesktopTelemetryReceiverHealth {
89 readonly status: ResourceTelemetrySourceStatus;
90 readonly lastSampleAt: Option.Option<DateTime.Utc>;
91 readonly lastError: Option.Option<string>;
92}
93
94export 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
101export class DesktopTelemetryReceiver extends Context.Service<
102 DesktopTelemetryReceiver,
103 DesktopTelemetryReceiverShape
104>()("t3/resourceTelemetry/DesktopTelemetryReceiver") {}
105
106const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage);
107const isDescriptorUnavailable = Schema.is(DesktopTelemetryDescriptorUnavailable);
108const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch);
109const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed);
110const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed);
111
112function 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
124function 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
130export 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
261export const layer = Layer.effect(DesktopTelemetryReceiver, make());
262
263export 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 @@
1import {
2 type DesktopElectronProcessMetric,
3 type DesktopHostTelemetrySnapshot,
4 type ResourceMonitorProcessSample,
5 type ResourceMonitorSnapshotEvent,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as DateTime from "effect/DateTime";
9import * as Option from "effect/Option";
10
11import { emptyTelemetryCounters, mergeProcesses, type MergeProcessesResult } from "./Model.ts";
12
13const SERVER_PID = 100;
14const BASE_TIME_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"));
15
16function 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
36function 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
54function 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
67function 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
94function 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
112describe("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 @@
1import type {
2 DesktopElectronProcessMetric,
3 DesktopHostTelemetrySnapshot,
4 ResourceMonitorProcessSample,
5 ResourceMonitorSnapshotEvent,
6 ResourceTelemetryAggregate,
7 ResourceTelemetryProcess,
8 ResourceTelemetryProcessCategory,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import * as Option from "effect/Option";
12
13const MAX_DELTA_INTERVAL_MS = 10_000;
14const ELECTRON_IDENTITY_TOLERANCE_MS = 2_000;
15
16export interface ProcessState {
17 readonly process: ResourceTelemetryProcess;
18 readonly sampledAtMs: number;
19}
20
21export interface GroupCounters {
22 readonly cpuTimeMs: number;
23 readonly ioReadBytes: number;
24 readonly ioWriteBytes: number;
25 readonly processStarts: number;
26 readonly processExits: number;
27}
28
29export interface TelemetryCounters {
30 readonly backend: GroupCounters;
31 readonly electron: GroupCounters;
32 readonly monitor: GroupCounters;
33 readonly allT3: GroupCounters;
34}
35
36export interface ProcessDelta {
37 readonly identityKey: string;
38 readonly category: ResourceTelemetryProcessCategory;
39 readonly cpuTimeMs: number;
40 readonly ioReadBytes: number;
41 readonly ioWriteBytes: number;
42}
43
44export 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
55export 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
69export const emptyGroupCounters = (): GroupCounters => ({
70 cpuTimeMs: 0,
71 ioReadBytes: 0,
72 ioWriteBytes: 0,
73 processStarts: 0,
74 processExits: 0,
75});
76
77export const emptyTelemetryCounters = (): TelemetryCounters => ({
78 backend: emptyGroupCounters(),
79 electron: emptyGroupCounters(),
80 monitor: emptyGroupCounters(),
81 allT3: emptyGroupCounters(),
82});
83
84export function processIdentityKey(pid: number, startTimeMs: number): string {
85 return `${pid}:${startTimeMs}`;
86}
87
88function finiteNonNegative(value: number): number {
89 return Number.isFinite(value) ? Math.max(0, value) : 0;
90}
91
92function 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
100function 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
113function 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
124function 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
146function 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
170function 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
187function 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
204function 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
242function 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
257function 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
267function 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
330function 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
355export 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 @@
1import type {
2 ResourceMonitorCapabilities,
3 ResourceMonitorCommand,
4 ResourceMonitorEvent,
5 ResourceMonitorExternalProcess,
6 ResourceMonitorHelloEvent,
7 ResourceMonitorSnapshotEvent,
8 ResourceTelemetrySourceStatus,
9} from "@t3tools/contracts";
10import {
11 RESOURCE_MONITOR_PROTOCOL_VERSION,
12 ResourceMonitorCommand as ResourceMonitorCommandSchema,
13 ResourceMonitorEvent as ResourceMonitorEventSchema,
14} from "@t3tools/contracts";
15import * as Cause from "effect/Cause";
16import * as Context from "effect/Context";
17import * as Crypto from "effect/Crypto";
18import * as DateTime from "effect/DateTime";
19import * as Deferred from "effect/Deferred";
20import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";
22import * as Fiber from "effect/Fiber";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as PubSub from "effect/PubSub";
26import * as Queue from "effect/Queue";
27import * as Ref from "effect/Ref";
28import * as Result from "effect/Result";
29import * as Schema from "effect/Schema";
30import * as Semaphore from "effect/Semaphore";
31import * as Stream from "effect/Stream";
32import * as Ndjson from "effect/unstable/encoding/Ndjson";
33import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
34
35import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";
36import { ServerConfig } from "../config.ts";
37
38const SAMPLE_INTERVAL_MS = 1_000;
39const HANDSHAKE_TIMEOUT = Duration.seconds(5);
40const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5);
41const INITIAL_RESTART_DELAY = Duration.millis(500);
42const MAX_RESTART_DELAY = Duration.seconds(10);
43const FAILURE_WINDOW_MS = 60_000;
44const MAX_FAILURES_PER_WINDOW = 5;
45
46export 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
58export 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
69export 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
81export 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
92export 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
104export 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
115export 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
126export type NativeTelemetryClientError =
127 | NativeTelemetrySpawnFailed
128 | NativeTelemetryHandshakeTimedOut
129 | NativeTelemetryProtocolMismatch
130 | NativeTelemetryDecodeFailed
131 | NativeTelemetryCommandFailed
132 | NativeTelemetryExited
133 | NativeTelemetryUnavailable;
134
135export 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
143export 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
155export class NativeTelemetryClient extends Context.Service<
156 NativeTelemetryClient,
157 NativeTelemetryClientShape
158>()("t3/resourceTelemetry/NativeTelemetryClient") {}
159
160interface 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
169const 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
178function 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
188const decodeMonitorEvent: (
189 value: unknown,
190) => Effect.Effect<ResourceMonitorEvent, Schema.SchemaError> = Schema.decodeUnknownEffect(
191 ResourceMonitorEventSchema,
192);
193const encodeMonitorCommand = Schema.encodeEffect(
194 Schema.fromJsonString(ResourceMonitorCommandSchema),
195);
196const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch);
197const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed);
198const isCommandFailed = Schema.is(NativeTelemetryCommandFailed);
199
200function 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
206function restartDelay(attempt: number): Duration.Duration {
207 return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY);
208}
209
210function errorMessage(error: NativeTelemetryClientError): string {
211 return error.message;
212}
213
214export 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
621export const layer = Layer.effect(NativeTelemetryClient, make());
622
623export 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 @@
1import type { ResourceAttributionEntry, ResourceAttributionSnapshot } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7
8export 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
17export interface ResourceAttributionShape {
18 readonly record: (input: ResourceAttributionRecord) => Effect.Effect<void>;
19 readonly snapshot: Effect.Effect<ResourceAttributionSnapshot>;
20}
21
22export class ResourceAttribution extends Context.Service<
23 ResourceAttribution,
24 ResourceAttributionShape
25>()("t3/resourceTelemetry/ResourceAttribution") {}
26
27function key(input: Pick<ResourceAttributionRecord, "component" | "operation">): string {
28 return `${input.component}\u0000${input.operation}`;
29}
30
31function 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
37export 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
73export const layer = Layer.effect(ResourceAttribution, make());
apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts+80−0
@@ -0,0 +1,80 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 HostProcessArchitecture,
4 HostProcessEnvironment,
5 HostProcessPlatform,
6} from "@t3tools/shared/hostProcess";
7import { assert, describe, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10
11import { ServerConfig } from "../config.ts";
12import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";
13
14describe("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 @@
1import {
2 HostProcessArchitecture,
3 HostProcessEnvironment,
4 HostProcessPlatform,
5} from "@t3tools/shared/hostProcess";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Schema from "effect/Schema";
13
14import { ServerConfig } from "../config.ts";
15
16export 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
28export 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
41export 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
53export type ResourceMonitorBinaryError =
54 | ResourceMonitorBinaryUnsupported
55 | ResourceMonitorBinaryNotFound
56 | ResourceMonitorBinaryNotExecutable;
57
58export interface ResourceMonitorBinaryShape {
59 readonly resolve: Effect.Effect<string, ResourceMonitorBinaryError>;
60}
61
62export class ResourceMonitorBinary extends Context.Service<
63 ResourceMonitorBinary,
64 ResourceMonitorBinaryShape
65>()("t3/resourceTelemetry/ResourceMonitorBinary") {}
66
67function binaryName(platform: NodeJS.Platform): string {
68 return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
69}
70
71export 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
84function 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
112export 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
193export const layer = Layer.effect(ResourceMonitorBinary, make());
apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts+302−0
@@ -0,0 +1,302 @@
1import type {
2 DesktopHostTelemetrySnapshot,
3 ResourceMonitorProcessSample,
4 ResourceMonitorSnapshotEvent,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as DateTime from "effect/DateTime";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as PubSub from "effect/PubSub";
14import * as Ref from "effect/Ref";
15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
17
18import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts";
19import * as NativeTelemetryClient from "./NativeTelemetryClient.ts";
20import * as ResourceAttribution from "./ResourceAttribution.ts";
21import * as ResourceTelemetry from "./ResourceTelemetry.ts";
22
23function 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
43function 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
95function 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
131describe("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 @@
1import type {
2 DesktopHostTelemetrySnapshot,
3 HostPowerSnapshot,
4 ResourceMonitorSnapshotEvent,
5 ResourceTelemetryHealth,
6 ResourceTelemetryHistory,
7 ResourceTelemetryHistoryInput,
8 ResourceTelemetryProcessIdentity,
9 ResourceTelemetryRetryResult,
10 ResourceTelemetrySnapshot,
11} from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as PubSub from "effect/PubSub";
18import * as Ref from "effect/Ref";
19import * as Schema from "effect/Schema";
20import * as Semaphore from "effect/Semaphore";
21import * as Stream from "effect/Stream";
22
23import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts";
24import {
25 emptyTelemetryCounters,
26 mergeProcesses,
27 type ProcessState,
28 type TelemetryCounters,
29} from "./Model.ts";
30import * as NativeTelemetryClient from "./NativeTelemetryClient.ts";
31import * as ResourceAttribution from "./ResourceAttribution.ts";
32import * as ResourceTelemetryStore from "./ResourceTelemetryStore.ts";
33
34const SAMPLE_INTERVAL_MS = 1_000;
35
36export 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
48export 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
61export class ResourceTelemetry extends Context.Service<ResourceTelemetry, ResourceTelemetryShape>()(
62 "t3/resourceTelemetry/ResourceTelemetry",
63) {}
64
65interface 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
75function 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
90function 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
128export 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
372export const layer = Layer.effect(ResourceTelemetry, make());
apps/server/src/resourceTelemetry/ResourceTelemetryStore.ts+253−0
@@ -0,0 +1,253 @@
1import type {
2 ResourceTelemetryHistory,
3 ResourceTelemetryHistoryBucket,
4 ResourceTelemetryHistoryInput,
5 ResourceTelemetryProcess,
6 ResourceTelemetryProcessSummary,
7 ResourceTelemetrySnapshot,
8} from "@t3tools/contracts";
9import * as Context from "effect/Context";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14
15import type { ProcessDelta } from "./Model.ts";
16import { processIdentityKey } from "./Model.ts";
17
18const RETENTION_MS = 60 * 60_000;
19const MAX_AGGREGATE_SAMPLES = 3_600;
20const MAX_PROCESS_SAMPLES = 20_000;
21
22interface 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
31interface ProcessSample {
32 readonly sampledAtMs: number;
33 readonly process: ResourceTelemetryProcess;
34 readonly cpuTimeMs: number;
35 readonly ioReadBytes: number;
36 readonly ioWriteBytes: number;
37}
38
39interface StoreState {
40 readonly aggregateSamples: ReadonlyArray<AggregateSample>;
41 readonly processSamples: ReadonlyArray<ProcessSample>;
42 readonly latest: ResourceTelemetrySnapshot;
43}
44
45export 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
56export class ResourceTelemetryStore extends Context.Service<
57 ResourceTelemetryStore,
58 ResourceTelemetryStoreShape
59>()("t3/resourceTelemetry/ResourceTelemetryStore") {}
60
61function 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
70function 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
116function 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
150export 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
252export 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
141import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";141import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
142import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";142import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
143import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";143import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
144import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
145import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";
146import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
147import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
144import * as Data from "effect/Data";148import * as Data from "effect/Data";
145 149
146const defaultProjectId = ProjectId.make("project-default");150const defaultProjectId = ProjectId.make("project-default");
@@ -368,6 +372,8 @@ const buildAppUnderTest = (options?: { · 221 unmodified lines
368 cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>;372 cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>;
369 relayClient?: Partial<RelayClient.RelayClientShape>;373 relayClient?: Partial<RelayClient.RelayClientShape>;
370 cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManagerShape>;374 cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManagerShape>;
375 nativeTelemetryClient?: Partial<NativeTelemetryClient.NativeTelemetryClientShape>;
376 desktopTelemetryReceiver?: Partial<DesktopTelemetryReceiver.DesktopTelemetryReceiverShape>;
371 };377 };
372}) =>378}) =>
373 Effect.gen(function* () {379 Effect.gen(function* () {
@@ -540,6 +546,15 @@ const buildAppUnderTest = (options?: { · 166 unmodified lines
540 ...options.layers.vcsStatusBroadcaster,546 ...options.layers.vcsStatusBroadcaster,
541 })547 })
542 : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer));548 : 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 );
543 558
544 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {559 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
545 disableListenLog: true,560 disableListenLog: true,
@@ -751,6 +766,7 @@ const buildAppUnderTest = (options?: { · 205 unmodified lines
751 );766 );
752 767
753 const appLayer = servedRoutesLayer.pipe(768 const appLayer = servedRoutesLayer.pipe(
769 Layer.provide(resourceTelemetryLayer),
754 Layer.provide(770 Layer.provide(
755 Layer.mock(BrowserTraceCollector)({771 Layer.mock(BrowserTraceCollector)({
756 record: () => Effect.void,772 record: () => Effect.void,
apps/server/src/server.ts+31−4
@@ -83,6 +83,11 @@ import * as CloudCliState from "./cloud/CliState.ts"; · 82 unmodified lines
83import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";83import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
84import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";84import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
85import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";85import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
86import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
87import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";
88import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
89import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
90import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
86import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";91import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
87import {92import {
88 clearPersistedServerRuntimeState,93 clearPersistedServerRuntimeState,
@@ -99,6 +104,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale" · 10 unmodified lines
99// already closes the websocket gracefully. Do not add an artificial drain before104// already closes the websocket gracefully. Do not add an artificial drain before
100// those finalizers get a chance to run.105// those finalizers get a chance to run.
101const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;106const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;
107const ResourceAttributionLayerLive = ResourceAttribution.layer;
108const ApplicationObservabilityLive = ObservabilityLive.pipe(
109 Layer.provideMerge(ResourceAttributionLayerLive),
110);
102 111
103const PtyAdapterLive = Layer.unwrap(112const PtyAdapterLive = Layer.unwrap(
104 Effect.gen(function* () {113 Effect.gen(function* () {
@@ -112,11 +121,30 @@ const PtyAdapterLive = Layer.unwrap( · 7 unmodified lines
112 }),121 }),
113);122);
114 123
124const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe(
125 Layer.provide(ResourceMonitorBinary.layer),
126);
127
128const ResourceTelemetryLayerLive = ResourceTelemetry.layer.pipe(
129 Layer.provideMerge(NativeTelemetryLayerLive),
130 Layer.provideMerge(DesktopTelemetryReceiver.layer),
131);
132
133const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe(
134 Layer.provide(ResourceTelemetryLayerLive),
135);
136
115const BackgroundLayerLive = BackgroundPolicy.layer.pipe(137const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
116 Layer.provide(HostPowerMonitor.layer),138 Layer.provide(HostPowerMonitorLayerLive),
117 Layer.provideMerge(ServerSettingsLive),139 Layer.provideMerge(ServerSettingsLive),
118);140);
119 141
142const ResourceDiagnosticsLayerLive = Layer.mergeAll(
143 ResourceTelemetryLayerLive,
144 ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
145 ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
146);
147
120const RelayClientLive = Layer.unwrap(148const RelayClientLive = Layer.unwrap(
121 Effect.gen(function* () {149 Effect.gen(function* () {
122 const config = yield* ServerConfig;150 const config = yield* ServerConfig;
@@ -337,8 +365,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 214 unmodified lines
337const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(365const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
338 // Misc.366 // Misc.
339 Layer.provideMerge(BackgroundLayerLive),367 Layer.provideMerge(BackgroundLayerLive),
340 Layer.provideMerge(ProcessDiagnostics.layer),368 Layer.provideMerge(ResourceDiagnosticsLayerLive),
341 Layer.provideMerge(ProcessResourceMonitor.layer),
342 Layer.provideMerge(TraceDiagnostics.layer),369 Layer.provideMerge(TraceDiagnostics.layer),
343 Layer.provideMerge(AnalyticsServiceLayerLive),370 Layer.provideMerge(AnalyticsServiceLayerLive),
344 Layer.provideMerge(ExternalLauncher.layer),371 Layer.provideMerge(ExternalLauncher.layer),
@@ -488,7 +515,7 @@ export const makeServerLayer = Layer.unwrap( · 143 unmodified lines
488 Layer.provideMerge(RuntimeServicesLive),515 Layer.provideMerge(RuntimeServicesLive),
489 Layer.provideMerge(serverRelayBrokerTracingLayer),516 Layer.provideMerge(serverRelayBrokerTracingLayer),
490 Layer.provideMerge(HttpServerLive),517 Layer.provideMerge(HttpServerLive),
491 Layer.provide(ObservabilityLive),518 Layer.provide(ApplicationObservabilityLive),
492 Layer.provideMerge(FetchHttpClient.layer),519 Layer.provideMerge(FetchHttpClient.layer),
493 Layer.provideMerge(VcsProcess.layer),520 Layer.provideMerge(VcsProcess.layer),
494 Layer.provideMerge(PlatformServicesLive),521 Layer.provideMerge(PlatformServicesLive),
apps/server/src/ws.ts+31−0
@@ -95,6 +95,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; · 94 unmodified lines
95import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts";95import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts";
96import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";96import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
97import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";97import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
98import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
98import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";99import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
99import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";100import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
100import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";101import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";
@@ -159,6 +160,9 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 58 unmodified lines
159 [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope],160 [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope],
160 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],161 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
161 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],162 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
163 [WS_METHODS.serverGetResourceTelemetry, AuthOrchestrationReadScope],
164 [WS_METHODS.serverGetResourceTelemetryHistory, AuthOrchestrationReadScope],
165 [WS_METHODS.serverRetryResourceTelemetry, AuthOrchestrationOperateScope],
162 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],166 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
163 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],167 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],
164 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],168 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],
@@ -173,6 +177,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 8 unmodified lines
173 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],177 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
174 [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],178 [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
175 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],179 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
180 [WS_METHODS.subscribeResourceTelemetry, AuthOrchestrationReadScope],
176 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],181 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
177 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],182 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
178 [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope],183 [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope],
@@ -298,6 +303,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 119 unmodified lines
298 const sessions = yield* SessionStore.SessionStore;303 const sessions = yield* SessionStore.SessionStore;
299 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;304 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
300 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;305 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
306 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
301 const relayClient = yield* RelayClient.RelayClient;307 const relayClient = yield* RelayClient.RelayClient;
302 const authorizationError = (requiredScope: AuthEnvironmentScope) =>308 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
303 new EnvironmentAuthorizationError({309 new EnvironmentAuthorizationError({
@@ -1105,6 +1111,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 801 unmodified lines
1105 "rpc.aggregate": "server",1111 "rpc.aggregate": "server",
1106 },1112 },
1107 ),1113 ),
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 }),
1108 [WS_METHODS.serverSignalProcess]: (input) =>1130 [WS_METHODS.serverSignalProcess]: (input) =>
1109 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {1131 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
1110 "rpc.aggregate": "server",1132 "rpc.aggregate": "server",
@@ -1656,6 +1678,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 545 unmodified lines
1656 ),1678 ),
1657 { "rpc.aggregate": "server" },1679 { "rpc.aggregate": "server" },
1658 ),1680 ),
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 ),
1659 });1690 });
1660 }),1691 }),
1661 );1692 );
apps/web/src/components/settings/DiagnosticsSettings.tsx+4−696
@@ -1,18 +1,5 @@
1import {1import { AlertTriangleIcon, CopyIcon, FolderOpenIcon, InfoIcon, RefreshCwIcon } from "lucide-react";
2 AlertTriangleIcon,2import { useCallback, useState, type ReactNode } from "react";
3 ChevronDownIcon,
4 ChevronRightIcon,
5 CopyIcon,
6 FolderOpenIcon,
7 InfoIcon,
8 RefreshCwIcon,
9} from "lucide-react";
10import { useCallback, useMemo, useState, type ReactNode } from "react";
11import type {
12 ServerProcessDiagnosticsEntry,
13 ServerProcessResourceHistorySummary,
14 ServerProcessSignal,
15} from "@t3tools/contracts";
16import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
17import * as Option from "effect/Option";4import * as Option from "effect/Option";
18 5
@@ -21,15 +8,11 @@ import { cn } from "../../lib/utils"; · 2 unmodified lines
21import { resolveAndPersistPreferredEditor } from "../../editorPreferences";8import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
22import { formatRelativeTime } from "../../timestampFormat";9import { formatRelativeTime } from "../../timestampFormat";
23import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState";10import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState";
24import {
25 useProcessDiagnostics,
26 useProcessResourceHistory,
27} from "../../lib/processDiagnosticsState";
28import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState";11import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState";
29import { Button } from "../ui/button";12import { Button } from "../ui/button";
30import { ScrollArea } from "../ui/scroll-area";13import { ScrollArea } from "../ui/scroll-area";
31import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
32import { toastManager } from "../ui/toast";15import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
33import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";16import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
34 17
35const NUMBER_FORMAT = new Intl.NumberFormat();18const NUMBER_FORMAT = new Intl.NumberFormat();
@@ -43,18 +26,6 @@ function formatDuration(value: number): string { · 7 unmodified lines
43 return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)} s`;26 return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)} s`;
44}27}
45 28
46function 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
58function formatRelative(value: DateTime.Utc | null): string {29function formatRelative(value: DateTime.Utc | null): string {
59 if (!value) return "No trace records";30 if (!value) return "No trace records";
60 const relative = formatRelativeTime(DateTime.formatIso(value));31 const relative = formatRelativeTime(DateTime.formatIso(value));
@@ -70,10 +41,6 @@ function shortenTraceId(traceId: string): string { · 9 unmodified lines
70 return `${traceId.slice(0, 18)}...${traceId.slice(-10)}`;41 return `${traceId.slice(0, 18)}...${traceId.slice(-10)}`;
71}42}
72 43
73function isStaleProcessSignalMessage(message: string | undefined): boolean {
74 return message?.includes("not a live descendant") ?? false;
75}
76
77function StatBlock({44function StatBlock({
78 label,45 label,
79 value,46 value,
@@ -286,471 +253,6 @@ function TraceIdCell({ traceId }: { traceId: string }) { · 206 unmodified lines
286 );253 );
287}254}
288 255
289function 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
297function 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
303function 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
349function 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
394function 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
522const 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
529function 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
536function formatShortProcessName(command: string): string {
537 const name = formatProcessName(command);
538 return name.length > 42 ? `${name.slice(0, 39)}...` : name;
539}
540
541function 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
578function 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
627function 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
653function 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
754function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) {256function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) {
755 useRelativeTimeTick();257 useRelativeTimeTick();
756 const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null;258 const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null;
@@ -805,29 +307,9 @@ function DiagnosticsRefreshButton({ · 48 unmodified lines
805export function DiagnosticsSettingsPanel() {307export function DiagnosticsSettingsPanel() {
806 const observability = useServerObservability();308 const observability = useServerObservability();
807 const availableEditors = useServerAvailableEditors();309 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];
812 const { data, error, isPending, refresh } = useTraceDiagnostics();310 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 });
828 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);311 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);
829 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);312 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);
830 const [signalingPid, setSignalingPid] = useState<number | null>(null);
831 313
832 const openLogsDirectory = useCallback(() => {314 const openLogsDirectory = useCallback(() => {
833 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;315 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
@@ -854,58 +336,7 @@ export function DiagnosticsSettingsPanel() { · 20 unmodified lines
854 }, [availableEditors, observability?.logsDirectoryPath]);336 }, [availableEditors, observability?.logsDirectoryPath]);
855 337
856 const isInitialLoading = isPending && data === null;338 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 }
883 339
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;
909 const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null;340 const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null;
910 const traceDiagnosticsPartialFailure = data341 const traceDiagnosticsPartialFailure = data
911 ? Option.getOrElse(data.partialFailure, () => false)342 ? Option.getOrElse(data.partialFailure, () => false)
@@ -913,130 +344,7 @@ export function DiagnosticsSettingsPanel() { · 1 unmodified line
913 344
914 return (345 return (
915 <SettingsPageContainer>346 <SettingsPageContainer>
916 <SettingsSection347 <ResourceTelemetryDiagnostics />
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>
1040 348
1041 <SettingsSection349 <SettingsSection
1042 title="Trace Diagnostics"350 title="Trace Diagnostics"
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+1151−0
@@ -0,0 +1,1151 @@
1import {
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";
15import 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";
28import * as DateTime from "effect/DateTime";
29import * as Option from "effect/Option";
30import { useCallback, useMemo, useState, type ReactNode } from "react";
31
32import { ensureLocalApi } from "../../localApi";
33import {
34 useResourceTelemetry,
35 useResourceTelemetryHistory,
36} from "../../lib/resourceTelemetryState";
37import { cn } from "../../lib/utils";
38import { formatRelativeTime } from "../../timestampFormat";
39import { Button } from "../ui/button";
40import { ScrollArea } from "../ui/scroll-area";
41import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
42import { toastManager } from "../ui/toast";
43import { SettingsSection, useRelativeTimeTick } from "./settingsLayout";
44
45const 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
52function 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
64function formatRate(value: number): string {
65 return `${formatBytes(value)}/s`;
66}
67
68function 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
76function 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
82function processIdentityKey(process: ResourceTelemetryProcess): string {
83 return `${process.identity.pid}:${process.identity.startTimeMs}`;
84}
85
86function processSummaryIdentityKey(process: ResourceTelemetryProcessSummary): string {
87 return `${process.identity.pid}:${process.identity.startTimeMs}`;
88}
89
90function 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
97function 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
122function 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
129function 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
142function 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
151function 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
157function 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
189function 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
203function 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
238function 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
268function 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
281function 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
298function 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
322function 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
348function 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
408function 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
453function canSignalProcess(process: ResourceTelemetryProcess): boolean {
454 return (
455 process.category === "server-child" ||
456 process.category === "provider-root" ||
457 process.category === "terminal-root"
458 );
459}
460
461function 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
496function 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
628function 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
724function 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
778export 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
172 getTraceDiagnostics: vi.fn(),172 getTraceDiagnostics: vi.fn(),
173 getProcessDiagnostics: vi.fn(),173 getProcessDiagnostics: vi.fn(),
174 getProcessResourceHistory: vi.fn(),174 getProcessResourceHistory: vi.fn(),
175 getResourceTelemetry: vi.fn(),
176 getResourceTelemetryHistory: vi.fn(),
177 retryResourceTelemetry: vi.fn(),
175 signalProcess: vi.fn(),178 signalProcess: vi.fn(),
176 reportClientActivity: vi.fn(),179 reportClientActivity: vi.fn(),
177 reportHostPowerState: vi.fn(),180 reportHostPowerState: vi.fn(),
178 getBackgroundPolicy: vi.fn(),181 getBackgroundPolicy: vi.fn(),
179 subscribeBackgroundPolicy: vi.fn(() => () => undefined),182 subscribeBackgroundPolicy: vi.fn(() => () => undefined),
183 subscribeResourceTelemetry: vi.fn(() => () => undefined),
180 },184 },
181 };185 };
182 return {186 return {
apps/web/src/lib/resourceTelemetryState.ts+228−0
@@ -0,0 +1,228 @@
1import { useAtomValue } from "@effect/atom-react";
2import type {
3 ResourceTelemetryHistory,
4 ResourceTelemetryHistoryInput,
5 ResourceTelemetrySnapshot,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import { AsyncResult, Atom } from "effect/unstable/reactivity";
11import { useCallback, useEffect } from "react";
12
13import {
14 getPrimaryEnvironmentConnection,
15 subscribeEnvironmentConnections,
16} from "../environments/runtime";
17import { ensureLocalApi } from "../localApi";
18import { appAtomRegistry } from "../rpc/atomRegistry";
19
20const RESOURCE_TELEMETRY_HISTORY_STALE_TIME_MS = 5_000;
21const RESOURCE_TELEMETRY_IDLE_TTL_MS = 5 * 60_000;
22const RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR = ":";
23
24interface ResourceTelemetryLiveState {
25 readonly data: ResourceTelemetrySnapshot | null;
26 readonly error: string | null;
27 readonly isPending: boolean;
28}
29
30const resourceTelemetryLiveAtom = Atom.make<ResourceTelemetryLiveState>({
31 data: null,
32 error: null,
33 isPending: true,
34}).pipe(Atom.keepAlive, Atom.withLabel("resource-telemetry-live"));
35
36function formatResourceTelemetryError(error: unknown): string {
37 return error instanceof Error ? error.message : "Failed to load resource telemetry.";
38}
39
40function updateLiveState(
41 update: (current: ResourceTelemetryLiveState) => ResourceTelemetryLiveState,
42): void {
43 appAtomRegistry.set(
44 resourceTelemetryLiveAtom,
45 update(appAtomRegistry.get(resourceTelemetryLiveAtom)),
46 );
47}
48
49let retainCount = 0;
50let requestGeneration = 0;
51let activeClient: object | null = null;
52let unsubscribeTelemetry: () => void = () => undefined;
53let unsubscribeConnections: () => void = () => undefined;
54
55function applySnapshot(snapshot: ResourceTelemetrySnapshot): void {
56 updateLiveState(() => ({
57 data: snapshot,
58 error: null,
59 isPending: false,
60 }));
61}
62
63function 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
100function 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
128function 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
150function formatHistoryKey(input: ResourceTelemetryHistoryInput): string {
151 return `${input.windowMs}${RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`;
152}
153
154function 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
162const 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
176export interface ResourceTelemetryState extends ResourceTelemetryLiveState {
177 readonly refresh: () => void;
178 readonly retry: () => Promise<ResourceTelemetrySnapshot>;
179}
180
181export interface ResourceTelemetryHistoryState {
182 readonly data: ResourceTelemetryHistory | null;
183 readonly error: string | null;
184 readonly isPending: boolean;
185 readonly refresh: () => void;
186}
187
188export function refreshResourceTelemetry(): void {
189 refreshFromCurrentClient();
190}
191
192export async function retryResourceTelemetry(): Promise<ResourceTelemetrySnapshot> {
193 const result = await ensureLocalApi().server.retryResourceTelemetry();
194 applySnapshot(result.snapshot);
195 return result.snapshot;
196}
197
198export 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
211export 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
159 rpcClient159 rpcClient
160 ? rpcClient.server.getProcessResourceHistory(input)160 ? rpcClient.server.getProcessResourceHistory(input)
161 : Promise.reject(unavailableLocalBackendError()),161 : 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()),
162 signalProcess: (input) =>174 signalProcess: (input) =>
163 rpcClient175 rpcClient
164 ? rpcClient.server.signalProcess(input)176 ? 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
37 37
38- **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.38- **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.
39 39
40Related design:
41
42- [Resource telemetry architecture](./resource-telemetry.md)
43
40## Event Lifecycle44## Event Lifecycle
41 45
42### Startup and client connect46### Startup and client connect
docs/architecture/resource-telemetry.md+335−0
@@ -0,0 +1,335 @@
1# Resource telemetry architecture
2
3Status: implemented
4
5## Purpose
6
7Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset`
8subprocess probes with two persistent, direct data sources:
9
101. a standalone Rust resource-monitor executable that reads process counters
11 through operating-system APIs via `sysinfo`;
122. Electron main-process APIs for Electron process metrics and host power state.
13
14The server merges both sources, computes rates from cumulative counters, keeps
15bounded in-memory history, exposes typed RPCs, and drives the diagnostics page.
16Telemetry history is not persisted to disk.
17
18## Why a standalone executable
19
20The 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
30The cost is one persistent child process and NDJSON serialization. That is a
31better failure boundary than repeatedly spawning shell utilities or loading
32native code into Node.
33
34## Runtime topology
35
36### Desktop
37
38```text
39Electron main
40 ├─ powerMonitor
41 ├─ app.getAppMetrics()
42 └─ inherited fd 4, NDJSON
43 │
44 ▼
45Node 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
55Electron telemetry is unavailable. The native monitor still runs beside the
56server and tracks the server process tree. Power fields degrade to `unknown`
57instead of invoking platform shell commands.
58
59## Native monitor
60
61The executable lives in `native/resource-monitor`.
62
63It receives schema-compatible commands on stdin and emits one JSON object per
64line on stdout:
65
66- `configure`
67- `setExternalProcesses`
68- `sampleNow`
69- `shutdown`
70- `hello`
71- `snapshot`
72- `error`
73
74The protocol version is defined by
75`RESOURCE_MONITOR_PROTOCOL_VERSION` in
76`packages/contracts/src/resourceTelemetry.ts`.
77
78### Collection
79
80The monitor keeps one `sysinfo::System` instance and refreshes it on a one-second
81interval. 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
90On Linux, task/thread enumeration is disabled. Command lines are loaded only
91when 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
96Each sample scans the accessible process table, builds the PID/PPID graph, and
97retains:
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
106Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native
107start times are matched with a two-second tolerance because native start times
108can have coarser platform resolution.
109
110The process list is emitted in depth-first tree order so renderer collapse and
111expansion preserves complete subtrees.
112
113### Sampling limits
114
115This 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
123Those deeper tracing systems can be added later as opt-in diagnostic modes
124without changing the public `ResourceTelemetry` model.
125
126## I/O semantics
127
128The monitor preserves platform semantics instead of presenting all counters as
129equivalent:
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
136The UI therefore labels these values as I/O reads and writes and exposes the
137per-process `ioSemantics` value.
138
139Group totals are observed deltas since telemetry startup. Per-process total
140columns are the operating system's cumulative counters for that process.
141
142## Electron telemetry
143
144Electron main owns `DesktopTelemetryPublisher`.
145
146It samples once per second from:
147
148- `app.getAppMetrics()`;
149- `powerMonitor.isOnBatteryPower()`;
150- `powerMonitor.getSystemIdleTime()`;
151- `powerMonitor.getSystemIdleState()`;
152- `powerMonitor.getCurrentThermalState()`.
153
154It 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
162Electron does not expose a cross-platform low-power-mode getter, so that field
163remains `unknown`.
164
165The desktop backend is spawned with:
166
167- fd 3 for the existing bootstrap payload;
168- fd 4 for desktop telemetry NDJSON.
169
170This is a private Electron-main-to-server pipe. It does not use the renderer
171WebSocket and is recreated for every backend restart.
172
173## Server Effect services
174
175The implementation is under `apps/server/src/resourceTelemetry`.
176
177### `ResourceMonitorBinary`
178
179Resolves an executable from:
180
1811. `T3CODE_RESOURCE_MONITOR_PATH`;
1822. desktop bootstrap configuration;
1833. bundled CLI resources;
1844. local Cargo build outputs.
185
186Unsupported platforms, missing binaries, and non-executable binaries use
187schema-backed tagged errors with descriptive messages.
188
189### `NativeTelemetryClient`
190
191Owns 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
203Snapshot sequence numbers are scoped to a monitor generation. Server ingestion
204uses the monitor restart count as the generation key, so sequence reset after a
205restart cannot freeze telemetry.
206
207### `DesktopTelemetryReceiver`
208
209Reads fd 4, decodes schema-validated messages, stores the latest Electron
210snapshot, and publishes desktop health. Decode errors, protocol mismatch,
211stream failure, and normal stream closure are represented explicitly.
212
213### `ResourceTelemetry`
214
215Merges 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
227Electron and monitor processes are visible but are not valid targets for the
228existing process-signal RPC.
229
230### `ResourceTelemetryStore`
231
232Keeps aggregate and process samples in memory for at most one hour, subject to
233hard sample-count bounds. Aggregate history retains up to 3,600 samples.
234Detailed process history retains up to 20,000 process samples, so high process
235fan-out can shorten detailed per-process coverage while aggregate coverage
236remains available.
237
238### `ResourceAttribution`
239
240Tracks known logical application I/O separately from OS counters. Current
241integration points record successful writes for:
242
243- provider native and canonical event logs;
244- the local server trace sink.
245
246Entries contain component, operation, logical bytes, count, and elapsed time.
247Future persistence paths should call `ResourceAttribution.record` rather than
248adding diagnostics-specific counters.
249
250## Background policy integration
251
252`HostPowerMonitor` now projects power state from `ResourceTelemetry`; it does not
253spawn macOS shell probes.
254
255The monitor updates its latest timestamp on every Electron sample but only
256publishes semantic state changes. Increasing idle seconds alone does not cause a
257background-policy broadcast every second.
258
259## Public API and UI
260
261The WebSocket RPC surface provides:
262
263- current snapshot;
264- bounded history;
265- explicit monitor retry;
266- a live snapshot subscription.
267
268The 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
279Legacy process diagnostics RPCs are projected from the same service so they no
280longer start recurring process-table commands.
281
282## Packaging
283
284Desktop artifact builds compile the Rust target, stage it as
285`resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the
286backend bootstrap.
287
288CLI release jobs upload each active platform monitor artifact and copy it into:
289
290```text
291apps/server/dist/resource-monitor/<platform>-<arch>/
292```
293
294The published server package already includes `dist`, so those executables ship
295with the CLI. Missing platform artifacts degrade native telemetry to
296`unavailable`; the server continues running.
297
298## Resource and failure behavior
299
300Steady 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
309The diagnostics page exposes the monitor's own process resource usage and
310collection duration so the observer's cost is measurable.
311
312Failures 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
323High-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
333These additions should preserve the current rules: direct platform APIs,
334schema-validated boundaries, explicit metric semantics, bounded retention, and
335no 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.
3version = 4
4
5[[package]]
6name = "bitflags"
7version = "2.13.0"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
10
11[[package]]
12name = "dispatch2"
13version = "0.3.1"
14source = "registry+https://github.com/rust-lang/crates.io-index"
15checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
16dependencies = [
17 "bitflags",
18 "objc2",
19]
20
21[[package]]
22name = "itoa"
23version = "1.0.18"
24source = "registry+https://github.com/rust-lang/crates.io-index"
25checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
26
27[[package]]
28name = "libc"
29version = "0.2.186"
30source = "registry+https://github.com/rust-lang/crates.io-index"
31checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
32
33[[package]]
34name = "memchr"
35version = "2.8.2"
36source = "registry+https://github.com/rust-lang/crates.io-index"
37checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
38
39[[package]]
40name = "ntapi"
41version = "0.4.3"
42source = "registry+https://github.com/rust-lang/crates.io-index"
43checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
44dependencies = [
45 "winapi",
46]
47
48[[package]]
49name = "objc2"
50version = "0.6.4"
51source = "registry+https://github.com/rust-lang/crates.io-index"
52checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
53dependencies = [
54 "objc2-encode",
55]
56
57[[package]]
58name = "objc2-core-foundation"
59version = "0.3.2"
60source = "registry+https://github.com/rust-lang/crates.io-index"
61checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
62dependencies = [
63 "bitflags",
64 "dispatch2",
65 "objc2",
66]
67
68[[package]]
69name = "objc2-encode"
70version = "4.1.0"
71source = "registry+https://github.com/rust-lang/crates.io-index"
72checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
73
74[[package]]
75name = "objc2-foundation"
76version = "0.3.2"
77source = "registry+https://github.com/rust-lang/crates.io-index"
78checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
79dependencies = [
80 "bitflags",
81 "objc2",
82]
83
84[[package]]
85name = "objc2-io-kit"
86version = "0.3.2"
87source = "registry+https://github.com/rust-lang/crates.io-index"
88checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
89dependencies = [
90 "libc",
91 "objc2-core-foundation",
92]
93
94[[package]]
95name = "objc2-open-directory"
96version = "0.3.2"
97source = "registry+https://github.com/rust-lang/crates.io-index"
98checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
99dependencies = [
100 "objc2",
101 "objc2-core-foundation",
102 "objc2-foundation",
103]
104
105[[package]]
106name = "proc-macro2"
107version = "1.0.106"
108source = "registry+https://github.com/rust-lang/crates.io-index"
109checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
110dependencies = [
111 "unicode-ident",
112]
113
114[[package]]
115name = "quote"
116version = "1.0.45"
117source = "registry+https://github.com/rust-lang/crates.io-index"
118checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
119dependencies = [
120 "proc-macro2",
121]
122
123[[package]]
124name = "serde"
125version = "1.0.228"
126source = "registry+https://github.com/rust-lang/crates.io-index"
127checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
128dependencies = [
129 "serde_core",
130 "serde_derive",
131]
132
133[[package]]
134name = "serde_core"
135version = "1.0.228"
136source = "registry+https://github.com/rust-lang/crates.io-index"
137checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
138dependencies = [
139 "serde_derive",
140]
141
142[[package]]
143name = "serde_derive"
144version = "1.0.228"
145source = "registry+https://github.com/rust-lang/crates.io-index"
146checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
147dependencies = [
148 "proc-macro2",
149 "quote",
150 "syn",
151]
152
153[[package]]
154name = "serde_json"
155version = "1.0.150"
156source = "registry+https://github.com/rust-lang/crates.io-index"
157checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
158dependencies = [
159 "itoa",
160 "memchr",
161 "serde",
162 "serde_core",
163 "zmij",
164]
165
166[[package]]
167name = "syn"
168version = "2.0.118"
169source = "registry+https://github.com/rust-lang/crates.io-index"
170checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
171dependencies = [
172 "proc-macro2",
173 "quote",
174 "unicode-ident",
175]
176
177[[package]]
178name = "sysinfo"
179version = "0.39.3"
180source = "registry+https://github.com/rust-lang/crates.io-index"
181checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
182dependencies = [
183 "libc",
184 "memchr",
185 "ntapi",
186 "objc2-core-foundation",
187 "objc2-io-kit",
188 "objc2-open-directory",
189 "windows",
190]
191
192[[package]]
193name = "t3-resource-monitor"
194version = "0.1.0"
195dependencies = [
196 "serde",
197 "serde_json",
198 "sysinfo",
199]
200
201[[package]]
202name = "unicode-ident"
203version = "1.0.24"
204source = "registry+https://github.com/rust-lang/crates.io-index"
205checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
206
207[[package]]
208name = "winapi"
209version = "0.3.9"
210source = "registry+https://github.com/rust-lang/crates.io-index"
211checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
212dependencies = [
213 "winapi-i686-pc-windows-gnu",
214 "winapi-x86_64-pc-windows-gnu",
215]
216
217[[package]]
218name = "winapi-i686-pc-windows-gnu"
219version = "0.4.0"
220source = "registry+https://github.com/rust-lang/crates.io-index"
221checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
222
223[[package]]
224name = "winapi-x86_64-pc-windows-gnu"
225version = "0.4.0"
226source = "registry+https://github.com/rust-lang/crates.io-index"
227checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
228
229[[package]]
230name = "windows"
231version = "0.62.2"
232source = "registry+https://github.com/rust-lang/crates.io-index"
233checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
234dependencies = [
235 "windows-collections",
236 "windows-core",
237 "windows-future",
238 "windows-numerics",
239]
240
241[[package]]
242name = "windows-collections"
243version = "0.3.2"
244source = "registry+https://github.com/rust-lang/crates.io-index"
245checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
246dependencies = [
247 "windows-core",
248]
249
250[[package]]
251name = "windows-core"
252version = "0.62.2"
253source = "registry+https://github.com/rust-lang/crates.io-index"
254checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
255dependencies = [
256 "windows-implement",
257 "windows-interface",
258 "windows-link",
259 "windows-result",
260 "windows-strings",
261]
262
263[[package]]
264name = "windows-future"
265version = "0.3.2"
266source = "registry+https://github.com/rust-lang/crates.io-index"
267checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
268dependencies = [
269 "windows-core",
270 "windows-link",
271 "windows-threading",
272]
273
274[[package]]
275name = "windows-implement"
276version = "0.60.2"
277source = "registry+https://github.com/rust-lang/crates.io-index"
278checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
279dependencies = [
280 "proc-macro2",
281 "quote",
282 "syn",
283]
284
285[[package]]
286name = "windows-interface"
287version = "0.59.3"
288source = "registry+https://github.com/rust-lang/crates.io-index"
289checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
290dependencies = [
291 "proc-macro2",
292 "quote",
293 "syn",
294]
295
296[[package]]
297name = "windows-link"
298version = "0.2.1"
299source = "registry+https://github.com/rust-lang/crates.io-index"
300checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
301
302[[package]]
303name = "windows-numerics"
304version = "0.3.1"
305source = "registry+https://github.com/rust-lang/crates.io-index"
306checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
307dependencies = [
308 "windows-core",
309 "windows-link",
310]
311
312[[package]]
313name = "windows-result"
314version = "0.4.1"
315source = "registry+https://github.com/rust-lang/crates.io-index"
316checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
317dependencies = [
318 "windows-link",
319]
320
321[[package]]
322name = "windows-strings"
323version = "0.5.1"
324source = "registry+https://github.com/rust-lang/crates.io-index"
325checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
326dependencies = [
327 "windows-link",
328]
329
330[[package]]
331name = "windows-threading"
332version = "0.2.1"
333source = "registry+https://github.com/rust-lang/crates.io-index"
334checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
335dependencies = [
336 "windows-link",
337]
338
339[[package]]
340name = "zmij"
341version = "1.0.21"
342source = "registry+https://github.com/rust-lang/crates.io-index"
343checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
native/resource-monitor/Cargo.toml+17−0
@@ -0,0 +1,17 @@
1[package]
2name = "t3-resource-monitor"
3version = "0.1.0"
4edition = "2024"
5license = "MIT"
6publish = false
7
8[dependencies]
9serde = { version = "1.0.228", features = ["derive"] }
10serde_json = "1.0.150"
11sysinfo = "0.39.3"
12
13[profile.release]
14codegen-units = 1
15lto = "thin"
16panic = "abort"
17strip = true
native/resource-monitor/src/main.rs+560−0
@@ -0,0 +1,560 @@
1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::io::{self, BufRead, BufWriter, Write};
4use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
5use std::thread;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
8
9const PROTOCOL_VERSION: u32 = 1;
10const MIN_SAMPLE_INTERVAL_MS: u64 = 250;
11const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000;
12const EXTERNAL_PROCESS_START_TOLERANCE_MS: u64 = 2_000;
13
14#[derive(Debug, Clone, Deserialize)]
15#[serde(rename_all = "camelCase")]
16struct 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)]
28enum 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
49impl 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
60enum Input {
61 Command(Command),
62 Invalid(String),
63}
64
65#[derive(Debug, Serialize)]
66#[serde(rename_all = "camelCase")]
67struct 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")]
79struct 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")]
92enum IoSemantics {
93 Storage,
94 AllIo,
95}
96
97#[derive(Debug, Serialize)]
98#[serde(rename_all = "camelCase")]
99struct 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")]
118struct 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")]
135struct 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)]
145struct CollectorConfig {
146 root_pid: u32,
147 sample_interval: Duration,
148 external_processes: HashMap<u32, Option<u64>>,
149}
150
151struct Collector {
152 system: System,
153 sequence: u64,
154}
155
156impl 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
247fn 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
256fn 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
265fn 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
291fn io_semantics() -> IoSemantics {
292 if cfg!(target_os = "windows") {
293 IoSemantics::AllIo
294 } else {
295 IoSemantics::Storage
296 }
297}
298
299fn unix_time_ms() -> u64 {
300 SystemTime::now()
301 .duration_since(UNIX_EPOCH)
302 .unwrap_or_default()
303 .as_millis() as u64
304}
305
306fn 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
310fn 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
347fn 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
353fn 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
371fn 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)]
485mod 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
16 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",16 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
17 "build:marketing": "vp run --filter @t3tools/marketing build",17 "build:marketing": "vp run --filter @t3tools/marketing build",
18 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",18 "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",
19 "typecheck": "vp run -r --concurrency-limit 2 typecheck",20 "typecheck": "vp run -r --concurrency-limit 2 typecheck",
20 "tc": "vp run -r --concurrency-limit 2 typecheck",21 "tc": "vp run -r --concurrency-limit 2 typecheck",
21 "lint": "vp lint --report-unused-disable-directives",22 "lint": "vp lint --report-unused-disable-directives",
22 "lint:mobile": "node scripts/mobile-native-static-check.ts",23 "lint:mobile": "node scripts/mobile-native-static-check.ts",
23 "test": "vp run -r test",24 "test": "vp run -r test",
25 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",
24 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",26 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",
25 "fmt": "vp fmt",27 "fmt": "vp fmt",
26 "fmt:check": "vp fmt --check",28 "fmt:check": "vp fmt --check",
packages/client-runtime/src/wsRpcClient.ts+24−0
@@ -171,6 +171,18 @@ export interface WsRpcClient { · 170 unmodified lines
171 readonly getProcessResourceHistory: RpcUnaryMethod<171 readonly getProcessResourceHistory: RpcUnaryMethod<
172 typeof WS_METHODS.serverGetProcessResourceHistory172 typeof WS_METHODS.serverGetProcessResourceHistory
173 >;173 >;
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 >;
174 readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>;186 readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>;
175 readonly reportClientActivity: RpcUnaryMethod<typeof WS_METHODS.serverReportClientActivity>;187 readonly reportClientActivity: RpcUnaryMethod<typeof WS_METHODS.serverReportClientActivity>;
176 readonly reportHostPowerState: RpcUnaryMethod<typeof WS_METHODS.serverReportHostPowerState>;188 readonly reportHostPowerState: RpcUnaryMethod<typeof WS_METHODS.serverReportHostPowerState>;
@@ -395,6 +407,18 @@ export function createWsRpcClient( · 218 unmodified lines
395 transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})),407 transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})),
396 getProcessResourceHistory: (input) =>408 getProcessResourceHistory: (input) =>
397 transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)),409 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 ),
398 signalProcess: (input) =>422 signalProcess: (input) =>
399 transport.request((client) => client[WS_METHODS.serverSignalProcess](input)),423 transport.request((client) => client[WS_METHODS.serverSignalProcess](input)),
400 reportClientActivity: (input) =>424 reportClientActivity: (input) =>
packages/contracts/src/desktopBootstrap.ts+3−1
@@ -1,6 +1,6 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2 2
3import { PortSchema } from "./baseSchemas.ts";3import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
4 4
5export const DesktopBackendBootstrap = Schema.Struct({5export const DesktopBackendBootstrap = Schema.Struct({
6 mode: Schema.Literal("desktop"),6 mode: Schema.Literal("desktop"),
@@ -13,6 +13,8 @@ export const DesktopBackendBootstrap = Schema.Struct({ · 6 unmodified lines
13 tailscaleServePort: PortSchema,13 tailscaleServePort: PortSchema,
14 otlpTracesUrl: Schema.optional(Schema.String),14 otlpTracesUrl: Schema.optional(Schema.String),
15 otlpMetricsUrl: Schema.optional(Schema.String),15 otlpMetricsUrl: Schema.optional(Schema.String),
16 desktopTelemetryFd: Schema.optionalKey(PositiveInt),
17 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
16});18});
17 19
18export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;20export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;
packages/contracts/src/index.ts+1−0
@@ -26,4 +26,5 @@ export * from "./assets.ts"; · 25 unmodified lines
26export * from "./review.ts";26export * from "./review.ts";
27export * from "./preview.ts";27export * from "./preview.ts";
28export * from "./previewAutomation.ts";28export * from "./previewAutomation.ts";
29export * from "./resourceTelemetry.ts";
29export * from "./rpc.ts";30export * from "./rpc.ts";
packages/contracts/src/ipc.ts+11−0
@@ -32,6 +32,12 @@ import type { · 31 unmodified lines
32 ProjectWriteFileResult,32 ProjectWriteFileResult,
33} from "./project.ts";33} from "./project.ts";
34import type { ProviderInstanceId } from "./providerInstance.ts";34import type { ProviderInstanceId } from "./providerInstance.ts";
35import type {
36 ResourceTelemetryHistory,
37 ResourceTelemetryHistoryInput,
38 ResourceTelemetryRetryResult,
39 ResourceTelemetrySnapshot,
40} from "./resourceTelemetry.ts";
35import type {41import type {
36 ServerConfig,42 ServerConfig,
37 ServerProcessDiagnosticsResult,43 ServerProcessDiagnosticsResult,
@@ -1078,6 +1084,11 @@ export interface LocalApi { · 1040 unmodified lines
1078 getProcessResourceHistory: (1084 getProcessResourceHistory: (
1079 input: ServerProcessResourceHistoryInput,1085 input: ServerProcessResourceHistoryInput,
1080 ) => Promise<ServerProcessResourceHistoryResult>;1086 ) => Promise<ServerProcessResourceHistoryResult>;
1087 getResourceTelemetry: () => Promise<ResourceTelemetrySnapshot>;
1088 getResourceTelemetryHistory: (
1089 input: ResourceTelemetryHistoryInput,
1090 ) => Promise<ResourceTelemetryHistory>;
1091 retryResourceTelemetry: () => Promise<ResourceTelemetryRetryResult>;
1081 signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>;1092 signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>;
1082 };1093 };
1083}1094}
packages/contracts/src/resourceTelemetry.ts+365−0
@@ -0,0 +1,365 @@
1import * as Schema from "effect/Schema";
2
3import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { HostPowerSnapshot } from "./background.ts";
5
6export const RESOURCE_MONITOR_PROTOCOL_VERSION = 1 as const;
7
8export const ResourceTelemetryIoSemantics = Schema.Literals([
9 "storage",
10 "logical",
11 "all-io",
12 "unavailable",
13]);
14export type ResourceTelemetryIoSemantics = typeof ResourceTelemetryIoSemantics.Type;
15
16export 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]);
28export type ResourceTelemetryProcessCategory = typeof ResourceTelemetryProcessCategory.Type;
29
30export const ResourceTelemetrySourceStatus = Schema.Literals([
31 "starting",
32 "healthy",
33 "degraded",
34 "unavailable",
35 "stopped",
36]);
37export type ResourceTelemetrySourceStatus = typeof ResourceTelemetrySourceStatus.Type;
38
39export const ResourceTelemetryProcessIdentity = Schema.Struct({
40 pid: PositiveInt,
41 startTimeMs: NonNegativeInt,
42});
43export type ResourceTelemetryProcessIdentity = typeof ResourceTelemetryProcessIdentity.Type;
44
45export const ResourceMonitorExternalProcess = Schema.Struct({
46 pid: PositiveInt,
47 startTimeMs: Schema.optionalKey(NonNegativeInt),
48});
49export type ResourceMonitorExternalProcess = typeof ResourceMonitorExternalProcess.Type;
50
51export 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});
60export type ResourceMonitorCapabilities = typeof ResourceMonitorCapabilities.Type;
61
62export 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});
78export type ResourceMonitorProcessSample = typeof ResourceMonitorProcessSample.Type;
79
80export 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});
87export type ResourceMonitorConfigureCommand = typeof ResourceMonitorConfigureCommand.Type;
88
89export const ResourceMonitorSetExternalProcessesCommand = Schema.Struct({
90 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
91 type: Schema.Literal("setExternalProcesses"),
92 processes: Schema.Array(ResourceMonitorExternalProcess),
93});
94export type ResourceMonitorSetExternalProcessesCommand =
95 typeof ResourceMonitorSetExternalProcessesCommand.Type;
96
97export const ResourceMonitorSampleNowCommand = Schema.Struct({
98 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
99 type: Schema.Literal("sampleNow"),
100 requestId: TrimmedNonEmptyString,
101});
102export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type;
103
104export const ResourceMonitorShutdownCommand = Schema.Struct({
105 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
106 type: Schema.Literal("shutdown"),
107});
108export type ResourceMonitorShutdownCommand = typeof ResourceMonitorShutdownCommand.Type;
109
110export const ResourceMonitorCommand = Schema.Union([
111 ResourceMonitorConfigureCommand,
112 ResourceMonitorSetExternalProcessesCommand,
113 ResourceMonitorSampleNowCommand,
114 ResourceMonitorShutdownCommand,
115]);
116export type ResourceMonitorCommand = typeof ResourceMonitorCommand.Type;
117
118export 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});
127export type ResourceMonitorHelloEvent = typeof ResourceMonitorHelloEvent.Type;
128
129export 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});
141export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type;
142
143export 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});
150export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type;
151
152export const ResourceMonitorEvent = Schema.Union([
153 ResourceMonitorHelloEvent,
154 ResourceMonitorSnapshotEvent,
155 ResourceMonitorErrorEvent,
156]);
157export type ResourceMonitorEvent = typeof ResourceMonitorEvent.Type;
158
159export 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]);
170export type DesktopElectronProcessType = typeof DesktopElectronProcessType.Type;
171
172export 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});
184export type DesktopElectronProcessMetric = typeof DesktopElectronProcessMetric.Type;
185
186export 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});
195export type DesktopHostTelemetrySnapshot = typeof DesktopHostTelemetrySnapshot.Type;
196
197export const DesktopHostTelemetryHello = Schema.Struct({
198 version: Schema.Literal(1),
199 type: Schema.Literal("desktopTelemetryHello"),
200 electronPid: PositiveInt,
201});
202export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type;
203
204export const DesktopHostTelemetryMessage = Schema.Union([
205 DesktopHostTelemetryHello,
206 DesktopHostTelemetrySnapshot,
207]);
208export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type;
209
210export 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});
236export type ResourceTelemetryProcess = typeof ResourceTelemetryProcess.Type;
237
238export 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});
251export type ResourceTelemetryAggregate = typeof ResourceTelemetryAggregate.Type;
252
253export const ResourceTelemetryGroups = Schema.Struct({
254 backend: ResourceTelemetryAggregate,
255 electron: ResourceTelemetryAggregate,
256 monitor: ResourceTelemetryAggregate,
257 allT3: ResourceTelemetryAggregate,
258});
259export type ResourceTelemetryGroups = typeof ResourceTelemetryGroups.Type;
260
261export const ResourceTelemetrySourceHealth = Schema.Struct({
262 status: ResourceTelemetrySourceStatus,
263 lastSampleAt: Schema.Option(Schema.DateTimeUtc),
264 lastError: Schema.Option(TrimmedNonEmptyString),
265});
266export type ResourceTelemetrySourceHealth = typeof ResourceTelemetrySourceHealth.Type;
267
268export 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});
279export type ResourceTelemetryHealth = typeof ResourceTelemetryHealth.Type;
280
281export const ResourceAttributionEntry = Schema.Struct({
282 component: TrimmedNonEmptyString,
283 operation: TrimmedNonEmptyString,
284 logicalReadBytes: NonNegativeInt,
285 logicalWriteBytes: NonNegativeInt,
286 count: NonNegativeInt,
287 durationMs: NonNegativeInt,
288});
289export type ResourceAttributionEntry = typeof ResourceAttributionEntry.Type;
290
291export const ResourceAttributionSnapshot = Schema.Struct({
292 readAt: Schema.DateTimeUtc,
293 entries: Schema.Array(ResourceAttributionEntry),
294});
295export type ResourceAttributionSnapshot = typeof ResourceAttributionSnapshot.Type;
296
297export 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});
307export type ResourceTelemetrySnapshot = typeof ResourceTelemetrySnapshot.Type;
308
309export const ResourceTelemetryHistoryInput = Schema.Struct({
310 windowMs: NonNegativeInt,
311 bucketMs: NonNegativeInt,
312});
313export type ResourceTelemetryHistoryInput = typeof ResourceTelemetryHistoryInput.Type;
314
315export 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});
325export type ResourceTelemetryHistoryBucket = typeof ResourceTelemetryHistoryBucket.Type;
326
327export 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});
347export type ResourceTelemetryProcessSummary = typeof ResourceTelemetryProcessSummary.Type;
348
349export 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});
359export type ResourceTelemetryHistory = typeof ResourceTelemetryHistory.Type;
360
361export const ResourceTelemetryRetryResult = Schema.Struct({
362 accepted: Schema.Boolean,
363 snapshot: ResourceTelemetrySnapshot,
364});
365export type ResourceTelemetryRetryResult = typeof ResourceTelemetryRetryResult.Type;
packages/contracts/src/rpc.ts+42−0
@@ -134,6 +134,12 @@ import { · 133 unmodified lines
134 ServerUpsertKeybindingInput,134 ServerUpsertKeybindingInput,
135 ServerUpsertKeybindingResult,135 ServerUpsertKeybindingResult,
136} from "./server.ts";136} from "./server.ts";
137import {
138 ResourceTelemetryHistory,
139 ResourceTelemetryHistoryInput,
140 ResourceTelemetryRetryResult,
141 ResourceTelemetrySnapshot,
142} from "./resourceTelemetry.ts";
137import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";143import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
138import {144import {
139 SourceControlCloneRepositoryInput,145 SourceControlCloneRepositoryInput,
@@ -215,6 +221,9 @@ export const WS_METHODS = { · 75 unmodified lines
215 serverGetTraceDiagnostics: "server.getTraceDiagnostics",221 serverGetTraceDiagnostics: "server.getTraceDiagnostics",
216 serverGetProcessDiagnostics: "server.getProcessDiagnostics",222 serverGetProcessDiagnostics: "server.getProcessDiagnostics",
217 serverGetProcessResourceHistory: "server.getProcessResourceHistory",223 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
224 serverGetResourceTelemetry: "server.getResourceTelemetry",
225 serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
226 serverRetryResourceTelemetry: "server.retryResourceTelemetry",
218 serverSignalProcess: "server.signalProcess",227 serverSignalProcess: "server.signalProcess",
219 serverReportClientActivity: "server.reportClientActivity",228 serverReportClientActivity: "server.reportClientActivity",
220 serverReportHostPowerState: "server.reportHostPowerState",229 serverReportHostPowerState: "server.reportHostPowerState",
@@ -239,6 +248,7 @@ export const WS_METHODS = { · 18 unmodified lines
239 subscribeServerLifecycle: "subscribeServerLifecycle",248 subscribeServerLifecycle: "subscribeServerLifecycle",
240 subscribeAuthAccess: "subscribeAuthAccess",249 subscribeAuthAccess: "subscribeAuthAccess",
241 subscribeBackgroundPolicy: "subscribeBackgroundPolicy",250 subscribeBackgroundPolicy: "subscribeBackgroundPolicy",
251 subscribeResourceTelemetry: "subscribeResourceTelemetry",
242} as const;252} as const;
243 253
244export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {254export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
@@ -318,6 +328,27 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( · 73 unmodified lines
318 },328 },
319);329);
320 330
331export const WsServerGetResourceTelemetryRpc = Rpc.make(WS_METHODS.serverGetResourceTelemetry, {
332 payload: Schema.Struct({}),
333 success: ResourceTelemetrySnapshot,
334 error: EnvironmentAuthorizationError,
335});
336
337export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make(
338 WS_METHODS.serverGetResourceTelemetryHistory,
339 {
340 payload: ResourceTelemetryHistoryInput,
341 success: ResourceTelemetryHistory,
342 error: EnvironmentAuthorizationError,
343 },
344);
345
346export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, {
347 payload: Schema.Struct({}),
348 success: ResourceTelemetryRetryResult,
349 error: EnvironmentAuthorizationError,
350});
351
321export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {352export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
322 payload: ServerSignalProcessInput,353 payload: ServerSignalProcessInput,
323 success: ServerSignalProcessResult,354 success: ServerSignalProcessResult,
@@ -710,6 +741,13 @@ export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackg · 386 unmodified lines
710 stream: true,741 stream: true,
711});742});
712 743
744export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, {
745 payload: Schema.Struct({}),
746 success: ResourceTelemetrySnapshot,
747 error: EnvironmentAuthorizationError,
748 stream: true,
749});
750
713export const WsRpcGroup = RpcGroup.make(751export const WsRpcGroup = RpcGroup.make(
714 WsServerGetConfigRpc,752 WsServerGetConfigRpc,
715 WsServerRefreshProvidersRpc,753 WsServerRefreshProvidersRpc,
@@ -722,6 +760,9 @@ export const WsRpcGroup = RpcGroup.make( · 6 unmodified lines
722 WsServerGetTraceDiagnosticsRpc,760 WsServerGetTraceDiagnosticsRpc,
723 WsServerGetProcessDiagnosticsRpc,761 WsServerGetProcessDiagnosticsRpc,
724 WsServerGetProcessResourceHistoryRpc,762 WsServerGetProcessResourceHistoryRpc,
763 WsServerGetResourceTelemetryRpc,
764 WsServerGetResourceTelemetryHistoryRpc,
765 WsServerRetryResourceTelemetryRpc,
725 WsServerSignalProcessRpc,766 WsServerSignalProcessRpc,
726 WsServerReportClientActivityRpc,767 WsServerReportClientActivityRpc,
727 WsServerReportHostPowerStateRpc,768 WsServerReportHostPowerStateRpc,
@@ -776,6 +817,7 @@ export const WsRpcGroup = RpcGroup.make( · 48 unmodified lines
776 WsSubscribeServerLifecycleRpc,817 WsSubscribeServerLifecycleRpc,
777 WsSubscribeAuthAccessRpc,818 WsSubscribeAuthAccessRpc,
778 WsSubscribeBackgroundPolicyRpc,819 WsSubscribeBackgroundPolicyRpc,
820 WsSubscribeResourceTelemetryRpc,
779 WsOrchestrationDispatchCommandRpc,821 WsOrchestrationDispatchCommandRpc,
780 WsOrchestrationGetTurnDiffRpc,822 WsOrchestrationGetTurnDiffRpc,
781 WsOrchestrationGetFullThreadDiffRpc,823 WsOrchestrationGetFullThreadDiffRpc,
packages/contracts/src/server.ts+2−0
@@ -302,6 +302,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type; · 301 unmodified lines
302 302
303export const ServerProcessDiagnosticsEntry = Schema.Struct({303export const ServerProcessDiagnosticsEntry = Schema.Struct({
304 pid: PositiveInt,304 pid: PositiveInt,
305 startTimeMs: NonNegativeInt,
305 ppid: NonNegativeInt,306 ppid: NonNegativeInt,
306 pgid: Schema.Option(Schema.Int),307 pgid: Schema.Option(Schema.Int),
307 status: TrimmedNonEmptyString,308 status: TrimmedNonEmptyString,
@@ -383,6 +384,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis · 75 unmodified lines
383 384
384export const ServerSignalProcessInput = Schema.Struct({385export const ServerSignalProcessInput = Schema.Struct({
385 pid: PositiveInt,386 pid: PositiveInt,
387 startTimeMs: NonNegativeInt,
386 signal: ServerProcessSignal,388 signal: ServerProcessSignal,
387});389});
388export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type;390export 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
8import * as Logger from "effect/Logger";8import * as Logger from "effect/Logger";
9import * as Order from "effect/Order";9import * as Order from "effect/Order";
10import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import * as Ref from "effect/Ref";
11import * as References from "effect/References";12import * as References from "effect/References";
12import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
13import * as Tracer from "effect/Tracer";14import * as Tracer from "effect/Tracer";
@@ -18,6 +19,7 @@ import { · 4 unmodified lines
18 makeLocalFileTracer,19 makeLocalFileTracer,
19 makeTraceSink,20 makeTraceSink,
20 type TraceRecord,21 type TraceRecord,
22 type TraceSinkFlushStats,
21} from "./observability.ts";23} from "./observability.ts";
22 24
23describe("causeErrorTag", () => {25describe("causeErrorTag", () => {
@@ -167,6 +169,34 @@ describe("observability", () => { · 143 unmodified lines
167 ),169 ),
168 );170 );
169 171
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
170 it.effect("rotates the trace file when the configured max size is exceeded", () =>200 it.effect("rotates the trace file when the configured max size is exceeded", () =>
171 Effect.scoped(201 Effect.scoped(
172 Effect.gen(function* () {202 Effect.gen(function* () {
packages/shared/src/observability.ts+38−3
@@ -9,6 +9,7 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; · 8 unmodified lines
9import { RotatingFileSink } from "./logging.ts";9import { RotatingFileSink } from "./logging.ts";
10 10
11const FLUSH_BUFFER_THRESHOLD = 32;11const FLUSH_BUFFER_THRESHOLD = 32;
12const textEncoder = new TextEncoder();
12 13
13export type TraceAttributes = Readonly<Record<string, unknown>>;14export type TraceAttributes = Readonly<Record<string, unknown>>;
14 15
@@ -94,6 +95,13 @@ export interface TraceSinkOptions { · 79 unmodified lines
94 readonly maxBytes: number;95 readonly maxBytes: number;
95 readonly maxFiles: number;96 readonly maxFiles: number;
96 readonly batchWindowMs: number;97 readonly batchWindowMs: number;
98 readonly onFlush?: (stats: TraceSinkFlushStats) => Effect.Effect<void>;
99}
100
101export interface TraceSinkFlushStats {
102 readonly logicalWriteBytes: number;
103 readonly count: number;
104 readonly durationMs: number;
97}105}
98 106
99export interface TraceSink {107export interface TraceSink {
@@ -263,23 +271,50 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac · 163 unmodified lines
263 });271 });
264 272
265 let buffer: Array<string> = [];273 let buffer: Array<string> = [];
274 let pendingFlushStats: TraceSinkFlushStats = {
275 logicalWriteBytes: 0,
276 count: 0,
277 durationMs: 0,
278 };
266 279
267 const flushUnsafe = () => {280 const flushUnsafe = () => {
268 if (buffer.length === 0) {281 if (buffer.length === 0) {
269 return;282 return;
270 }283 }
271 284
272 const chunk = buffer.join("");285 const records = buffer;
286 const chunk = records.join("");
273 buffer = [];287 buffer = [];
288 const startedAt = performance.now();
274 289
275 try {290 try {
276 sink.write(chunk);291 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 };
277 } catch {298 } catch {
278 buffer.unshift(chunk);299 buffer.unshift(...records);
279 }300 }
280 };301 };
281 302
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 );
283 318
284 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));319 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
285 yield* Effect.forkScoped(320 yield* Effect.forkScoped(
scripts/build-desktop-artifact.test.ts+24−0
@@ -9,12 +9,15 @@ import { · 8 unmodified lines
9 createStageWorkspaceConfig,9 createStageWorkspaceConfig,
10 createStagePnpmConfig,10 createStagePnpmConfig,
11 DESKTOP_ASAR_UNPACK,11 DESKTOP_ASAR_UNPACK,
12 DESKTOP_EXTRA_RESOURCES,
12 resolveDesktopRuntimeDependencies,13 resolveDesktopRuntimeDependencies,
13 resolveFffNativeDependencies,14 resolveFffNativeDependencies,
14 resolveBuildOptions,15 resolveBuildOptions,
15 resolveDesktopBuildIconAssets,16 resolveDesktopBuildIconAssets,
16 resolveDesktopProductName,17 resolveDesktopProductName,
17 resolveDesktopUpdateChannel,18 resolveDesktopUpdateChannel,
19 resolveResourceMonitorRustTargets,
20 resourceMonitorExecutableName,
18 resolveGitHubPublishConfig,21 resolveGitHubPublishConfig,
19 resolveMockUpdateServerPort,22 resolveMockUpdateServerPort,
20 resolveMockUpdateServerUrl,23 resolveMockUpdateServerUrl,
@@ -175,6 +178,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { · 154 unmodified lines
175 assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]);178 assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]);
176 });179 });
177 180
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
178 it("promotes target fff binaries to direct staged dependencies", () => {202 it("promotes target fff binaries to direct staged dependencies", () => {
179 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {203 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {
180 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",204 "@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
72 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;72 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;
73}73}
74 74
75export 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
91export function resourceMonitorExecutableName(platform: typeof BuildPlatform.Type): string {
92 return platform === "win" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
93}
94
75const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {95const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {
76 mac: {96 mac: {
77 cliFlag: "--mac",97 cliFlag: "--mac",
@@ -292,6 +312,12 @@ interface StagePackageJson { · 214 unmodified lines
292 312
293export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const;313export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const;
294export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const;314export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const;
315export const DESKTOP_EXTRA_RESOURCES = [
316 {
317 from: "apps/desktop/prod-resources/resource-monitor",
318 to: "resource-monitor",
319 },
320] as const;
295 321
296export function resolveFffNativeDependencies(322export function resolveFffNativeDependencies(
297 platform: typeof BuildPlatform.Type,323 platform: typeof BuildPlatform.Type,
@@ -497,6 +523,78 @@ const runCommand = Effect.fn("runCommand")(function* ( · 199 unmodified lines
497 }523 }
498});524});
499 525
526const 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
500function generateMacIconSet(598function generateMacIconSet(
501 sourcePng: string,599 sourcePng: string,
502 targetIcns: string,600 targetIcns: string,
@@ -755,6 +853,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 252 unmodified lines
755 directories: {853 directories: {
756 buildResources: "apps/desktop/resources",854 buildResources: "apps/desktop/resources",
757 },855 },
856 extraResources: DESKTOP_EXTRA_RESOURCES,
758 };857 };
759 const updateChannel = resolveDesktopUpdateChannel(version);858 const updateChannel = resolveDesktopUpdateChannel(version);
760 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);859 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
@@ -941,6 +1040,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 180 unmodified lines
941 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));1040 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
942 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);1041 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
943 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));1042 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 });
944 1050
945 yield* assertPlatformBuildResources(1051 yield* assertPlatformBuildResources(
946 options.platform,1052 options.platform,