| author | |
| committer | |
| log | 49c0d96edf280f1fe6750a83161a3a2cff0f25ef |
| tree | 7cdd1dbdb6fda6a1459dc5a0367f709d86abf54b |
| parent | 3137c2b162109640343a6ff88120609e061f3782 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>122 files changed, 18478 insertions(+), 2154 deletions(-)
.github/workflows/ci.yml+14| ... | ... | @@ -22,6 +22,11 @@ jobs: |
| 22 | 22 | cache: true |
| 23 | 23 | run-install: true |
| 24 | 24 | |
| 25 | - name: Setup Rust | |
| 26 | uses: dtolnay/rust-toolchain@stable | |
| 27 | with: | |
| 28 | components: rustfmt | |
| 29 | ||
| 25 | 30 | - name: Ensure Electron runtime is installed |
| 26 | 31 | run: vp run --filter @t3tools/desktop ensure:electron |
| 27 | 32 | |
| ... | ... | @@ -31,6 +36,9 @@ jobs: |
| 31 | 36 | - name: Typecheck |
| 32 | 37 | run: vpr typecheck |
| 33 | 38 | |
| 39 | - name: Check resource monitor formatting | |
| 40 | run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check | |
| 41 | ||
| 34 | 42 | - name: Build desktop pipeline |
| 35 | 43 | run: vp run build:desktop |
| 36 | 44 | |
| ... | ... | @@ -55,12 +63,18 @@ jobs: |
| 55 | 63 | cache: true |
| 56 | 64 | run-install: true |
| 57 | 65 | |
| 66 | - name: Setup Rust | |
| 67 | uses: dtolnay/rust-toolchain@stable | |
| 68 | ||
| 58 | 69 | - name: Ensure Electron runtime is installed |
| 59 | 70 | run: vp run --filter @t3tools/desktop ensure:electron |
| 60 | 71 | |
| 61 | 72 | - name: Test |
| 62 | 73 | run: vp run test |
| 63 | 74 | |
| 75 | - name: Test resource monitor | |
| 76 | run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml | |
| 77 | ||
| 64 | 78 | mobile_native_static_analysis: |
| 65 | 79 | name: Mobile Native Static Analysis |
| 66 | 80 | runs-on: blacksmith-12vcpu-macos-26 |
.github/workflows/release.yml+51| ... | ... | @@ -326,21 +326,29 @@ jobs: |
| 326 | 326 | platform: mac |
| 327 | 327 | target: dmg |
| 328 | 328 | arch: arm64 |
| 329 | rust_target: aarch64-apple-darwin | |
| 330 | resource_key: darwin-arm64 | |
| 329 | 331 | - label: macOS x64 |
| 330 | 332 | runner: blacksmith-12vcpu-macos-26 |
| 331 | 333 | platform: mac |
| 332 | 334 | target: dmg |
| 333 | 335 | arch: x64 |
| 336 | rust_target: x86_64-apple-darwin | |
| 337 | resource_key: darwin-x64 | |
| 334 | 338 | - label: Linux x64 |
| 335 | 339 | runner: blacksmith-32vcpu-ubuntu-2404 |
| 336 | 340 | platform: linux |
| 337 | 341 | target: AppImage |
| 338 | 342 | arch: x64 |
| 343 | rust_target: x86_64-unknown-linux-gnu | |
| 344 | resource_key: linux-x64 | |
| 339 | 345 | - label: Windows x64 |
| 340 | 346 | runner: blacksmith-32vcpu-windows-2025 |
| 341 | 347 | platform: win |
| 342 | 348 | target: nsis |
| 343 | 349 | arch: x64 |
| 350 | rust_target: x86_64-pc-windows-msvc | |
| 351 | resource_key: win32-x64 | |
| 344 | 352 | # - label: Windows arm64 |
| 345 | 353 | # runner: windows-11-arm |
| 346 | 354 | # platform: win |
| ... | ... | @@ -360,6 +368,11 @@ jobs: |
| 360 | 368 | cache: true |
| 361 | 369 | run-install: true |
| 362 | 370 | |
| 371 | - name: Setup Rust | |
| 372 | uses: dtolnay/rust-toolchain@stable | |
| 373 | with: | |
| 374 | targets: ${{ matrix.rust_target }} | |
| 375 | ||
| 363 | 376 | - name: Download relay client tracing config |
| 364 | 377 | uses: actions/download-artifact@v8 |
| 365 | 378 | with: |
| ... | ... | @@ -598,6 +611,19 @@ jobs: |
| 598 | 611 | # done |
| 599 | 612 | # fi |
| 600 | 613 | |
| 614 | - name: Collect resource monitor | |
| 615 | shell: bash | |
| 616 | run: | | |
| 617 | set -euo pipefail | |
| 618 | binary_name="t3-resource-monitor" | |
| 619 | if [[ "${{ matrix.platform }}" == "win" ]]; then | |
| 620 | binary_name="${binary_name}.exe" | |
| 621 | fi | |
| 622 | source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" | |
| 623 | target_dir="resource-monitor-publish/${{ matrix.resource_key }}" | |
| 624 | mkdir -p "$target_dir" | |
| 625 | cp "$source_path" "$target_dir/$binary_name" | |
| 626 | ||
| 601 | 627 | - name: Upload build artifacts |
| 602 | 628 | uses: actions/upload-artifact@v7 |
| 603 | 629 | with: |
| ... | ... | @@ -605,6 +631,13 @@ jobs: |
| 605 | 631 | path: release-publish/* |
| 606 | 632 | if-no-files-found: error |
| 607 | 633 | |
| 634 | - name: Upload resource monitor | |
| 635 | uses: actions/upload-artifact@v7 | |
| 636 | with: | |
| 637 | name: resource-monitor-${{ matrix.resource_key }} | |
| 638 | path: resource-monitor-publish/${{ matrix.resource_key }}/* | |
| 639 | if-no-files-found: error | |
| 640 | ||
| 608 | 641 | publish_cli: |
| 609 | 642 | name: Publish CLI to npm |
| 610 | 643 | needs: [preflight, relay_public_config, build] |
| ... | ... | @@ -659,6 +692,24 @@ jobs: |
| 659 | 692 | - name: Build CLI package |
| 660 | 693 | run: vp run --filter t3 build |
| 661 | 694 | |
| 695 | - name: Download resource monitors | |
| 696 | uses: actions/download-artifact@v8 | |
| 697 | with: | |
| 698 | pattern: resource-monitor-* | |
| 699 | path: ${{ runner.temp }}/resource-monitors | |
| 700 | ||
| 701 | - name: Bundle resource monitors into CLI package | |
| 702 | shell: bash | |
| 703 | run: | | |
| 704 | set -euo pipefail | |
| 705 | for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do | |
| 706 | resource_key="${artifact_dir##*/resource-monitor-}" | |
| 707 | target_dir="apps/server/dist/resource-monitor/${resource_key}" | |
| 708 | mkdir -p "$target_dir" | |
| 709 | cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" | |
| 710 | chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true | |
| 711 | done | |
| 712 | ||
| 662 | 713 | - name: Publish CLI package |
| 663 | 714 | run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose |
| 664 | 715 |
.gitignore+1| ... | ... | @@ -30,6 +30,7 @@ dist-electron/ |
| 30 | 30 | .showcase/ |
| 31 | 31 | apps/mobile/.showcase/ |
| 32 | 32 | artifacts/app-store/screenshots/ |
| 33 | native/**/target/ | |
| 33 | 34 | node_modules/ |
| 34 | 35 | .alchemy/ |
| 35 | 36 | *.log |
apps/desktop/src/app/DesktopAppIdentity.test.ts+1| ... | ... | @@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => |
| 55 | 55 | }), |
| 56 | 56 | setAppUserModelId: () => Effect.void, |
| 57 | 57 | requestSingleInstanceLock: Effect.succeed(true), |
| 58 | getAppMetrics: Effect.succeed([]), | |
| 58 | 59 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 59 | 60 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 60 | 61 | setDesktopName: () => Effect.void, |
apps/desktop/src/app/DesktopLifecycle.test.ts+1| ... | ... | @@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => { |
| 30 | 30 | setAboutPanelOptions: () => Effect.void, |
| 31 | 31 | setAppUserModelId: () => Effect.void, |
| 32 | 32 | requestSingleInstanceLock: Effect.succeed(true), |
| 33 | getAppMetrics: Effect.succeed([]), | |
| 33 | 34 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 34 | 35 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 35 | 36 | setDesktopName: () => Effect.void, |
apps/desktop/src/app/DesktopObservability.test.ts+180-14| ... | ... | @@ -49,20 +49,41 @@ const environmentInput = (baseDir: string) => |
| 49 | 49 | runningUnderArm64Translation: false, |
| 50 | 50 | }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; |
| 51 | 51 | |
| 52 | const makeEnvironmentLayer = (baseDir: string) => | |
| 52 | const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) => | |
| 53 | 53 | DesktopEnvironment.layer(environmentInput(baseDir)).pipe( |
| 54 | 54 | Layer.provide( |
| 55 | 55 | Layer.mergeAll( |
| 56 | 56 | NodeServices.layer, |
| 57 | 57 | DesktopConfig.layerTest({ |
| 58 | 58 | T3CODE_HOME: baseDir, |
| 59 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", | |
| 59 | VITE_DEV_SERVER_URL: isDevelopment ? "http://127.0.0.1:5733" : undefined, | |
| 60 | 60 | }), |
| 61 | 61 | ), |
| 62 | 62 | ), |
| 63 | 63 | ); |
| 64 | 64 | |
| 65 | 65 | describe("DesktopObservability", () => { |
| 66 | it("advances a retained output offset instead of repeatedly copying a full head chunk", () => { | |
| 67 | const maxBufferedBytes = 1024 * 1024; | |
| 68 | const initial = DesktopObservability.appendBoundedOutputChunk( | |
| 69 | { | |
| 70 | runId: "test-run", | |
| 71 | startDetails: "pid=123", | |
| 72 | chunks: [], | |
| 73 | byteLength: 0, | |
| 74 | }, | |
| 75 | "stderr", | |
| 76 | new Uint8Array(maxBufferedBytes), | |
| 77 | ); | |
| 78 | const initialBackingBuffer = initial.chunks[0]?.chunk.buffer; | |
| 79 | ||
| 80 | const next = DesktopObservability.appendBoundedOutputChunk(initial, "stderr", Uint8Array.of(1)); | |
| 81 | ||
| 82 | assert.equal(next.chunks[0]?.chunk.buffer, initialBackingBuffer); | |
| 83 | assert.equal(next.chunks[0]?.offset, 1); | |
| 84 | assert.equal(next.byteLength, maxBufferedBytes); | |
| 85 | }); | |
| 86 | ||
| 66 | 87 | it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => |
| 67 | 88 | Effect.gen(function* () { |
| 68 | 89 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -112,26 +133,34 @@ describe("DesktopObservability", () => { |
| 112 | 133 | ), |
| 113 | 134 | ); |
| 114 | 135 | |
| 115 | it.effect("persists backend child output as structured JSON records in development", () => | |
| 136 | it.effect("buffers backend child output and persists it only when a failure is reported", () => | |
| 116 | 137 | Effect.gen(function* () { |
| 117 | 138 | const fileSystem = yield* FileSystem.FileSystem; |
| 118 | 139 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 119 | 140 | prefix: "t3-desktop-backend-output-log-test-", |
| 120 | 141 | }); |
| 121 | const environmentLayer = makeEnvironmentLayer(baseDir); | |
| 142 | const environmentLayer = makeEnvironmentLayer(baseDir, false); | |
| 122 | 143 | const logPath = yield* Effect.gen(function* () { |
| 123 | 144 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 124 | 145 | return environment.path.join(environment.logDir, "server-child.log"); |
| 125 | 146 | }).pipe(Effect.provide(environmentLayer)); |
| 147 | const tracePath = yield* Effect.gen(function* () { | |
| 148 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 149 | return environment.path.join(environment.logDir, "desktop.trace.ndjson"); | |
| 150 | }).pipe(Effect.provide(environmentLayer)); | |
| 126 | 151 | |
| 127 | 152 | yield* Effect.gen(function* () { |
| 128 | 153 | const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; |
| 129 | 154 | const outputLog = yield* factory.forInstance("primary"); |
| 130 | yield* outputLog.writeSessionBoundary({ | |
| 131 | phase: "START", | |
| 155 | yield* outputLog.beginSession({ | |
| 132 | 156 | details: "pid=123 port=3773 cwd=/repo", |
| 133 | 157 | }); |
| 134 | 158 | yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); |
| 159 | assert.isFalse(yield* fileSystem.exists(logPath)); | |
| 160 | yield* outputLog.persistFailure({ details: "code=1" }); | |
| 161 | yield* outputLog.beginSession({ details: "pid=456" }); | |
| 162 | yield* outputLog.writeOutputChunk("stderr", new TextEncoder().encode("normal shutdown\n")); | |
| 163 | yield* outputLog.discardSession; | |
| 135 | 164 | }).pipe( |
| 136 | 165 | Effect.annotateLogs({ runId: "test-run" }), |
| 137 | 166 | Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), |
| ... | ... | @@ -139,16 +168,18 @@ describe("DesktopObservability", () => { |
| 139 | 168 | |
| 140 | 169 | const log = yield* fileSystem.readFileString(logPath); |
| 141 | 170 | const lines = log.trimEnd().split("\n"); |
| 142 | const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); | |
| 171 | const start = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); | |
| 143 | 172 | const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); |
| 173 | const end = yield* decodeDesktopBackendChildLogRecord(lines[2] ?? ""); | |
| 144 | 174 | |
| 145 | assert.equal(boundary.message, "backend child process session start"); | |
| 146 | assert.equal(boundary.level, "INFO"); | |
| 147 | assert.equal(boundary.annotations.component, "desktop-backend-child"); | |
| 148 | assert.equal(boundary.annotations.runId, "test-run"); | |
| 149 | assert.equal(boundary.annotations.instanceId, "primary"); | |
| 150 | assert.equal(boundary.annotations.phase, "START"); | |
| 151 | assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); | |
| 175 | assert.equal(lines.length, 3); | |
| 176 | assert.equal(start.message, "backend child process failure output start"); | |
| 177 | assert.equal(start.level, "ERROR"); | |
| 178 | assert.equal(start.annotations.component, "desktop-backend-child"); | |
| 179 | assert.equal(start.annotations.runId, "test-run"); | |
| 180 | assert.equal(start.annotations.instanceId, "primary"); | |
| 181 | assert.equal(start.annotations.phase, "START"); | |
| 182 | assert.equal(start.annotations.details, "pid=123 port=3773 cwd=/repo"); | |
| 152 | 183 | |
| 153 | 184 | assert.equal(output.message, "backend child process output"); |
| 154 | 185 | assert.equal(output.level, "INFO"); |
| ... | ... | @@ -157,6 +188,141 @@ describe("DesktopObservability", () => { |
| 157 | 188 | assert.equal(output.annotations.instanceId, "primary"); |
| 158 | 189 | assert.equal(output.annotations.stream, "stdout"); |
| 159 | 190 | assert.equal(output.annotations.text, "hello server\n"); |
| 191 | ||
| 192 | assert.equal(end.message, "backend child process failure output end"); | |
| 193 | assert.equal(end.level, "ERROR"); | |
| 194 | assert.equal(end.annotations.instanceId, "primary"); | |
| 195 | assert.equal(end.annotations.phase, "END"); | |
| 196 | assert.equal(end.annotations.details, "code=1"); | |
| 197 | ||
| 198 | const traceRecords = (yield* fileSystem.readFileString(tracePath)) | |
| 199 | .trim() | |
| 200 | .split("\n") | |
| 201 | .filter((line) => line.length > 0) | |
| 202 | .map((line) => decodeTraceRecordLine(line)); | |
| 203 | assert.isFalse( | |
| 204 | traceRecords.some( | |
| 205 | (record) => record.name === "desktop.observability.backendOutput.writeOutputChunk", | |
| 206 | ), | |
| 207 | ); | |
| 208 | }).pipe( | |
| 209 | Effect.scoped, | |
| 210 | Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), | |
| 211 | ), | |
| 212 | ); | |
| 213 | ||
| 214 | it.effect("keeps buffering output after a non-terminal failure snapshot", () => | |
| 215 | Effect.gen(function* () { | |
| 216 | const fileSystem = yield* FileSystem.FileSystem; | |
| 217 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 218 | prefix: "t3-desktop-backend-output-snapshot-test-", | |
| 219 | }); | |
| 220 | const environmentLayer = makeEnvironmentLayer(baseDir, false); | |
| 221 | const logPath = yield* Effect.gen(function* () { | |
| 222 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 223 | return environment.path.join(environment.logDir, "server-child.log"); | |
| 224 | }).pipe(Effect.provide(environmentLayer)); | |
| 225 | ||
| 226 | yield* Effect.gen(function* () { | |
| 227 | const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; | |
| 228 | const outputLog = yield* factory.forInstance("primary"); | |
| 229 | yield* outputLog.beginSession({ details: "pid=123" }); | |
| 230 | yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("before timeout\n")); | |
| 231 | yield* outputLog.persistFailureSnapshot({ details: "readiness timeout" }); | |
| 232 | yield* outputLog.writeOutputChunk("stderr", new TextEncoder().encode("after timeout\n")); | |
| 233 | yield* outputLog.persistFailure({ details: "code=1" }); | |
| 234 | }).pipe( | |
| 235 | Effect.annotateLogs({ runId: "test-run" }), | |
| 236 | Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), | |
| 237 | ); | |
| 238 | ||
| 239 | const records = yield* Effect.forEach( | |
| 240 | (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"), | |
| 241 | (line) => decodeDesktopBackendChildLogRecord(line), | |
| 242 | ); | |
| 243 | assert.equal( | |
| 244 | records.some((record) => record.annotations.text === "after timeout\n"), | |
| 245 | true, | |
| 246 | ); | |
| 247 | assert.equal(records.at(-1)?.annotations.details, "code=1"); | |
| 248 | }).pipe( | |
| 249 | Effect.scoped, | |
| 250 | Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), | |
| 251 | ), | |
| 252 | ); | |
| 253 | ||
| 254 | it.effect("retains only the last mebibyte of backend child output", () => | |
| 255 | Effect.gen(function* () { | |
| 256 | const fileSystem = yield* FileSystem.FileSystem; | |
| 257 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 258 | prefix: "t3-desktop-backend-output-bound-test-", | |
| 259 | }); | |
| 260 | const environmentLayer = makeEnvironmentLayer(baseDir, false); | |
| 261 | const logPath = yield* Effect.gen(function* () { | |
| 262 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 263 | return environment.path.join(environment.logDir, "server-child.log"); | |
| 264 | }).pipe(Effect.provide(environmentLayer)); | |
| 265 | const maxBufferedBytes = 1024 * 1024; | |
| 266 | const discardedPrefixBytes = 128; | |
| 267 | const output = new Uint8Array(maxBufferedBytes + discardedPrefixBytes); | |
| 268 | output.fill("x".charCodeAt(0)); | |
| 269 | output.fill("y".charCodeAt(0), 0, discardedPrefixBytes); | |
| 270 | ||
| 271 | yield* Effect.scoped( | |
| 272 | Effect.gen(function* () { | |
| 273 | const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; | |
| 274 | const outputLog = yield* factory.forInstance("primary"); | |
| 275 | yield* outputLog.beginSession({ details: "pid=123" }); | |
| 276 | yield* outputLog.writeOutputChunk("stderr", output); | |
| 277 | yield* outputLog.persistFailure({ details: "code=1" }); | |
| 278 | }).pipe( | |
| 279 | Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), | |
| 280 | ), | |
| 281 | ); | |
| 282 | ||
| 283 | const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"); | |
| 284 | const record = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); | |
| 285 | const text = record.annotations.text; | |
| 286 | assert.equal(typeof text, "string"); | |
| 287 | if (typeof text !== "string") { | |
| 288 | return; | |
| 289 | } | |
| 290 | assert.equal(new TextEncoder().encode(text).byteLength, maxBufferedBytes); | |
| 291 | assert.isFalse(text.includes("y")); | |
| 292 | }).pipe( | |
| 293 | Effect.scoped, | |
| 294 | Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), | |
| 295 | ), | |
| 296 | ); | |
| 297 | ||
| 298 | it.effect("bounds the number of retained backend child output chunks", () => | |
| 299 | Effect.gen(function* () { | |
| 300 | const fileSystem = yield* FileSystem.FileSystem; | |
| 301 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 302 | prefix: "t3-desktop-backend-output-chunks-test-", | |
| 303 | }); | |
| 304 | const environmentLayer = makeEnvironmentLayer(baseDir, false); | |
| 305 | const logPath = yield* Effect.gen(function* () { | |
| 306 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 307 | return environment.path.join(environment.logDir, "server-child.log"); | |
| 308 | }).pipe(Effect.provide(environmentLayer)); | |
| 309 | ||
| 310 | yield* Effect.scoped( | |
| 311 | Effect.gen(function* () { | |
| 312 | const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; | |
| 313 | const outputLog = yield* factory.forInstance("primary"); | |
| 314 | yield* outputLog.beginSession({ details: "pid=123" }); | |
| 315 | for (let index = 0; index < 300; index += 1) { | |
| 316 | yield* outputLog.writeOutputChunk("stderr", Uint8Array.of(index % 128)); | |
| 317 | } | |
| 318 | yield* outputLog.persistFailure({ details: "code=1" }); | |
| 319 | }).pipe( | |
| 320 | Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), | |
| 321 | ), | |
| 322 | ); | |
| 323 | ||
| 324 | const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"); | |
| 325 | assert.equal(lines.length, 258); | |
| 160 | 326 | }).pipe( |
| 161 | 327 | Effect.scoped, |
| 162 | 328 | Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), |
apps/desktop/src/app/DesktopObservability.ts+164-39| ... | ... | @@ -24,7 +24,9 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 24 | 24 | const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; |
| 25 | 25 | const DESKTOP_LOG_FILE_MAX_FILES = 10; |
| 26 | 26 | const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; |
| 27 | const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; | |
| 27 | const DESKTOP_TRACE_BATCH_WINDOW_MS = 1_000; | |
| 28 | const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES = 1024 * 1024; | |
| 29 | const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS = 256; | |
| 28 | 30 | |
| 29 | 31 | export interface RotatingLogFileWriter { |
| 30 | 32 | readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; |
| ... | ... | @@ -32,14 +34,14 @@ export interface RotatingLogFileWriter { |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | 36 | export interface DesktopBackendOutputLogShape { |
| 35 | readonly writeSessionBoundary: (input: { | |
| 36 | readonly phase: "START" | "END"; | |
| 37 | readonly details: string; | |
| 38 | }) => Effect.Effect<void>; | |
| 37 | readonly beginSession: (input: { readonly details: string }) => Effect.Effect<void>; | |
| 39 | 38 | readonly writeOutputChunk: ( |
| 40 | 39 | streamName: "stdout" | "stderr", |
| 41 | 40 | chunk: Uint8Array, |
| 42 | 41 | ) => Effect.Effect<void>; |
| 42 | readonly persistFailureSnapshot: (input: { readonly details: string }) => Effect.Effect<void>; | |
| 43 | readonly persistFailure: (input: { readonly details: string }) => Effect.Effect<void>; | |
| 44 | readonly discardSession: Effect.Effect<void>; | |
| 43 | 45 | } |
| 44 | 46 | |
| 45 | 47 | // Factory for per-instance backend output logs. `forInstance(id)` returns |
| ... | ... | @@ -127,10 +129,80 @@ const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( |
| 127 | 129 | ); |
| 128 | 130 | |
| 129 | 131 | const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { |
| 130 | writeSessionBoundary: () => Effect.void, | |
| 132 | beginSession: () => Effect.void, | |
| 131 | 133 | writeOutputChunk: () => Effect.void, |
| 134 | persistFailureSnapshot: () => Effect.void, | |
| 135 | persistFailure: () => Effect.void, | |
| 136 | discardSession: Effect.void, | |
| 132 | 137 | }; |
| 133 | 138 | |
| 139 | interface BufferedBackendOutputChunk { | |
| 140 | readonly streamName: "stdout" | "stderr"; | |
| 141 | readonly chunk: Uint8Array; | |
| 142 | readonly offset: number; | |
| 143 | } | |
| 144 | ||
| 145 | interface BackendOutputSession { | |
| 146 | readonly runId: string; | |
| 147 | readonly startDetails: string; | |
| 148 | readonly chunks: ReadonlyArray<BufferedBackendOutputChunk>; | |
| 149 | readonly byteLength: number; | |
| 150 | } | |
| 151 | ||
| 152 | export function appendBoundedOutputChunk( | |
| 153 | session: BackendOutputSession, | |
| 154 | streamName: "stdout" | "stderr", | |
| 155 | chunk: Uint8Array, | |
| 156 | ): BackendOutputSession { | |
| 157 | if (chunk.byteLength === 0) { | |
| 158 | return session; | |
| 159 | } | |
| 160 | ||
| 161 | const retainedChunk = | |
| 162 | chunk.byteLength > DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES | |
| 163 | ? chunk.slice(chunk.byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES) | |
| 164 | : chunk.slice(); | |
| 165 | const chunks = [...session.chunks, { streamName, chunk: retainedChunk, offset: 0 }]; | |
| 166 | let byteLength = session.byteLength + retainedChunk.byteLength; | |
| 167 | let overflow = Math.max(0, byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES); | |
| 168 | let firstRetainedIndex = 0; | |
| 169 | ||
| 170 | while (overflow > 0) { | |
| 171 | const first = chunks[firstRetainedIndex]; | |
| 172 | if (!first) break; | |
| 173 | const retainedByteLength = first.chunk.byteLength - first.offset; | |
| 174 | if (retainedByteLength <= overflow) { | |
| 175 | overflow -= retainedByteLength; | |
| 176 | byteLength -= retainedByteLength; | |
| 177 | firstRetainedIndex += 1; | |
| 178 | continue; | |
| 179 | } | |
| 180 | ||
| 181 | chunks[firstRetainedIndex] = { | |
| 182 | ...first, | |
| 183 | offset: first.offset + overflow, | |
| 184 | }; | |
| 185 | byteLength -= overflow; | |
| 186 | overflow = 0; | |
| 187 | } | |
| 188 | ||
| 189 | const excessChunks = Math.max( | |
| 190 | 0, | |
| 191 | chunks.length - firstRetainedIndex - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS, | |
| 192 | ); | |
| 193 | for (let index = firstRetainedIndex; index < firstRetainedIndex + excessChunks; index += 1) { | |
| 194 | const chunk = chunks[index]; | |
| 195 | byteLength -= chunk ? chunk.chunk.byteLength - chunk.offset : 0; | |
| 196 | } | |
| 197 | firstRetainedIndex += excessChunks; | |
| 198 | ||
| 199 | return { | |
| 200 | ...session, | |
| 201 | chunks: chunks.slice(firstRetainedIndex), | |
| 202 | byteLength, | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 134 | 206 | const currentDesktopRunId = Effect.gen(function* () { |
| 135 | 207 | const annotations = yield* References.CurrentLogAnnotations; |
| 136 | 208 | const runId = annotations.runId; |
| ... | ... | @@ -345,47 +417,93 @@ const makeBackendOutputLogShape = ( |
| 345 | 417 | environment: DesktopEnvironment.DesktopEnvironment["Service"], |
| 346 | 418 | id: string, |
| 347 | 419 | sink: Option.Option<RotatingLogFileWriter>, |
| 348 | ): DesktopBackendOutputLogShape => | |
| 420 | ): Effect.Effect<DesktopBackendOutputLogShape> => | |
| 349 | 421 | Option.match(sink, { |
| 350 | onNone: () => DesktopBackendOutputLogNoop, | |
| 422 | onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), | |
| 351 | 423 | onSome: (logFile) => |
| 352 | ({ | |
| 353 | writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( | |
| 354 | function* ({ phase, details }) { | |
| 355 | const runId = yield* currentDesktopRunId; | |
| 424 | Effect.gen(function* () { | |
| 425 | const sessionRef = yield* Ref.make(Option.none<BackendOutputSession>()); | |
| 426 | const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( | |
| 427 | function* (session: BackendOutputSession, details: string) { | |
| 356 | 428 | yield* writeBackendChildLogRecord(logFile, { |
| 357 | message: `backend child process session ${phase.toLowerCase()}`, | |
| 358 | level: "INFO", | |
| 429 | message: "backend child process failure output start", | |
| 430 | level: "ERROR", | |
| 359 | 431 | annotations: { |
| 360 | 432 | component: "desktop-backend-child", |
| 361 | runId, | |
| 433 | runId: session.runId, | |
| 362 | 434 | instanceId: id, |
| 363 | phase, | |
| 364 | details: sanitizeLogValue(details), | |
| 435 | phase: "START", | |
| 436 | details: session.startDetails, | |
| 365 | 437 | }, |
| 366 | 438 | }); |
| 367 | }, | |
| 368 | ), | |
| 369 | writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( | |
| 370 | function* (streamName, chunk) { | |
| 371 | if (environment.isDevelopment) { | |
| 372 | yield* writeDevelopmentConsoleOutput(streamName, chunk); | |
| 439 | for (const output of session.chunks) { | |
| 440 | yield* writeBackendChildLogRecord(logFile, { | |
| 441 | message: "backend child process output", | |
| 442 | level: output.streamName === "stderr" ? "ERROR" : "INFO", | |
| 443 | annotations: { | |
| 444 | component: "desktop-backend-child", | |
| 445 | runId: session.runId, | |
| 446 | instanceId: id, | |
| 447 | stream: output.streamName, | |
| 448 | text: textDecoder.decode(output.chunk.subarray(output.offset)), | |
| 449 | }, | |
| 450 | }); | |
| 373 | 451 | } |
| 374 | const runId = yield* currentDesktopRunId; | |
| 375 | 452 | yield* writeBackendChildLogRecord(logFile, { |
| 376 | message: "backend child process output", | |
| 377 | level: streamName === "stderr" ? "ERROR" : "INFO", | |
| 453 | message: "backend child process failure output end", | |
| 454 | level: "ERROR", | |
| 378 | 455 | annotations: { |
| 379 | 456 | component: "desktop-backend-child", |
| 380 | runId, | |
| 457 | runId: session.runId, | |
| 381 | 458 | instanceId: id, |
| 382 | stream: streamName, | |
| 383 | text: textDecoder.decode(chunk), | |
| 459 | phase: "END", | |
| 460 | details: sanitizeLogValue(details), | |
| 384 | 461 | }, |
| 385 | 462 | }); |
| 386 | 463 | }, |
| 387 | ), | |
| 388 | }) satisfies DesktopBackendOutputLogShape, | |
| 464 | ); | |
| 465 | return { | |
| 466 | beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ | |
| 467 | details, | |
| 468 | }) { | |
| 469 | const runId = yield* currentDesktopRunId; | |
| 470 | yield* Ref.set( | |
| 471 | sessionRef, | |
| 472 | Option.some({ | |
| 473 | runId, | |
| 474 | startDetails: sanitizeLogValue(details), | |
| 475 | chunks: [], | |
| 476 | byteLength: 0, | |
| 477 | }), | |
| 478 | ); | |
| 479 | }), | |
| 480 | writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { | |
| 481 | if (environment.isDevelopment) { | |
| 482 | yield* writeDevelopmentConsoleOutput(streamName, chunk); | |
| 483 | } | |
| 484 | yield* Ref.update( | |
| 485 | sessionRef, | |
| 486 | Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), | |
| 487 | ); | |
| 488 | }), | |
| 489 | persistFailureSnapshot: Effect.fn( | |
| 490 | "desktop.observability.backendOutput.persistFailureSnapshot", | |
| 491 | )(function* ({ details }) { | |
| 492 | const session = yield* Ref.get(sessionRef); | |
| 493 | if (Option.isSome(session)) { | |
| 494 | yield* writeFailure(session.value, details); | |
| 495 | } | |
| 496 | }), | |
| 497 | persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( | |
| 498 | function* ({ details }) { | |
| 499 | const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); | |
| 500 | if (Option.isNone(session)) return; | |
| 501 | yield* writeFailure(session.value, details); | |
| 502 | }, | |
| 503 | ), | |
| 504 | discardSession: Ref.set(sessionRef, Option.none()), | |
| 505 | } satisfies DesktopBackendOutputLogShape; | |
| 506 | }), | |
| 389 | 507 | }); |
| 390 | 508 | |
| 391 | 509 | const backendOutputLogFactoryLayer = Layer.effect( |
| ... | ... | @@ -412,10 +530,9 @@ const backendOutputLogFactoryLayer = Layer.effect( |
| 412 | 530 | const cacheKey = backendLogFilePathForInstance(environment, id); |
| 413 | 531 | const cached = cache.get(cacheKey); |
| 414 | 532 | if (cached !== undefined) { |
| 415 | return Effect.succeed([ | |
| 416 | makeBackendOutputLogShape(environment, id, cached), | |
| 417 | cache, | |
| 418 | ] as const); | |
| 533 | return makeBackendOutputLogShape(environment, id, cached).pipe( | |
| 534 | Effect.map((outputLog) => [outputLog, cache] as const), | |
| 535 | ); | |
| 419 | 536 | } |
| 420 | 537 | return makeBackendOutputSinkForInstance(environment, id).pipe( |
| 421 | 538 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| ... | ... | @@ -424,11 +541,19 @@ const backendOutputLogFactoryLayer = Layer.effect( |
| 424 | 541 | Effect.map((sink) => { |
| 425 | 542 | const next = new Map(cache); |
| 426 | 543 | next.set(cacheKey, sink); |
| 427 | return [ | |
| 428 | makeBackendOutputLogShape(environment, id, sink), | |
| 429 | next as ReadonlyMap<string, Option.Option<RotatingLogFileWriter>>, | |
| 430 | ] as const; | |
| 544 | return { sink, next }; | |
| 431 | 545 | }), |
| 546 | Effect.flatMap(({ sink, next }) => | |
| 547 | makeBackendOutputLogShape(environment, id, sink).pipe( | |
| 548 | Effect.map( | |
| 549 | (outputLog) => | |
| 550 | [ | |
| 551 | outputLog, | |
| 552 | next as ReadonlyMap<string, Option.Option<RotatingLogFileWriter>>, | |
| 553 | ] as const, | |
| 554 | ), | |
| 555 | ), | |
| 556 | ), | |
| 432 | 557 | ); |
| 433 | 558 | }); |
| 434 | 559 |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+95-1| ... | ... | @@ -52,6 +52,7 @@ function makeEnvironmentLayer( |
| 52 | 52 | baseDir: string, |
| 53 | 53 | options?: { |
| 54 | 54 | readonly appPath?: string; |
| 55 | readonly dirname?: string; | |
| 55 | 56 | readonly isPackaged?: boolean; |
| 56 | 57 | readonly devServerUrl?: string; |
| 57 | 58 | readonly platform?: NodeJS.Platform; |
| ... | ... | @@ -59,7 +60,7 @@ function makeEnvironmentLayer( |
| 59 | 60 | }, |
| 60 | 61 | ) { |
| 61 | 62 | return DesktopEnvironment.layer({ |
| 62 | dirname: "/repo/apps/desktop/src", | |
| 63 | dirname: options?.dirname ?? "/repo/apps/desktop/src", | |
| 63 | 64 | homeDirectory: baseDir, |
| 64 | 65 | platform: options?.platform ?? "darwin", |
| 65 | 66 | processArch: "x64", |
| ... | ... | @@ -464,6 +465,8 @@ describe("DesktopBackendConfiguration", () => { |
| 464 | 465 | // both wslhost-forwarded localhost and the distro's eth0 IP. |
| 465 | 466 | assert.equal(config.bootstrap.host, "0.0.0.0"); |
| 466 | 467 | assert.equal(config.bootstrap.tailscaleServeEnabled, false); |
| 468 | assert.notProperty(config.bootstrap, "desktopTelemetryFd"); | |
| 469 | assert.notProperty(config.bootstrap, "resourceMonitorPath"); | |
| 467 | 470 | // httpBaseUrl uses the resolved distro IP from the test stub, |
| 468 | 471 | // not localhost — the renderer reaches the backend directly to |
| 469 | 472 | // avoid relying on wslhost forwarding. |
| ... | ... | @@ -713,6 +716,97 @@ describe("DesktopBackendConfiguration", () => { |
| 713 | 716 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 714 | 717 | ); |
| 715 | 718 | |
| 719 | it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => | |
| 720 | Effect.gen(function* () { | |
| 721 | const fileSystem = yield* FileSystem.FileSystem; | |
| 722 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 723 | prefix: "t3-desktop-backend-config-test-", | |
| 724 | }); | |
| 725 | const resourcesPath = `${baseDir}/resources`; | |
| 726 | const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`; | |
| 727 | const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`; | |
| 728 | const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`; | |
| 729 | yield* fileSystem.makeDirectory( | |
| 730 | `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`, | |
| 731 | { recursive: true }, | |
| 732 | ); | |
| 733 | yield* fileSystem.makeDirectory(`${resourcesPath}/resource-monitor`, { | |
| 734 | recursive: true, | |
| 735 | }); | |
| 736 | yield* fileSystem.writeFileString(embeddedMonitorPath, "embedded"); | |
| 737 | yield* fileSystem.writeFileString(monitorPath, "binary"); | |
| 738 | yield* fileSystem.chmod(monitorPath, 0o755); | |
| 739 | ||
| 740 | yield* Effect.gen(function* () { | |
| 741 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 742 | const config = yield* configuration.resolvePrimary; | |
| 743 | assert.equal(config.bootstrap.resourceMonitorPath, monitorPath); | |
| 744 | assert.equal(config.bootstrap.desktopTelemetryFd, 4); | |
| 745 | assert.equal(config.bootstrap.desktopTelemetryControlFd, 5); | |
| 746 | }).pipe( | |
| 747 | Effect.provide( | |
| 748 | DesktopBackendConfiguration.layer.pipe( | |
| 749 | Layer.provideMerge(serverExposureLayer), | |
| 750 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 751 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | |
| 752 | Layer.provideMerge( | |
| 753 | makeEnvironmentLayer(baseDir, { | |
| 754 | appPath: `${resourcesPath}/app.asar`, | |
| 755 | dirname, | |
| 756 | isPackaged: true, | |
| 757 | resourcesPath, | |
| 758 | }), | |
| 759 | ), | |
| 760 | ), | |
| 761 | ), | |
| 762 | ); | |
| 763 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 764 | ); | |
| 765 | ||
| 766 | it.effect("prefers the release resource monitor when both development builds exist", () => | |
| 767 | Effect.gen(function* () { | |
| 768 | const fileSystem = yield* FileSystem.FileSystem; | |
| 769 | const path = yield* Path.Path; | |
| 770 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 771 | prefix: "t3-desktop-backend-config-test-", | |
| 772 | }); | |
| 773 | const dirname = path.join(baseDir, "apps/desktop/src"); | |
| 774 | const releaseMonitorPath = path.join( | |
| 775 | baseDir, | |
| 776 | "native/resource-monitor/target/release/t3-resource-monitor", | |
| 777 | ); | |
| 778 | const debugMonitorPath = path.join( | |
| 779 | baseDir, | |
| 780 | "native/resource-monitor/target/debug/t3-resource-monitor", | |
| 781 | ); | |
| 782 | yield* fileSystem.makeDirectory(path.dirname(releaseMonitorPath), { recursive: true }); | |
| 783 | yield* fileSystem.makeDirectory(path.dirname(debugMonitorPath), { recursive: true }); | |
| 784 | yield* fileSystem.writeFileString(releaseMonitorPath, "release"); | |
| 785 | yield* fileSystem.writeFileString(debugMonitorPath, "debug"); | |
| 786 | ||
| 787 | yield* Effect.gen(function* () { | |
| 788 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 789 | const config = yield* configuration.resolvePrimary; | |
| 790 | assert.equal(config.bootstrap.resourceMonitorPath, releaseMonitorPath); | |
| 791 | }).pipe( | |
| 792 | Effect.provide( | |
| 793 | DesktopBackendConfiguration.layer.pipe( | |
| 794 | Layer.provideMerge(serverExposureLayer), | |
| 795 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 796 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | |
| 797 | Layer.provideMerge( | |
| 798 | makeEnvironmentLayer(baseDir, { | |
| 799 | dirname, | |
| 800 | devServerUrl: "http://127.0.0.1:5733", | |
| 801 | isPackaged: false, | |
| 802 | }), | |
| 803 | ), | |
| 804 | ), | |
| 805 | ), | |
| 806 | ); | |
| 807 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 808 | ); | |
| 809 | ||
| 716 | 810 | it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => |
| 717 | 811 | withHarness( |
| 718 | 812 | Effect.gen(function* () { |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+56-2| ... | ... | @@ -141,6 +141,44 @@ const logBackendObservabilitySettingsReadFailure = ( |
| 141 | 141 | ); |
| 142 | 142 | }; |
| 143 | 143 | |
| 144 | function resourceMonitorBinaryName(platform: NodeJS.Platform): string { | |
| 145 | return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; | |
| 146 | } | |
| 147 | ||
| 148 | const resolveResourceMonitorPath = Effect.fn( | |
| 149 | "desktop.backendConfiguration.resolveResourceMonitorPath", | |
| 150 | )(function* () { | |
| 151 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 152 | const fileSystem = yield* FileSystem.FileSystem; | |
| 153 | const binaryName = resourceMonitorBinaryName(environment.platform); | |
| 154 | const candidates = environment.isDevelopment | |
| 155 | ? [ | |
| 156 | environment.path.join( | |
| 157 | environment.rootDir, | |
| 158 | "native/resource-monitor/target/release", | |
| 159 | binaryName, | |
| 160 | ), | |
| 161 | environment.path.join( | |
| 162 | environment.rootDir, | |
| 163 | "native/resource-monitor/target/debug", | |
| 164 | binaryName, | |
| 165 | ), | |
| 166 | ] | |
| 167 | : environment.isPackaged | |
| 168 | ? [environment.path.join(environment.resourcesPath, "resource-monitor", binaryName)] | |
| 169 | : environment.resolveResourcePathCandidates( | |
| 170 | environment.path.join("resource-monitor", binaryName), | |
| 171 | ); | |
| 172 | ||
| 173 | for (const candidate of candidates) { | |
| 174 | if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { | |
| 175 | return Option.some(candidate); | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | return Option.none<string>(); | |
| 180 | }); | |
| 181 | ||
| 144 | 182 | const readPersistedBackendObservabilitySettings = Effect.gen(function* () { |
| 145 | 183 | const fileSystem = yield* FileSystem.FileSystem; |
| 146 | 184 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| ... | ... | @@ -326,7 +364,9 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS |
| 326 | 364 | |
| 327 | 365 | const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( |
| 328 | 366 | function* ( |
| 329 | input: SharedBootstrapInput, | |
| 367 | input: SharedBootstrapInput & { | |
| 368 | readonly resourceMonitorPath: Option.Option<string>; | |
| 369 | }, | |
| 330 | 370 | ): Effect.fn.Return< |
| 331 | 371 | DesktopBackendManager.DesktopBackendStartConfig, |
| 332 | 372 | never, |
| ... | ... | @@ -345,6 +385,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 345 | 385 | desktopBootstrapToken: input.bootstrapToken, |
| 346 | 386 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, |
| 347 | 387 | tailscaleServePort: backendExposure.tailscaleServePort, |
| 388 | desktopTelemetryFd: 4, | |
| 389 | desktopTelemetryControlFd: 5, | |
| 390 | ...Option.match(input.resourceMonitorPath, { | |
| 391 | onNone: () => ({}), | |
| 392 | onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), | |
| 393 | }), | |
| 348 | 394 | ...buildObservabilityFragment(input.observabilitySettings), |
| 349 | 395 | }; |
| 350 | 396 | |
| ... | ... | @@ -411,6 +457,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 411 | 457 | // inert. |
| 412 | 458 | tailscaleServeEnabled: false, |
| 413 | 459 | tailscaleServePort: 443, |
| 460 | // The packaged sidecar is a Windows executable and cannot run inside the | |
| 461 | // Linux WSL backend. Keep the field absent instead of passing an unusable | |
| 462 | // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable. | |
| 463 | // See docs/architecture/resource-telemetry.md. | |
| 414 | 464 | ...buildObservabilityFragment(input.observabilitySettings), |
| 415 | 465 | }; |
| 416 | 466 | |
| ... | ... | @@ -621,7 +671,11 @@ export const make = Effect.gen(function* () { |
| 621 | 671 | |
| 622 | 672 | const buildWindowsPrimaryConfig = Effect.gen(function* () { |
| 623 | 673 | const shared = yield* sharedInputs; |
| 624 | return yield* resolvePrimaryStartConfig(shared).pipe( | |
| 674 | const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe( | |
| 675 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 676 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 677 | ); | |
| 678 | return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( | |
| 625 | 679 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 626 | 680 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), |
| 627 | 681 | ); |
apps/desktop/src/backend/DesktopBackendManager.test.ts+700-16| ... | ... | @@ -1,14 +1,17 @@ |
| 1 | 1 | import { |
| 2 | 2 | DesktopBackendBootstrap, |
| 3 | 3 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 4 | DesktopTelemetryControlMessage, | |
| 4 | 5 | } from "@t3tools/contracts"; |
| 5 | 6 | import { assert, describe, it } from "@effect/vitest"; |
| 6 | 7 | import * as Deferred from "effect/Deferred"; |
| 7 | 8 | import * as Duration from "effect/Duration"; |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 9 | 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 10 | 12 | import * as Layer from "effect/Layer"; |
| 11 | 13 | import * as Option from "effect/Option"; |
| 14 | import * as PlatformError from "effect/PlatformError"; | |
| 12 | 15 | import * as Queue from "effect/Queue"; |
| 13 | 16 | import * as Ref from "effect/Ref"; |
| 14 | 17 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -21,10 +24,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 21 | 24 | |
| 22 | 25 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 23 | 26 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 27 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | |
| 24 | 28 | |
| 25 | 29 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( |
| 26 | 30 | Schema.fromJsonString(DesktopBackendBootstrap), |
| 27 | 31 | ); |
| 32 | const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); | |
| 33 | const encodeDesktopTelemetryControl = Schema.encodeSync( | |
| 34 | Schema.fromJsonString(DesktopTelemetryControlMessage), | |
| 35 | ); | |
| 28 | 36 | |
| 29 | 37 | const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 30 | 38 | executablePath: "/electron", |
| ... | ... | @@ -41,6 +49,8 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 41 | 49 | desktopBootstrapToken: "token", |
| 42 | 50 | tailscaleServeEnabled: false, |
| 43 | 51 | tailscaleServePort: 443, |
| 52 | desktopTelemetryFd: 4, | |
| 53 | desktopTelemetryControlFd: 5, | |
| 44 | 54 | }, |
| 45 | 55 | bootstrapDelivery: "fd3", |
| 46 | 56 | extendEnv: true, |
| ... | ... | @@ -52,14 +62,16 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 52 | 62 | const configWithObservability: DesktopBackendBootstrapValue = { |
| 53 | 63 | ...baseConfig.bootstrap, |
| 54 | 64 | tailscaleServeEnabled: true, |
| 65 | desktopTelemetryFd: 4, | |
| 55 | 66 | otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", |
| 56 | 67 | }; |
| 57 | 68 | |
| 58 | 69 | function makeProcess(options?: { |
| 59 | readonly stdout?: Stream.Stream<Uint8Array>; | |
| 60 | readonly stderr?: Stream.Stream<Uint8Array>; | |
| 61 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | |
| 70 | readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; | |
| 71 | readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; | |
| 72 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; | |
| 62 | 73 | readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; |
| 74 | readonly getOutputFd?: ChildProcessSpawner.ChildProcessHandle["getOutputFd"]; | |
| 63 | 75 | }): ChildProcessSpawner.ChildProcessHandle { |
| 64 | 76 | return ChildProcessSpawner.makeHandle({ |
| 65 | 77 | pid: ChildProcessSpawner.ProcessId(123), |
| ... | ... | @@ -71,7 +83,7 @@ function makeProcess(options?: { |
| 71 | 83 | kill: options?.kill ?? (() => Effect.void), |
| 72 | 84 | stdin: Sink.drain, |
| 73 | 85 | getInputFd: () => Sink.drain, |
| 74 | getOutputFd: () => Stream.empty, | |
| 86 | getOutputFd: options?.getOutputFd ?? (() => Stream.empty), | |
| 75 | 87 | unref: Effect.succeed(Effect.void), |
| 76 | 88 | }); |
| 77 | 89 | } |
| ... | ... | @@ -112,7 +124,14 @@ interface MakeInstanceInput { |
| 112 | 124 | failure: DesktopBackendManager.PreflightFailure, |
| 113 | 125 | ) => Effect.Effect<boolean>; |
| 114 | 126 | readonly config?: DesktopBackendManager.DesktopBackendStartConfig; |
| 115 | readonly configResolve?: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>; | |
| 127 | readonly configResolve?: Effect.Effect< | |
| 128 | DesktopBackendManager.DesktopBackendStartConfig, | |
| 129 | PlatformError.PlatformError | |
| 130 | >; | |
| 131 | readonly desktopTelemetryStream?: Stream.Stream<Uint8Array>; | |
| 132 | readonly desktopTelemetryPublisher?: Partial< | |
| 133 | DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] | |
| 134 | >; | |
| 116 | 135 | } |
| 117 | 136 | |
| 118 | 137 | // Helper that constructs a primary backend instance using the factory |
| ... | ... | @@ -122,8 +141,11 @@ interface MakeInstanceInput { |
| 122 | 141 | // to drive the instance's lifecycle. |
| 123 | 142 | function makeTestInstance(input: MakeInstanceInput) { |
| 124 | 143 | const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { |
| 125 | writeSessionBoundary: () => Effect.void, | |
| 144 | beginSession: () => Effect.void, | |
| 126 | 145 | writeOutputChunk: () => Effect.void, |
| 146 | persistFailureSnapshot: () => Effect.void, | |
| 147 | persistFailure: () => Effect.void, | |
| 148 | discardSession: Effect.void, | |
| 127 | 149 | ...input.backendOutputLog, |
| 128 | 150 | }; |
| 129 | 151 | const servicesLayer = Layer.mergeAll( |
| ... | ... | @@ -135,6 +157,16 @@ function makeTestInstance(input: MakeInstanceInput) { |
| 135 | 157 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { |
| 136 | 158 | forInstance: () => Effect.succeed(stubLog), |
| 137 | 159 | } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), |
| 160 | Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { | |
| 161 | latest: Effect.succeed(Option.none()), | |
| 162 | changes: Stream.empty, | |
| 163 | encoded: input.desktopTelemetryStream ?? Stream.empty, | |
| 164 | handleControl: () => Effect.void, | |
| 165 | handleControlForSource: (_sourceId, message) => | |
| 166 | (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message), | |
| 167 | removeControlSource: () => Effect.void, | |
| 168 | ...input.desktopTelemetryPublisher, | |
| 169 | }), | |
| 138 | 170 | ); |
| 139 | 171 | |
| 140 | 172 | const instance = DesktopBackendManager.makeBackendInstance({ |
| ... | ... | @@ -150,11 +182,12 @@ function makeTestInstance(input: MakeInstanceInput) { |
| 150 | 182 | } |
| 151 | 183 | |
| 152 | 184 | describe("DesktopBackendManager", () => { |
| 153 | it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => | |
| 185 | it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => | |
| 154 | 186 | Effect.scoped( |
| 155 | 187 | Effect.gen(function* () { |
| 156 | 188 | let spawnedCommand: ChildProcess.Command | undefined; |
| 157 | 189 | let bootstrapJson = ""; |
| 190 | let telemetryJson = ""; | |
| 158 | 191 | let readyCount = 0; |
| 159 | 192 | const ready = yield* Deferred.make<void>(); |
| 160 | 193 | const exited = yield* Queue.unbounded<void>(); |
| ... | ... | @@ -169,6 +202,10 @@ describe("DesktopBackendManager", () => { |
| 169 | 202 | if (fd3?.type === "input" && fd3.stream) { |
| 170 | 203 | bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); |
| 171 | 204 | } |
| 205 | const fd4 = command.options.additionalFds?.fd4; | |
| 206 | if (fd4?.type === "input" && fd4.stream) { | |
| 207 | telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); | |
| 208 | } | |
| 172 | 209 | } |
| 173 | 210 | |
| 174 | 211 | return makeProcess({ |
| ... | ... | @@ -184,12 +221,14 @@ describe("DesktopBackendManager", () => { |
| 184 | 221 | bootstrap: configWithObservability, |
| 185 | 222 | }, |
| 186 | 223 | spawnerLayer, |
| 224 | desktopTelemetryStream: Stream.encodeText( | |
| 225 | Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), | |
| 226 | ), | |
| 187 | 227 | onReady: Effect.sync(() => { |
| 188 | 228 | readyCount += 1; |
| 189 | 229 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), |
| 190 | 230 | backendOutputLog: { |
| 191 | writeSessionBoundary: ({ phase }) => | |
| 192 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 231 | persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), | |
| 193 | 232 | }, |
| 194 | 233 | }); |
| 195 | 234 | |
| ... | ... | @@ -210,16 +249,400 @@ describe("DesktopBackendManager", () => { |
| 210 | 249 | assert.equal(spawnedCommand.options.stderr, "pipe"); |
| 211 | 250 | assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); |
| 212 | 251 | assert.isDefined(spawnedCommand.options.forceKillAfter); |
| 252 | assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); | |
| 253 | assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); | |
| 213 | 254 | assert.equal( |
| 214 | 255 | Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), |
| 215 | 256 | 2_000, |
| 216 | 257 | ); |
| 217 | 258 | |
| 218 | 259 | assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); |
| 260 | assert.equal( | |
| 261 | telemetryJson, | |
| 262 | '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', | |
| 263 | ); | |
| 264 | }), | |
| 265 | ), | |
| 266 | ); | |
| 267 | ||
| 268 | it.effect("preserves the readiness timeout cause and process context", () => | |
| 269 | Effect.gen(function* () { | |
| 270 | const requested = yield* Deferred.make<HttpClientRequest.HttpClientRequest>(); | |
| 271 | const layer = Layer.merge( | |
| 272 | TestClock.layer(), | |
| 273 | httpClientLayer((request) => | |
| 274 | Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), | |
| 275 | ), | |
| 276 | ); | |
| 277 | ||
| 278 | yield* Effect.gen(function* () { | |
| 279 | const readiness = yield* DesktopBackendManager.waitForHttpReady({ | |
| 280 | executablePath: baseConfig.executablePath, | |
| 281 | entryPath: baseConfig.entryPath, | |
| 282 | cwd: baseConfig.cwd, | |
| 283 | httpBaseUrl: baseConfig.httpBaseUrl, | |
| 284 | timeout: Duration.millis(50), | |
| 285 | }).pipe(Effect.flip, Effect.forkChild); | |
| 286 | ||
| 287 | const request = yield* Deferred.await(requested); | |
| 288 | assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); | |
| 289 | ||
| 290 | yield* TestClock.adjust(Duration.millis(50)); | |
| 291 | const error = yield* Fiber.join(readiness); | |
| 292 | ||
| 293 | assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); | |
| 294 | assert.equal(error.executablePath, "/electron"); | |
| 295 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 296 | assert.equal(error.cwd, "/server"); | |
| 297 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 298 | assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); | |
| 299 | assert.equal(error.timeoutMs, 50); | |
| 300 | assert.isDefined(error.cause); | |
| 301 | assert.equal( | |
| 302 | error.message, | |
| 303 | "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", | |
| 304 | ); | |
| 305 | }).pipe(Effect.provide(layer)); | |
| 306 | }), | |
| 307 | ); | |
| 308 | ||
| 309 | it.effect("reports bootstrap encoding failures with stable process context", () => | |
| 310 | Effect.gen(function* () { | |
| 311 | const spawnerLayer = Layer.succeed( | |
| 312 | ChildProcessSpawner.ChildProcessSpawner, | |
| 313 | ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), | |
| 314 | ); | |
| 315 | const error = yield* DesktopBackendManager.runBackendProcess({ | |
| 316 | ...baseConfig, | |
| 317 | desktopTelemetryStream: Stream.empty, | |
| 318 | bootstrap: { | |
| 319 | ...baseConfig.bootstrap, | |
| 320 | port: 0, | |
| 321 | }, | |
| 322 | }).pipe( | |
| 323 | Effect.flip, | |
| 324 | Effect.scoped, | |
| 325 | Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), | |
| 326 | ); | |
| 327 | ||
| 328 | if (error._tag !== "BackendProcessBootstrapEncodeError") { | |
| 329 | return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); | |
| 330 | } | |
| 331 | assert.equal(error.executablePath, "/electron"); | |
| 332 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 333 | assert.equal(error.cwd, "/server"); | |
| 334 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 335 | assert.isDefined(error.cause); | |
| 336 | assert.equal( | |
| 337 | error.message, | |
| 338 | "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", | |
| 339 | ); | |
| 340 | assert.isTrue(isBackendProcessError(error)); | |
| 341 | }), | |
| 342 | ); | |
| 343 | ||
| 344 | it.effect("preserves spawn failures without deriving their message from the cause", () => | |
| 345 | Effect.gen(function* () { | |
| 346 | const spawnCause = PlatformError.systemError({ | |
| 347 | _tag: "PermissionDenied", | |
| 348 | module: "ChildProcessSpawner", | |
| 349 | method: "spawn", | |
| 350 | pathOrDescriptor: baseConfig.executablePath, | |
| 351 | description: "low-level detail that must not become the public message", | |
| 352 | }); | |
| 353 | const spawnerLayer = Layer.succeed( | |
| 354 | ChildProcessSpawner.ChildProcessSpawner, | |
| 355 | ChildProcessSpawner.make(() => Effect.fail(spawnCause)), | |
| 356 | ); | |
| 357 | const error = yield* DesktopBackendManager.runBackendProcess({ | |
| 358 | ...baseConfig, | |
| 359 | desktopTelemetryStream: Stream.empty, | |
| 360 | }).pipe( | |
| 361 | Effect.flip, | |
| 362 | Effect.scoped, | |
| 363 | Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), | |
| 364 | ); | |
| 365 | ||
| 366 | if (error._tag !== "BackendProcessSpawnError") { | |
| 367 | return assert.fail(`Expected backend spawn error, received ${error._tag}`); | |
| 368 | } | |
| 369 | assert.equal(error.executablePath, "/electron"); | |
| 370 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 371 | assert.equal(error.cwd, "/server"); | |
| 372 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 373 | assert.strictEqual(error.cause, spawnCause); | |
| 374 | assert.equal( | |
| 375 | error.message, | |
| 376 | "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", | |
| 377 | ); | |
| 378 | assert.notInclude(error.message, spawnCause.message); | |
| 379 | assert.isTrue(isBackendProcessError(error)); | |
| 380 | }), | |
| 381 | ); | |
| 382 | ||
| 383 | it.effect("preserves exit-status failures without copying their detail into the message", () => | |
| 384 | Effect.gen(function* () { | |
| 385 | const exitCause = PlatformError.systemError({ | |
| 386 | _tag: "PermissionDenied", | |
| 387 | module: "ChildProcess", | |
| 388 | method: "exitCode", | |
| 389 | description: "exit-status-secret-sentinel", | |
| 390 | }); | |
| 391 | const spawnerLayer = Layer.succeed( | |
| 392 | ChildProcessSpawner.ChildProcessSpawner, | |
| 393 | ChildProcessSpawner.make(() => | |
| 394 | Effect.succeed( | |
| 395 | makeProcess({ | |
| 396 | exitCode: Effect.fail(exitCause), | |
| 397 | }), | |
| 398 | ), | |
| 399 | ), | |
| 400 | ); | |
| 401 | const error = yield* DesktopBackendManager.runBackendProcess({ | |
| 402 | ...baseConfig, | |
| 403 | desktopTelemetryStream: Stream.empty, | |
| 404 | }).pipe( | |
| 405 | Effect.flip, | |
| 406 | Effect.scoped, | |
| 407 | Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), | |
| 408 | ); | |
| 409 | ||
| 410 | if (error._tag !== "BackendProcessExitStatusError") { | |
| 411 | return assert.fail(`Expected backend exit-status error, received ${error._tag}`); | |
| 412 | } | |
| 413 | assert.equal(error.pid, 123); | |
| 414 | assert.equal(error.executablePath, "/electron"); | |
| 415 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 416 | assert.equal(error.cwd, "/server"); | |
| 417 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 418 | assert.strictEqual(error.cause, exitCause); | |
| 419 | assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); | |
| 420 | assert.notInclude(error.message, "exit-status-secret-sentinel"); | |
| 421 | assert.isTrue(isBackendProcessError(error)); | |
| 422 | }), | |
| 423 | ); | |
| 424 | ||
| 425 | it.effect("reports output stream failures with process and stream context", () => | |
| 426 | Effect.gen(function* () { | |
| 427 | const outputCause = PlatformError.systemError({ | |
| 428 | _tag: "BadResource", | |
| 429 | module: "ChildProcess", | |
| 430 | method: "stdout", | |
| 431 | description: "output-stream-secret-sentinel", | |
| 432 | }); | |
| 433 | const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>(); | |
| 434 | const spawnerLayer = Layer.succeed( | |
| 435 | ChildProcessSpawner.ChildProcessSpawner, | |
| 436 | ChildProcessSpawner.make(() => | |
| 437 | Effect.succeed( | |
| 438 | makeProcess({ | |
| 439 | stdout: Stream.fail(outputCause), | |
| 440 | exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 441 | }), | |
| 442 | ), | |
| 443 | ), | |
| 444 | ); | |
| 445 | ||
| 446 | const exit = yield* DesktopBackendManager.runBackendProcess({ | |
| 447 | ...baseConfig, | |
| 448 | desktopTelemetryStream: Stream.empty, | |
| 449 | onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), | |
| 450 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); | |
| 451 | const error = yield* Deferred.await(reported); | |
| 452 | ||
| 453 | assert.equal(exit.code.pipe(Option.getOrUndefined), 0); | |
| 454 | if (error._tag !== "BackendProcessOutputReadError") { | |
| 455 | return assert.fail(`Expected output read error, received ${error._tag}`); | |
| 456 | } | |
| 457 | assert.equal(error.executablePath, "/electron"); | |
| 458 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 459 | assert.equal(error.cwd, "/server"); | |
| 460 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 461 | assert.equal(error.pid, 123); | |
| 462 | assert.equal(error.streamName, "stdout"); | |
| 463 | assert.strictEqual(error.cause, outputCause); | |
| 464 | assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); | |
| 465 | assert.notInclude(error.message, "output-stream-secret-sentinel"); | |
| 466 | }), | |
| 467 | ); | |
| 468 | ||
| 469 | it.effect("reports output handler failures separately from stream read failures", () => | |
| 470 | Effect.gen(function* () { | |
| 471 | const chunk = new TextEncoder().encode("backend output"); | |
| 472 | const nextChunk = new TextEncoder().encode("still draining"); | |
| 473 | const outputCause = new Error("output-handler-secret-sentinel"); | |
| 474 | const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>(); | |
| 475 | const drained = yield* Deferred.make<void>(); | |
| 476 | let outputCount = 0; | |
| 477 | const spawnerLayer = Layer.succeed( | |
| 478 | ChildProcessSpawner.ChildProcessSpawner, | |
| 479 | ChildProcessSpawner.make(() => | |
| 480 | Effect.succeed( | |
| 481 | makeProcess({ | |
| 482 | stdout: Stream.make(chunk, nextChunk), | |
| 483 | exitCode: Deferred.await(drained).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 484 | }), | |
| 485 | ), | |
| 486 | ), | |
| 487 | ); | |
| 488 | ||
| 489 | const exit = yield* DesktopBackendManager.runBackendProcess({ | |
| 490 | ...baseConfig, | |
| 491 | desktopTelemetryStream: Stream.empty, | |
| 492 | onOutput: () => { | |
| 493 | outputCount += 1; | |
| 494 | return outputCount === 1 | |
| 495 | ? Effect.fail(outputCause) | |
| 496 | : Deferred.succeed(drained, void 0).pipe(Effect.asVoid); | |
| 497 | }, | |
| 498 | onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), | |
| 499 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); | |
| 500 | const error = yield* Deferred.await(reported); | |
| 501 | ||
| 502 | assert.equal(exit.code.pipe(Option.getOrUndefined), 0); | |
| 503 | if (error._tag !== "BackendProcessOutputHandlingError") { | |
| 504 | return assert.fail(`Expected output handling error, received ${error._tag}`); | |
| 505 | } | |
| 506 | assert.equal(error.executablePath, "/electron"); | |
| 507 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 508 | assert.equal(error.cwd, "/server"); | |
| 509 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 510 | assert.equal(error.pid, 123); | |
| 511 | assert.equal(error.streamName, "stdout"); | |
| 512 | assert.equal(error.chunkByteLength, chunk.byteLength); | |
| 513 | assert.strictEqual(error.cause, outputCause); | |
| 514 | assert.equal( | |
| 515 | error.message, | |
| 516 | `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, | |
| 517 | ); | |
| 518 | assert.notInclude(error.message, "output-handler-secret-sentinel"); | |
| 519 | assert.equal(outputCount, 2); | |
| 520 | }), | |
| 521 | ); | |
| 522 | ||
| 523 | it.effect("reports child exit before waiting for trailing output to drain", () => | |
| 524 | Effect.scoped( | |
| 525 | Effect.gen(function* () { | |
| 526 | const exitObserved = yield* Deferred.make<void>(); | |
| 527 | const finishOutputDrain = yield* Deferred.make<void>(); | |
| 528 | const spawnerLayer = Layer.succeed( | |
| 529 | ChildProcessSpawner.ChildProcessSpawner, | |
| 530 | ChildProcessSpawner.make(() => | |
| 531 | Effect.succeed( | |
| 532 | makeProcess({ | |
| 533 | stdout: Stream.fromEffect( | |
| 534 | Deferred.await(finishOutputDrain).pipe( | |
| 535 | Effect.as(new TextEncoder().encode("trailing output\n")), | |
| 536 | ), | |
| 537 | ), | |
| 538 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), | |
| 539 | }), | |
| 540 | ), | |
| 541 | ), | |
| 542 | ); | |
| 543 | ||
| 544 | const runFiber = yield* DesktopBackendManager.runBackendProcess({ | |
| 545 | ...baseConfig, | |
| 546 | desktopTelemetryStream: Stream.empty, | |
| 547 | onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), | |
| 548 | }).pipe( | |
| 549 | Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), | |
| 550 | Effect.forkChild, | |
| 551 | ); | |
| 552 | ||
| 553 | yield* Deferred.await(exitObserved); | |
| 554 | assert.isUndefined(runFiber.pollUnsafe()); | |
| 555 | ||
| 556 | yield* Deferred.succeed(finishOutputDrain, void 0); | |
| 557 | assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); | |
| 558 | }), | |
| 559 | ), | |
| 560 | ); | |
| 561 | ||
| 562 | it.effect("continues routing desktop telemetry control messages after an invalid line", () => | |
| 563 | Effect.scoped( | |
| 564 | Effect.gen(function* () { | |
| 565 | const handled = yield* Deferred.make<boolean>(); | |
| 566 | const controlMessage = encodeDesktopTelemetryControl({ | |
| 567 | version: 1, | |
| 568 | type: "setDiagnosticsDemand", | |
| 569 | enabled: true, | |
| 570 | }); | |
| 571 | const spawnerLayer = Layer.succeed( | |
| 572 | ChildProcessSpawner.ChildProcessSpawner, | |
| 573 | ChildProcessSpawner.make(() => | |
| 574 | Effect.succeed( | |
| 575 | makeProcess({ | |
| 576 | getOutputFd: (fd) => | |
| 577 | fd === 5 | |
| 578 | ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) | |
| 579 | : Stream.empty, | |
| 580 | exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 581 | }), | |
| 582 | ), | |
| 583 | ), | |
| 584 | ); | |
| 585 | const instance = yield* makeTestInstance({ | |
| 586 | spawnerLayer, | |
| 587 | desktopTelemetryPublisher: { | |
| 588 | handleControl: (message) => | |
| 589 | message.type === "setDiagnosticsDemand" | |
| 590 | ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) | |
| 591 | : Effect.void, | |
| 592 | }, | |
| 593 | }); | |
| 594 | ||
| 595 | yield* instance.start; | |
| 596 | assert.isTrue(yield* Deferred.await(handled)); | |
| 219 | 597 | }), |
| 220 | 598 | ), |
| 221 | 599 | ); |
| 222 | 600 | |
| 601 | it.effect("drains trailing child output before persisting an unexpected exit", () => | |
| 602 | Effect.scoped( | |
| 603 | Effect.gen(function* () { | |
| 604 | const persistedOutput = yield* Deferred.make<ReadonlyArray<string>>(); | |
| 605 | const outputDrainStarted = yield* Deferred.make<void>(); | |
| 606 | const outputChunks = yield* Ref.make<Array<string>>([]); | |
| 607 | const spawnerLayer = Layer.succeed( | |
| 608 | ChildProcessSpawner.ChildProcessSpawner, | |
| 609 | ChildProcessSpawner.make(() => | |
| 610 | Effect.succeed( | |
| 611 | makeProcess({ | |
| 612 | stdout: Stream.fromEffect( | |
| 613 | Deferred.succeed(outputDrainStarted, void 0).pipe( | |
| 614 | Effect.andThen(Effect.sleep(Duration.seconds(1))), | |
| 615 | Effect.as(new TextEncoder().encode("trailing output\n")), | |
| 616 | ), | |
| 617 | ), | |
| 618 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), | |
| 619 | }), | |
| 620 | ), | |
| 621 | ), | |
| 622 | ); | |
| 623 | const instance = yield* makeTestInstance({ | |
| 624 | spawnerLayer, | |
| 625 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 626 | backendOutputLog: { | |
| 627 | writeOutputChunk: (_streamName, chunk) => | |
| 628 | Ref.update(outputChunks, (current) => [...current, new TextDecoder().decode(chunk)]), | |
| 629 | persistFailure: () => | |
| 630 | Ref.get(outputChunks).pipe( | |
| 631 | Effect.flatMap((chunks) => Deferred.succeed(persistedOutput, chunks)), | |
| 632 | Effect.asVoid, | |
| 633 | ), | |
| 634 | }, | |
| 635 | }); | |
| 636 | ||
| 637 | yield* instance.start; | |
| 638 | yield* Deferred.await(outputDrainStarted); | |
| 639 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 640 | ||
| 641 | assert.deepEqual(yield* Deferred.await(persistedOutput), ["trailing output\n"]); | |
| 642 | }).pipe(Effect.provide(TestClock.layer())), | |
| 643 | ), | |
| 644 | ); | |
| 645 | ||
| 223 | 646 | it.effect("retries HTTP readiness before reporting the backend ready", () => |
| 224 | 647 | Effect.scoped( |
| 225 | 648 | Effect.gen(function* () { |
| ... | ... | @@ -256,8 +679,7 @@ describe("DesktopBackendManager", () => { |
| 256 | 679 | readyCount += 1; |
| 257 | 680 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), |
| 258 | 681 | backendOutputLog: { |
| 259 | writeSessionBoundary: ({ phase }) => | |
| 260 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 682 | persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), | |
| 261 | 683 | }, |
| 262 | 684 | }); |
| 263 | 685 | |
| ... | ... | @@ -285,9 +707,15 @@ describe("DesktopBackendManager", () => { |
| 285 | 707 | let startCount = 0; |
| 286 | 708 | let closedCount = 0; |
| 287 | 709 | const closed = yield* Deferred.make<void>(); |
| 710 | const teardownStarted = yield* Deferred.make<void>(); | |
| 711 | const finishTeardown = yield* Deferred.make<void>(); | |
| 288 | 712 | const startedPids = yield* Queue.unbounded<number>(); |
| 289 | 713 | const ready = yield* Deferred.make<void>(); |
| 290 | 714 | const backendReadyFlag = yield* Ref.make(false); |
| 715 | let shutdownCount = 0; | |
| 716 | let persistedFailureCount = 0; | |
| 717 | let discardedSessionCount = 0; | |
| 718 | let removedTelemetrySources = 0; | |
| 291 | 719 | |
| 292 | 720 | const spawnerLayer = Layer.succeed( |
| 293 | 721 | ChildProcessSpawner.ChildProcessSpawner, |
| ... | ... | @@ -296,9 +724,16 @@ describe("DesktopBackendManager", () => { |
| 296 | 724 | const scope = yield* Scope.Scope; |
| 297 | 725 | startCount += 1; |
| 298 | 726 | yield* Queue.offer(startedPids, 123); |
| 299 | const close = Effect.sync(() => { | |
| 300 | closedCount += 1; | |
| 301 | }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); | |
| 727 | const close = Deferred.succeed(teardownStarted, undefined).pipe( | |
| 728 | Effect.andThen(Deferred.await(finishTeardown)), | |
| 729 | Effect.andThen( | |
| 730 | Effect.sync(() => { | |
| 731 | closedCount += 1; | |
| 732 | }), | |
| 733 | ), | |
| 734 | Effect.andThen(Deferred.succeed(closed, void 0)), | |
| 735 | Effect.asVoid, | |
| 736 | ); | |
| 302 | 737 | |
| 303 | 738 | yield* Scope.addFinalizer(scope, close); |
| 304 | 739 | |
| ... | ... | @@ -316,7 +751,28 @@ describe("DesktopBackendManager", () => { |
| 316 | 751 | Effect.andThen(Deferred.succeed(ready, void 0)), |
| 317 | 752 | Effect.asVoid, |
| 318 | 753 | ), |
| 319 | onShutdown: Ref.set(backendReadyFlag, false), | |
| 754 | onShutdown: Ref.set(backendReadyFlag, false).pipe( | |
| 755 | Effect.andThen( | |
| 756 | Effect.sync(() => { | |
| 757 | shutdownCount += 1; | |
| 758 | }), | |
| 759 | ), | |
| 760 | ), | |
| 761 | backendOutputLog: { | |
| 762 | persistFailure: () => | |
| 763 | Effect.sync(() => { | |
| 764 | persistedFailureCount += 1; | |
| 765 | }), | |
| 766 | discardSession: Effect.sync(() => { | |
| 767 | discardedSessionCount += 1; | |
| 768 | }), | |
| 769 | }, | |
| 770 | desktopTelemetryPublisher: { | |
| 771 | removeControlSource: () => | |
| 772 | Effect.sync(() => { | |
| 773 | removedTelemetrySources += 1; | |
| 774 | }), | |
| 775 | }, | |
| 320 | 776 | }); |
| 321 | 777 | assert.isTrue(Option.isNone(yield* instance.currentConfig)); |
| 322 | 778 | |
| ... | ... | @@ -330,12 +786,21 @@ describe("DesktopBackendManager", () => { |
| 330 | 786 | assert.equal(runningSnapshot.ready, true); |
| 331 | 787 | assert.deepEqual(runningSnapshot.activePid, Option.some(123)); |
| 332 | 788 | |
| 333 | yield* instance.stop(); | |
| 789 | const stopFiber = yield* instance.stop().pipe(Effect.forkChild); | |
| 790 | yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); | |
| 791 | assert.isFalse(yield* Ref.get(backendReadyFlag)); | |
| 792 | assert.equal(shutdownCount, 1); | |
| 793 | yield* Deferred.succeed(finishTeardown, undefined); | |
| 794 | yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); | |
| 334 | 795 | assert.equal(startCount, 1); |
| 335 | 796 | assert.equal(closedCount, 1); |
| 797 | assert.equal(persistedFailureCount, 0); | |
| 798 | assert.equal(discardedSessionCount, 1); | |
| 799 | assert.equal(removedTelemetrySources, 1); | |
| 336 | 800 | |
| 337 | 801 | const stoppedSnapshot = yield* instance.snapshot; |
| 338 | 802 | assert.isFalse(yield* Ref.get(backendReadyFlag)); |
| 803 | assert.equal(shutdownCount, 1); | |
| 339 | 804 | assert.equal(stoppedSnapshot.desiredRunning, false); |
| 340 | 805 | assert.equal(stoppedSnapshot.ready, false); |
| 341 | 806 | assert.equal(Option.isNone(stoppedSnapshot.activePid), true); |
| ... | ... | @@ -343,6 +808,213 @@ describe("DesktopBackendManager", () => { |
| 343 | 808 | ), |
| 344 | 809 | ); |
| 345 | 810 | |
| 811 | it.effect("restarts when start is requested during stop teardown", () => | |
| 812 | Effect.scoped( | |
| 813 | Effect.gen(function* () { | |
| 814 | const starts = yield* Queue.unbounded<number>(); | |
| 815 | const teardownStarted = yield* Deferred.make<void>(); | |
| 816 | const finishTeardown = yield* Deferred.make<void>(); | |
| 817 | let startCount = 0; | |
| 818 | ||
| 819 | const spawnerLayer = Layer.succeed( | |
| 820 | ChildProcessSpawner.ChildProcessSpawner, | |
| 821 | ChildProcessSpawner.make(() => | |
| 822 | Effect.gen(function* () { | |
| 823 | const scope = yield* Scope.Scope; | |
| 824 | const closed = yield* Deferred.make<void>(); | |
| 825 | startCount += 1; | |
| 826 | yield* Queue.offer(starts, startCount); | |
| 827 | if (startCount === 1) { | |
| 828 | yield* Scope.addFinalizer( | |
| 829 | scope, | |
| 830 | Deferred.succeed(teardownStarted, undefined).pipe( | |
| 831 | Effect.andThen(Deferred.await(finishTeardown)), | |
| 832 | Effect.andThen(Deferred.succeed(closed, undefined)), | |
| 833 | Effect.asVoid, | |
| 834 | ), | |
| 835 | ); | |
| 836 | } else { | |
| 837 | yield* Scope.addFinalizer( | |
| 838 | scope, | |
| 839 | Deferred.succeed(closed, undefined).pipe(Effect.asVoid), | |
| 840 | ); | |
| 841 | } | |
| 842 | return makeProcess({ | |
| 843 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 844 | }); | |
| 845 | }), | |
| 846 | ), | |
| 847 | ); | |
| 848 | ||
| 849 | const instance = yield* makeTestInstance({ | |
| 850 | spawnerLayer, | |
| 851 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 852 | }); | |
| 853 | ||
| 854 | yield* instance.start; | |
| 855 | assert.equal(yield* Queue.take(starts), 1); | |
| 856 | ||
| 857 | const stopFiber = yield* instance.stop().pipe(Effect.forkChild); | |
| 858 | yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); | |
| 859 | yield* instance.start; | |
| 860 | assert.equal((yield* instance.snapshot).desiredRunning, true); | |
| 861 | ||
| 862 | yield* Deferred.succeed(finishTeardown, undefined); | |
| 863 | yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); | |
| 864 | yield* TestClock.adjust(Duration.millis(500)); | |
| 865 | ||
| 866 | assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); | |
| 867 | const restarted = yield* instance.snapshot; | |
| 868 | assert.equal(restarted.desiredRunning, true); | |
| 869 | assert.deepEqual(restarted.activePid, Option.some(123)); | |
| 870 | }).pipe(Effect.provide(TestClock.layer())), | |
| 871 | ), | |
| 872 | ); | |
| 873 | ||
| 874 | it.effect("retries config resolution after a start request during stop teardown", () => | |
| 875 | Effect.scoped( | |
| 876 | Effect.gen(function* () { | |
| 877 | const starts = yield* Queue.unbounded<number>(); | |
| 878 | const teardownStarted = yield* Deferred.make<void>(); | |
| 879 | const finishTeardown = yield* Deferred.make<void>(); | |
| 880 | const configAttempts = yield* Ref.make(0); | |
| 881 | let startCount = 0; | |
| 882 | ||
| 883 | const configFailure = PlatformError.systemError({ | |
| 884 | _tag: "Unknown", | |
| 885 | module: "DesktopBackendManager", | |
| 886 | method: "configResolve", | |
| 887 | description: "transient configuration failure", | |
| 888 | }); | |
| 889 | const spawnerLayer = Layer.succeed( | |
| 890 | ChildProcessSpawner.ChildProcessSpawner, | |
| 891 | ChildProcessSpawner.make(() => | |
| 892 | Effect.gen(function* () { | |
| 893 | const scope = yield* Scope.Scope; | |
| 894 | const closed = yield* Deferred.make<void>(); | |
| 895 | startCount += 1; | |
| 896 | yield* Queue.offer(starts, startCount); | |
| 897 | if (startCount === 1) { | |
| 898 | yield* Scope.addFinalizer( | |
| 899 | scope, | |
| 900 | Deferred.succeed(teardownStarted, undefined).pipe( | |
| 901 | Effect.andThen(Deferred.await(finishTeardown)), | |
| 902 | Effect.andThen(Deferred.succeed(closed, undefined)), | |
| 903 | Effect.asVoid, | |
| 904 | ), | |
| 905 | ); | |
| 906 | } else { | |
| 907 | yield* Scope.addFinalizer( | |
| 908 | scope, | |
| 909 | Deferred.succeed(closed, undefined).pipe(Effect.asVoid), | |
| 910 | ); | |
| 911 | } | |
| 912 | return makeProcess({ | |
| 913 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 914 | }); | |
| 915 | }), | |
| 916 | ), | |
| 917 | ); | |
| 918 | const configResolve = Ref.updateAndGet(configAttempts, (attempt) => attempt + 1).pipe( | |
| 919 | Effect.flatMap((attempt) => | |
| 920 | attempt === 2 ? Effect.fail(configFailure) : Effect.succeed(baseConfig), | |
| 921 | ), | |
| 922 | ); | |
| 923 | const instance = yield* makeTestInstance({ | |
| 924 | spawnerLayer, | |
| 925 | configResolve, | |
| 926 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 927 | }); | |
| 928 | ||
| 929 | yield* instance.start; | |
| 930 | assert.equal(yield* Queue.take(starts), 1); | |
| 931 | ||
| 932 | const stopFiber = yield* instance.stop().pipe(Effect.forkChild); | |
| 933 | yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); | |
| 934 | yield* instance.start; | |
| 935 | yield* Deferred.succeed(finishTeardown, undefined); | |
| 936 | yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); | |
| 937 | ||
| 938 | const pendingRestart = yield* instance.snapshot; | |
| 939 | assert.equal(pendingRestart.desiredRunning, true); | |
| 940 | assert.equal(pendingRestart.restartScheduled, true); | |
| 941 | ||
| 942 | yield* TestClock.adjust(Duration.seconds(2)); | |
| 943 | ||
| 944 | assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); | |
| 945 | assert.equal(yield* Ref.get(configAttempts), 3); | |
| 946 | assert.equal((yield* instance.snapshot).desiredRunning, true); | |
| 947 | }).pipe(Effect.provide(TestClock.layer())), | |
| 948 | ), | |
| 949 | ); | |
| 950 | ||
| 951 | it.effect("keeps a timed-out run active until its process exits", () => | |
| 952 | Effect.scoped( | |
| 953 | Effect.gen(function* () { | |
| 954 | const starts = yield* Queue.unbounded<number>(); | |
| 955 | const teardownStarted = yield* Deferred.make<void>(); | |
| 956 | const finishTeardown = yield* Deferred.make<void>(); | |
| 957 | let startCount = 0; | |
| 958 | ||
| 959 | const spawnerLayer = Layer.succeed( | |
| 960 | ChildProcessSpawner.ChildProcessSpawner, | |
| 961 | ChildProcessSpawner.make(() => | |
| 962 | Effect.gen(function* () { | |
| 963 | const scope = yield* Scope.Scope; | |
| 964 | const closed = yield* Deferred.make<void>(); | |
| 965 | startCount += 1; | |
| 966 | yield* Queue.offer(starts, startCount); | |
| 967 | if (startCount === 1) { | |
| 968 | yield* Scope.addFinalizer( | |
| 969 | scope, | |
| 970 | Deferred.succeed(teardownStarted, undefined).pipe( | |
| 971 | Effect.andThen(Deferred.await(finishTeardown)), | |
| 972 | Effect.andThen(Deferred.succeed(closed, undefined)), | |
| 973 | Effect.asVoid, | |
| 974 | ), | |
| 975 | ); | |
| 976 | } else { | |
| 977 | yield* Scope.addFinalizer( | |
| 978 | scope, | |
| 979 | Deferred.succeed(closed, undefined).pipe(Effect.asVoid), | |
| 980 | ); | |
| 981 | } | |
| 982 | return makeProcess({ | |
| 983 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 984 | }); | |
| 985 | }), | |
| 986 | ), | |
| 987 | ); | |
| 988 | ||
| 989 | const instance = yield* makeTestInstance({ | |
| 990 | spawnerLayer, | |
| 991 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 992 | }); | |
| 993 | ||
| 994 | yield* instance.start; | |
| 995 | assert.equal(yield* Queue.take(starts), 1); | |
| 996 | ||
| 997 | const stopFiber = yield* instance | |
| 998 | .stop({ timeout: Duration.millis(100) }) | |
| 999 | .pipe(Effect.forkChild); | |
| 1000 | yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); | |
| 1001 | yield* instance.start; | |
| 1002 | yield* TestClock.adjust(Duration.millis(100)); | |
| 1003 | yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); | |
| 1004 | ||
| 1005 | assert.equal(startCount, 1); | |
| 1006 | const timedOut = yield* instance.snapshot; | |
| 1007 | assert.equal(timedOut.desiredRunning, true); | |
| 1008 | assert.deepEqual(timedOut.activePid, Option.some(123)); | |
| 1009 | ||
| 1010 | yield* Deferred.succeed(finishTeardown, undefined); | |
| 1011 | yield* TestClock.adjust(Duration.millis(500)); | |
| 1012 | ||
| 1013 | assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); | |
| 1014 | }).pipe(Effect.provide(TestClock.layer())), | |
| 1015 | ), | |
| 1016 | ); | |
| 1017 | ||
| 346 | 1018 | it.effect("does not notify shutdown before the first start has prior state", () => |
| 347 | 1019 | Effect.scoped( |
| 348 | 1020 | Effect.gen(function* () { |
| ... | ... | @@ -383,6 +1055,7 @@ describe("DesktopBackendManager", () => { |
| 383 | 1055 | Effect.scoped( |
| 384 | 1056 | Effect.gen(function* () { |
| 385 | 1057 | const starts = yield* Queue.unbounded<number>(); |
| 1058 | const failures = yield* Queue.unbounded<string>(); | |
| 386 | 1059 | let startCount = 0; |
| 387 | 1060 | |
| 388 | 1061 | const spawnerLayer = Layer.succeed( |
| ... | ... | @@ -402,11 +1075,15 @@ describe("DesktopBackendManager", () => { |
| 402 | 1075 | const instance = yield* makeTestInstance({ |
| 403 | 1076 | spawnerLayer, |
| 404 | 1077 | httpClientLayer: httpClientLayer(() => Effect.never), |
| 1078 | backendOutputLog: { | |
| 1079 | persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid), | |
| 1080 | }, | |
| 405 | 1081 | }); |
| 406 | 1082 | |
| 407 | 1083 | yield* instance.start; |
| 408 | 1084 | |
| 409 | 1085 | assert.equal(yield* Queue.take(starts), 1); |
| 1086 | assert.equal(yield* Queue.take(failures), "pid=123 code=1"); | |
| 410 | 1087 | |
| 411 | 1088 | yield* TestClock.adjust(Duration.millis(499)); |
| 412 | 1089 | assert.equal(yield* Queue.size(starts), 0); |
| ... | ... | @@ -657,6 +1334,13 @@ describe("DesktopBackendManager", () => { |
| 657 | 1334 | yield* instance.start; |
| 658 | 1335 | |
| 659 | 1336 | assert.equal(yield* Queue.take(starts), 1); |
| 1337 | let restartScheduled = false; | |
| 1338 | while (!restartScheduled) { | |
| 1339 | restartScheduled = (yield* instance.snapshot).restartScheduled; | |
| 1340 | if (!restartScheduled) { | |
| 1341 | yield* Effect.yieldNow; | |
| 1342 | } | |
| 1343 | } | |
| 660 | 1344 | |
| 661 | 1345 | yield* instance.start; |
| 662 | 1346 | assert.equal(yield* Queue.take(starts), 2); |
apps/desktop/src/backend/DesktopBackendManager.ts+466-144| ... | ... | @@ -33,7 +33,6 @@ import * as FileSystem from "effect/FileSystem"; |
| 33 | 33 | import * as Option from "effect/Option"; |
| 34 | 34 | import * as PlatformError from "effect/PlatformError"; |
| 35 | 35 | import * as Ref from "effect/Ref"; |
| 36 | import * as Result from "effect/Result"; | |
| 37 | 36 | import * as Schedule from "effect/Schedule"; |
| 38 | 37 | import * as Schema from "effect/Schema"; |
| 39 | 38 | import * as Semaphore from "effect/Semaphore"; |
| ... | ... | @@ -46,10 +45,13 @@ import { |
| 46 | 45 | DesktopBackendBootstrap, |
| 47 | 46 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 48 | 47 | PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 48 | DesktopTelemetryControlMessage, | |
| 49 | type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue, | |
| 49 | 50 | } from "@t3tools/contracts"; |
| 50 | 51 | import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; |
| 51 | 52 | |
| 52 | 53 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 54 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | |
| 53 | 55 | |
| 54 | 56 | const INITIAL_RESTART_DELAY = Duration.millis(500); |
| 55 | 57 | const MAX_RESTART_DELAY = Duration.seconds(10); |
| ... | ... | @@ -62,7 +64,10 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); |
| 62 | 64 | const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); |
| 63 | 65 | const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); |
| 64 | 66 | const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); |
| 67 | const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); | |
| 65 | 68 | const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; |
| 69 | const { logWarning: logBackendProcessWarning } = | |
| 70 | DesktopObservability.makeComponentLogger("desktop-backend-process"); | |
| 66 | 71 | |
| 67 | 72 | type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; |
| 68 | 73 | |
| ... | ... | @@ -70,13 +75,17 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; |
| 70 | 75 | |
| 71 | 76 | export type BackendProcessOutputStream = "stdout" | "stderr"; |
| 72 | 77 | |
| 73 | export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; | |
| 74 | ||
| 75 | export interface DesktopBackendStartConfig { | |
| 78 | export interface BackendProcessContext { | |
| 76 | 79 | readonly executablePath: string; |
| 77 | readonly args: ReadonlyArray<string>; | |
| 78 | 80 | readonly entryPath: string; |
| 79 | 81 | readonly cwd: string; |
| 82 | readonly httpBaseUrl: URL; | |
| 83 | } | |
| 84 | ||
| 85 | export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; | |
| 86 | ||
| 87 | export interface DesktopBackendStartConfig extends BackendProcessContext { | |
| 88 | readonly args: ReadonlyArray<string>; | |
| 80 | 89 | readonly env: Record<string, string | undefined>; |
| 81 | 90 | // When true the spawner merges the desktop process.env on top of `env`; |
| 82 | 91 | // when false `env` is passed verbatim. WSL mode opts out so a leaking |
| ... | ... | @@ -106,55 +115,122 @@ export interface PreflightFailure { |
| 106 | 115 | interface BackendProcessExit { |
| 107 | 116 | readonly code: Option.Option<number>; |
| 108 | 117 | readonly reason: string; |
| 109 | readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; | |
| 110 | 118 | } |
| 111 | 119 | |
| 112 | export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()( | |
| 113 | "BackendTimeoutError", | |
| 120 | const backendProcessContextSchema = { | |
| 121 | executablePath: Schema.String, | |
| 122 | entryPath: Schema.String, | |
| 123 | cwd: Schema.String, | |
| 124 | httpBaseUrl: Schema.URL, | |
| 125 | }; | |
| 126 | ||
| 127 | export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass<BackendReadinessTimeoutError>()( | |
| 128 | "BackendReadinessTimeoutError", | |
| 114 | 129 | { |
| 115 | url: Schema.instanceOf(URL), | |
| 130 | ...backendProcessContextSchema, | |
| 131 | readinessUrl: Schema.URL, | |
| 132 | timeoutMs: Schema.Number, | |
| 133 | cause: Schema.Defect(), | |
| 116 | 134 | }, |
| 117 | 135 | ) { |
| 118 | override get message() { | |
| 119 | return `Timed out waiting for backend readiness at ${this.url.href}.`; | |
| 136 | override get message(): string { | |
| 137 | return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; | |
| 120 | 138 | } |
| 121 | 139 | } |
| 122 | 140 | |
| 123 | class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()( | |
| 141 | export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()( | |
| 124 | 142 | "BackendProcessBootstrapEncodeError", |
| 125 | 143 | { |
| 126 | entryPath: Schema.String, | |
| 144 | ...backendProcessContextSchema, | |
| 127 | 145 | cause: Schema.Defect(), |
| 128 | 146 | }, |
| 129 | 147 | ) { |
| 130 | override get message() { | |
| 148 | override get message(): string { | |
| 131 | 149 | return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; |
| 132 | 150 | } |
| 133 | 151 | } |
| 134 | 152 | |
| 135 | class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()( | |
| 153 | export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()( | |
| 136 | 154 | "BackendProcessSpawnError", |
| 137 | 155 | { |
| 138 | executablePath: Schema.String, | |
| 156 | ...backendProcessContextSchema, | |
| 157 | cause: Schema.Defect(), | |
| 158 | }, | |
| 159 | ) { | |
| 160 | override get message(): string { | |
| 161 | return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | export class BackendProcessOutputReadError extends Schema.TaggedErrorClass<BackendProcessOutputReadError>()( | |
| 166 | "BackendProcessOutputReadError", | |
| 167 | { | |
| 168 | ...backendProcessContextSchema, | |
| 169 | pid: Schema.Number, | |
| 170 | streamName: Schema.Literals(["stdout", "stderr"]), | |
| 171 | cause: Schema.Defect(), | |
| 172 | }, | |
| 173 | ) { | |
| 174 | override get message(): string { | |
| 175 | return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`; | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass<BackendProcessOutputHandlingError>()( | |
| 180 | "BackendProcessOutputHandlingError", | |
| 181 | { | |
| 182 | ...backendProcessContextSchema, | |
| 183 | pid: Schema.Number, | |
| 184 | streamName: Schema.Literals(["stdout", "stderr"]), | |
| 185 | chunkByteLength: Schema.Number, | |
| 186 | cause: Schema.Defect(), | |
| 187 | }, | |
| 188 | ) { | |
| 189 | override get message(): string { | |
| 190 | return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`; | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | export type BackendProcessOutputError = | |
| 195 | | BackendProcessOutputReadError | |
| 196 | | BackendProcessOutputHandlingError; | |
| 197 | ||
| 198 | export class BackendProcessExitStatusError extends Schema.TaggedErrorClass<BackendProcessExitStatusError>()( | |
| 199 | "BackendProcessExitStatusError", | |
| 200 | { | |
| 201 | ...backendProcessContextSchema, | |
| 202 | pid: Schema.Number, | |
| 139 | 203 | cause: Schema.Defect(), |
| 140 | 204 | }, |
| 141 | 205 | ) { |
| 142 | override get message() { | |
| 143 | return `Failed to spawn the desktop backend process at ${this.executablePath}.`; | |
| 206 | override get message(): string { | |
| 207 | return `Failed to read the exit status of desktop backend process ${this.pid}.`; | |
| 144 | 208 | } |
| 145 | 209 | } |
| 146 | 210 | |
| 147 | type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; | |
| 211 | export const BackendProcessError = Schema.Union([ | |
| 212 | BackendProcessBootstrapEncodeError, | |
| 213 | BackendProcessSpawnError, | |
| 214 | BackendProcessExitStatusError, | |
| 215 | ]); | |
| 216 | export type BackendProcessError = typeof BackendProcessError.Type; | |
| 148 | 217 | |
| 149 | 218 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { |
| 219 | readonly desktopTelemetryStream: Stream.Stream<Uint8Array>; | |
| 220 | readonly onDesktopTelemetryControl?: ( | |
| 221 | message: DesktopTelemetryControlMessageValue, | |
| 222 | ) => Effect.Effect<void>; | |
| 150 | 223 | readonly readinessTimeout?: Duration.Duration; |
| 224 | readonly outputDrainTimeout?: Duration.Duration; | |
| 151 | 225 | readonly onStarted?: (pid: number) => Effect.Effect<void>; |
| 226 | readonly onExitObserved?: () => Effect.Effect<void>; | |
| 152 | 227 | readonly onReady?: () => Effect.Effect<void>; |
| 153 | readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>; | |
| 228 | readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>; | |
| 154 | 229 | readonly onOutput?: ( |
| 155 | 230 | streamName: BackendProcessOutputStream, |
| 156 | 231 | chunk: Uint8Array, |
| 157 | ) => Effect.Effect<void>; | |
| 232 | ) => Effect.Effect<void, Error>; | |
| 233 | readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect<void>; | |
| 158 | 234 | } |
| 159 | 235 | |
| 160 | 236 | export interface DesktopBackendSnapshot { |
| ... | ... | @@ -225,6 +301,8 @@ interface ActiveBackendRun { |
| 225 | 301 | readonly scope: Scope.Closeable; |
| 226 | 302 | readonly fiber: Option.Option<Fiber.Fiber<void, never>>; |
| 227 | 303 | readonly pid: Option.Option<number>; |
| 304 | readonly exitObserved: boolean; | |
| 305 | readonly stopRequested: boolean; | |
| 228 | 306 | } |
| 229 | 307 | |
| 230 | 308 | interface BackendManagerState { |
| ... | ... | @@ -266,76 +344,130 @@ const calculateRestartDelay = (attempt: number): Duration.Duration => |
| 266 | 344 | |
| 267 | 345 | const closeRun = ( |
| 268 | 346 | run: ActiveBackendRun, |
| 347 | parentScope: Scope.Scope, | |
| 269 | 348 | options?: { readonly timeout?: Duration.Duration }, |
| 270 | ): Effect.Effect<void> => { | |
| 349 | ): Effect.Effect<boolean> => { | |
| 271 | 350 | const waitForFiber = Option.match(run.fiber, { |
| 272 | 351 | onNone: () => Effect.void, |
| 273 | 352 | onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), |
| 274 | 353 | }); |
| 275 | 354 | const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); |
| 355 | const timeout = options?.timeout; | |
| 276 | 356 | |
| 277 | return ( | |
| 278 | options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close | |
| 279 | ).pipe(Effect.ignore); | |
| 357 | if (!timeout) { | |
| 358 | return close.pipe(Effect.as(true)); | |
| 359 | } | |
| 360 | ||
| 361 | return Effect.forkIn(close, parentScope).pipe( | |
| 362 | Effect.flatMap((closeFiber) => | |
| 363 | Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)), | |
| 364 | ), | |
| 365 | ); | |
| 280 | 366 | }; |
| 281 | 367 | |
| 282 | const waitForHttpReady = ( | |
| 283 | baseUrl: URL, | |
| 284 | timeout: Duration.Duration, | |
| 285 | ): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => { | |
| 286 | const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); | |
| 368 | export const waitForHttpReady = ( | |
| 369 | options: BackendProcessContext & { readonly timeout: Duration.Duration }, | |
| 370 | ): Effect.Effect<void, BackendReadinessTimeoutError, HttpClient.HttpClient> => { | |
| 371 | const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); | |
| 287 | 372 | return waitForHttpReadyShared({ |
| 288 | baseUrl: baseUrl.href, | |
| 373 | baseUrl: options.httpBaseUrl.href, | |
| 289 | 374 | path: BACKEND_READINESS_PATH, |
| 290 | timeoutMs: Duration.toMillis(timeout), | |
| 375 | timeoutMs: Duration.toMillis(options.timeout), | |
| 291 | 376 | intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL), |
| 292 | 377 | probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT), |
| 293 | makeError: () => new BackendTimeoutError({ url: readinessUrl }), | |
| 378 | makeError: ({ cause }) => | |
| 379 | new BackendReadinessTimeoutError({ | |
| 380 | executablePath: options.executablePath, | |
| 381 | entryPath: options.entryPath, | |
| 382 | cwd: options.cwd, | |
| 383 | httpBaseUrl: options.httpBaseUrl, | |
| 384 | readinessUrl, | |
| 385 | timeoutMs: Duration.toMillis(options.timeout), | |
| 386 | cause, | |
| 387 | }), | |
| 294 | 388 | }); |
| 295 | 389 | }; |
| 296 | 390 | |
| 297 | function describeProcessExit( | |
| 298 | result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>, | |
| 299 | ): BackendProcessExit { | |
| 300 | if (Result.isSuccess(result)) { | |
| 301 | return { | |
| 302 | code: Option.some(result.success), | |
| 303 | reason: `code=${result.success}`, | |
| 304 | result, | |
| 305 | }; | |
| 306 | } | |
| 307 | ||
| 308 | return { | |
| 309 | code: Option.none(), | |
| 310 | reason: result.failure.message, | |
| 311 | result, | |
| 312 | }; | |
| 313 | } | |
| 314 | ||
| 315 | 391 | function drainBackendOutput( |
| 392 | context: BackendProcessContext & { readonly pid: number }, | |
| 316 | 393 | streamName: BackendProcessOutputStream, |
| 317 | 394 | stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, |
| 318 | onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>, | |
| 395 | onOutput: ( | |
| 396 | streamName: BackendProcessOutputStream, | |
| 397 | chunk: Uint8Array, | |
| 398 | ) => Effect.Effect<void, Error>, | |
| 399 | onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect<void>, | |
| 319 | 400 | ): Effect.Effect<void> { |
| 320 | 401 | return stream.pipe( |
| 321 | Stream.runForEach((chunk) => onOutput(streamName, chunk)), | |
| 322 | Effect.ignore, | |
| 402 | Stream.mapError( | |
| 403 | (cause) => | |
| 404 | new BackendProcessOutputReadError({ | |
| 405 | ...context, | |
| 406 | streamName, | |
| 407 | cause, | |
| 408 | }), | |
| 409 | ), | |
| 410 | Stream.runForEach((chunk) => | |
| 411 | onOutput(streamName, chunk).pipe( | |
| 412 | Effect.mapError( | |
| 413 | (cause) => | |
| 414 | new BackendProcessOutputHandlingError({ | |
| 415 | ...context, | |
| 416 | streamName, | |
| 417 | chunkByteLength: chunk.byteLength, | |
| 418 | cause, | |
| 419 | }), | |
| 420 | ), | |
| 421 | Effect.catchTag("BackendProcessOutputHandlingError", onOutputFailure), | |
| 422 | ), | |
| 423 | ), | |
| 424 | Effect.catchTags({ | |
| 425 | BackendProcessOutputReadError: onOutputFailure, | |
| 426 | }), | |
| 323 | 427 | ); |
| 324 | 428 | } |
| 325 | 429 | |
| 326 | 430 | const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); |
| 431 | const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect( | |
| 432 | Schema.fromJsonString(DesktopTelemetryControlMessage), | |
| 433 | ); | |
| 327 | 434 | |
| 328 | const runBackendProcess = Effect.fn("runBackendProcess")(function* ( | |
| 435 | export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( | |
| 329 | 436 | options: RunBackendProcessOptions, |
| 330 | 437 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { |
| 331 | 438 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 332 | 439 | const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( |
| 333 | 440 | Effect.mapError( |
| 334 | (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }), | |
| 441 | (cause) => | |
| 442 | new BackendProcessBootstrapEncodeError({ | |
| 443 | executablePath: options.executablePath, | |
| 444 | entryPath: options.entryPath, | |
| 445 | cwd: options.cwd, | |
| 446 | httpBaseUrl: options.httpBaseUrl, | |
| 447 | cause, | |
| 448 | }), | |
| 335 | 449 | ), |
| 336 | 450 | ); |
| 337 | 451 | const onOutput = options.onOutput ?? (() => Effect.void); |
| 338 | 452 | const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); |
| 453 | const additionalFds: Record<`fd${number}`, ChildProcess.AdditionalFdConfig> = {}; | |
| 454 | if (options.bootstrapDelivery === "fd3") { | |
| 455 | additionalFds.fd3 = { | |
| 456 | type: "input", | |
| 457 | stream: bootstrapStream, | |
| 458 | }; | |
| 459 | if (options.bootstrap.desktopTelemetryFd !== undefined) { | |
| 460 | additionalFds[`fd${options.bootstrap.desktopTelemetryFd}`] = { | |
| 461 | type: "input", | |
| 462 | stream: options.desktopTelemetryStream, | |
| 463 | }; | |
| 464 | } | |
| 465 | if (options.bootstrap.desktopTelemetryControlFd !== undefined) { | |
| 466 | additionalFds[`fd${options.bootstrap.desktopTelemetryControlFd}`] = { | |
| 467 | type: "output", | |
| 468 | }; | |
| 469 | } | |
| 470 | } | |
| 339 | 471 | const command = ChildProcess.make(options.executablePath, options.args, { |
| 340 | 472 | cwd: options.cwd, |
| 341 | 473 | env: options.env, |
| ... | ... | @@ -350,34 +482,131 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( |
| 350 | 482 | // wsl.exe drops additional file descriptors when forwarding to the Linux |
| 351 | 483 | // side, so the WSL spawn path delivers the bootstrap envelope via stdin |
| 352 | 484 | // (`--bootstrap-fd 0`) instead. |
| 353 | ...(options.bootstrapDelivery === "fd3" | |
| 354 | ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } | |
| 355 | : {}), | |
| 485 | ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}), | |
| 356 | 486 | }); |
| 357 | 487 | |
| 358 | const handle = yield* spawner | |
| 359 | .spawn(command) | |
| 360 | .pipe( | |
| 361 | Effect.mapError( | |
| 362 | (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }), | |
| 363 | ), | |
| 364 | ); | |
| 488 | const handle = yield* spawner.spawn(command).pipe( | |
| 489 | Effect.mapError( | |
| 490 | (cause) => | |
| 491 | new BackendProcessSpawnError({ | |
| 492 | executablePath: options.executablePath, | |
| 493 | entryPath: options.entryPath, | |
| 494 | cwd: options.cwd, | |
| 495 | httpBaseUrl: options.httpBaseUrl, | |
| 496 | cause, | |
| 497 | }), | |
| 498 | ), | |
| 499 | ); | |
| 500 | const outputFibers: Array<Fiber.Fiber<void, never>> = []; | |
| 365 | 501 | |
| 366 | 502 | yield* options.onStarted?.(handle.pid) ?? Effect.void; |
| 503 | if ( | |
| 504 | options.bootstrap.desktopTelemetryControlFd !== undefined && | |
| 505 | options.onDesktopTelemetryControl !== undefined | |
| 506 | ) { | |
| 507 | const controlFd = options.bootstrap.desktopTelemetryControlFd; | |
| 508 | const handleControl = options.onDesktopTelemetryControl; | |
| 509 | yield* handle.getOutputFd(controlFd).pipe( | |
| 510 | Stream.decodeText(), | |
| 511 | Stream.splitLines, | |
| 512 | Stream.filter((line) => line.trim().length > 0), | |
| 513 | Stream.runForEach((line) => | |
| 514 | decodeDesktopTelemetryControlLine(line).pipe( | |
| 515 | Effect.flatMap(handleControl), | |
| 516 | Effect.catchCause((cause) => | |
| 517 | logBackendProcessWarning("ignored invalid desktop telemetry control message", { | |
| 518 | fd: controlFd, | |
| 519 | cause: Cause.pretty(cause), | |
| 520 | }), | |
| 521 | ), | |
| 522 | ), | |
| 523 | ), | |
| 524 | Effect.catchCause((cause) => | |
| 525 | logBackendProcessWarning("desktop telemetry control stream stopped", { | |
| 526 | fd: controlFd, | |
| 527 | cause: Cause.pretty(cause), | |
| 528 | }), | |
| 529 | ), | |
| 530 | Effect.ensuring( | |
| 531 | handleControl({ | |
| 532 | version: 1, | |
| 533 | type: "setDiagnosticsDemand", | |
| 534 | enabled: false, | |
| 535 | }), | |
| 536 | ), | |
| 537 | Effect.forkScoped, | |
| 538 | ); | |
| 539 | } | |
| 367 | 540 | if (options.captureOutput) { |
| 368 | yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); | |
| 369 | yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); | |
| 541 | const outputContext = { | |
| 542 | executablePath: options.executablePath, | |
| 543 | entryPath: options.entryPath, | |
| 544 | cwd: options.cwd, | |
| 545 | httpBaseUrl: options.httpBaseUrl, | |
| 546 | pid: Number(handle.pid), | |
| 547 | }; | |
| 548 | const onOutputFailure = options.onOutputFailure ?? (() => Effect.void); | |
| 549 | outputFibers.push( | |
| 550 | yield* drainBackendOutput( | |
| 551 | outputContext, | |
| 552 | "stdout", | |
| 553 | handle.stdout, | |
| 554 | onOutput, | |
| 555 | onOutputFailure, | |
| 556 | ).pipe(Effect.forkScoped), | |
| 557 | yield* drainBackendOutput( | |
| 558 | outputContext, | |
| 559 | "stderr", | |
| 560 | handle.stderr, | |
| 561 | onOutput, | |
| 562 | onOutputFailure, | |
| 563 | ).pipe(Effect.forkScoped), | |
| 564 | ); | |
| 370 | 565 | } |
| 371 | yield* waitForHttpReady( | |
| 372 | options.httpBaseUrl, | |
| 373 | options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | |
| 374 | ).pipe( | |
| 566 | yield* waitForHttpReady({ | |
| 567 | executablePath: options.executablePath, | |
| 568 | entryPath: options.entryPath, | |
| 569 | cwd: options.cwd, | |
| 570 | httpBaseUrl: options.httpBaseUrl, | |
| 571 | timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | |
| 572 | }).pipe( | |
| 375 | 573 | Effect.tap(() => options.onReady?.() ?? Effect.void), |
| 376 | Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), | |
| 574 | Effect.catchTags({ | |
| 575 | BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, | |
| 576 | }), | |
| 377 | 577 | Effect.forkScoped, |
| 378 | 578 | ); |
| 379 | 579 | |
| 380 | return describeProcessExit(yield* Effect.result(handle.exitCode)); | |
| 580 | const exit = yield* handle.exitCode.pipe( | |
| 581 | Effect.mapError( | |
| 582 | (cause) => | |
| 583 | new BackendProcessExitStatusError({ | |
| 584 | executablePath: options.executablePath, | |
| 585 | entryPath: options.entryPath, | |
| 586 | cwd: options.cwd, | |
| 587 | httpBaseUrl: options.httpBaseUrl, | |
| 588 | pid: Number(handle.pid), | |
| 589 | cause, | |
| 590 | }), | |
| 591 | ), | |
| 592 | Effect.exit, | |
| 593 | ); | |
| 594 | yield* options.onExitObserved?.() ?? Effect.void; | |
| 595 | yield* Effect.forEach(outputFibers, Fiber.await, { | |
| 596 | concurrency: "unbounded", | |
| 597 | discard: true, | |
| 598 | }).pipe( | |
| 599 | Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT), | |
| 600 | Effect.ignore, | |
| 601 | ); | |
| 602 | if (Exit.isFailure(exit)) { | |
| 603 | return yield* Effect.failCause(exit.cause); | |
| 604 | } | |
| 605 | const exitCode = exit.value; | |
| 606 | return { | |
| 607 | code: Option.some(exitCode), | |
| 608 | reason: `code=${exitCode}`, | |
| 609 | } satisfies BackendProcessExit; | |
| 381 | 610 | }); |
| 382 | 611 | |
| 383 | 612 | // Factory for one pooled backend instance. The returned instance owns |
| ... | ... | @@ -394,12 +623,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 394 | 623 | | ChildProcessSpawner.ChildProcessSpawner |
| 395 | 624 | | HttpClient.HttpClient |
| 396 | 625 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 626 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher | |
| 397 | 627 | | Scope.Scope |
| 398 | 628 | > { |
| 399 | 629 | const parentScope = yield* Scope.Scope; |
| 400 | 630 | const fileSystem = yield* FileSystem.FileSystem; |
| 401 | 631 | const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; |
| 402 | 632 | const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); |
| 633 | const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; | |
| 403 | 634 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 404 | 635 | const httpClient = yield* HttpClient.HttpClient; |
| 405 | 636 | const state = yield* Ref.make(initialState); |
| ... | ... | @@ -444,6 +675,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 444 | 675 | Effect.gen(function* () { |
| 445 | 676 | const current = yield* Ref.get(state); |
| 446 | 677 | if (Option.isSome(current.active)) { |
| 678 | if (!current.desiredRunning) { | |
| 679 | yield* Ref.update(state, (latest) => ({ | |
| 680 | ...latest, | |
| 681 | desiredRunning: true, | |
| 682 | })); | |
| 683 | } | |
| 447 | 684 | return; |
| 448 | 685 | } |
| 449 | 686 | |
| ... | ... | @@ -462,6 +699,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 462 | 699 | Effect.option, |
| 463 | 700 | ); |
| 464 | 701 | if (Option.isNone(config)) { |
| 702 | if (current.desiredRunning) { | |
| 703 | yield* scheduleRestart("failed to generate desktop backend configuration"); | |
| 704 | } | |
| 465 | 705 | return; |
| 466 | 706 | } |
| 467 | 707 | const entryExists = yield* fileSystem |
| ... | ... | @@ -561,6 +801,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 561 | 801 | scope: runScope, |
| 562 | 802 | fiber: Option.none(), |
| 563 | 803 | pid: Option.none(), |
| 804 | exitObserved: false, | |
| 805 | stopRequested: false, | |
| 564 | 806 | } satisfies ActiveBackendRun), |
| 565 | 807 | nextRunId: latest.nextRunId + 1, |
| 566 | 808 | }, |
| ... | ... | @@ -571,54 +813,70 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 571 | 813 | ) { |
| 572 | 814 | yield* mutex.withPermits(1)( |
| 573 | 815 | Effect.gen(function* () { |
| 574 | const { isCurrentRun, nextState, pid } = yield* Ref.modify( | |
| 575 | state, | |
| 576 | ( | |
| 577 | latest, | |
| 578 | ): readonly [ | |
| 579 | { | |
| 580 | readonly isCurrentRun: boolean; | |
| 581 | readonly nextState: BackendManagerState; | |
| 582 | readonly pid: Option.Option<number>; | |
| 583 | }, | |
| 584 | BackendManagerState, | |
| 585 | ] => { | |
| 586 | const currentRun = Option.getOrUndefined(latest.active); | |
| 587 | if (currentRun?.id !== runId) { | |
| 816 | const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = | |
| 817 | yield* Ref.modify( | |
| 818 | state, | |
| 819 | ( | |
| 820 | latest, | |
| 821 | ): readonly [ | |
| 822 | { | |
| 823 | readonly isCurrentRun: boolean; | |
| 824 | readonly nextState: BackendManagerState; | |
| 825 | readonly pid: Option.Option<number>; | |
| 826 | readonly exitObserved: boolean; | |
| 827 | readonly stopRequested: boolean; | |
| 828 | readonly wasReady: boolean; | |
| 829 | }, | |
| 830 | BackendManagerState, | |
| 831 | ] => { | |
| 832 | const currentRun = Option.getOrUndefined(latest.active); | |
| 833 | if (currentRun?.id !== runId) { | |
| 834 | return [ | |
| 835 | { | |
| 836 | isCurrentRun: false, | |
| 837 | nextState: latest, | |
| 838 | pid: Option.none<number>(), | |
| 839 | exitObserved: false, | |
| 840 | stopRequested: false, | |
| 841 | wasReady: false, | |
| 842 | }, | |
| 843 | latest, | |
| 844 | ] as const; | |
| 845 | } | |
| 846 | ||
| 847 | const next = { | |
| 848 | ...latest, | |
| 849 | active: Option.none<ActiveBackendRun>(), | |
| 850 | ready: false, | |
| 851 | }; | |
| 588 | 852 | return [ |
| 589 | 853 | { |
| 590 | isCurrentRun: false, | |
| 591 | nextState: latest, | |
| 592 | pid: Option.none<number>(), | |
| 854 | isCurrentRun: true, | |
| 855 | nextState: next, | |
| 856 | pid: currentRun.pid, | |
| 857 | exitObserved: currentRun.exitObserved, | |
| 858 | stopRequested: currentRun.stopRequested, | |
| 859 | wasReady: latest.ready, | |
| 593 | 860 | }, |
| 594 | latest, | |
| 861 | next, | |
| 595 | 862 | ] as const; |
| 596 | } | |
| 597 | ||
| 598 | const next = { | |
| 599 | ...latest, | |
| 600 | active: Option.none<ActiveBackendRun>(), | |
| 601 | ready: false, | |
| 602 | }; | |
| 603 | return [ | |
| 604 | { | |
| 605 | isCurrentRun: true, | |
| 606 | nextState: next, | |
| 607 | pid: currentRun.pid, | |
| 608 | }, | |
| 609 | next, | |
| 610 | ] as const; | |
| 611 | }, | |
| 612 | ); | |
| 863 | }, | |
| 864 | ); | |
| 613 | 865 | |
| 614 | 866 | if (isCurrentRun) { |
| 867 | yield* desktopTelemetryPublisher.removeControlSource(spec.id); | |
| 615 | 868 | if (Option.isSome(pid)) { |
| 616 | yield* backendOutputLog.writeSessionBoundary({ | |
| 617 | phase: "END", | |
| 618 | details: `pid=${pid.value} ${reason}`, | |
| 619 | }); | |
| 869 | if (exitObserved && !stopRequested) { | |
| 870 | yield* backendOutputLog.persistFailure({ | |
| 871 | details: `pid=${pid.value} ${reason}`, | |
| 872 | }); | |
| 873 | } else { | |
| 874 | yield* backendOutputLog.discardSession; | |
| 875 | } | |
| 876 | } | |
| 877 | if (wasReady) { | |
| 878 | yield* spec.onShutdown?.() ?? Effect.void; | |
| 620 | 879 | } |
| 621 | yield* spec.onShutdown?.() ?? Effect.void; | |
| 622 | 880 | } |
| 623 | 881 | |
| 624 | 882 | if (isCurrentRun && nextState.desiredRunning) { |
| ... | ... | @@ -630,16 +888,23 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 630 | 888 | |
| 631 | 889 | const program = runBackendProcess({ |
| 632 | 890 | ...config.value, |
| 891 | desktopTelemetryStream: desktopTelemetryPublisher.encoded, | |
| 892 | onDesktopTelemetryControl: (message) => | |
| 893 | desktopTelemetryPublisher.handleControlForSource(spec.id, message), | |
| 633 | 894 | onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { |
| 634 | 895 | yield* updateActiveRun(runId, (run) => ({ |
| 635 | 896 | ...run, |
| 636 | 897 | pid: Option.some(pid), |
| 637 | 898 | })); |
| 638 | yield* backendOutputLog.writeSessionBoundary({ | |
| 639 | phase: "START", | |
| 899 | yield* backendOutputLog.beginSession({ | |
| 640 | 900 | details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, |
| 641 | 901 | }); |
| 642 | 902 | }), |
| 903 | onExitObserved: () => | |
| 904 | updateActiveRun(runId, (run) => ({ | |
| 905 | ...run, | |
| 906 | exitObserved: true, | |
| 907 | })), | |
| 643 | 908 | onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { |
| 644 | 909 | const isCurrentRun = yield* Ref.modify(state, (latest) => { |
| 645 | 910 | const activeRun = Option.getOrUndefined(latest.active); |
| ... | ... | @@ -662,10 +927,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 662 | 927 | |
| 663 | 928 | yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; |
| 664 | 929 | }), |
| 665 | onReadinessFailure: (error) => | |
| 666 | logInstanceWarning("backend readiness check failed during bootstrap", { | |
| 667 | error: error.message, | |
| 668 | }), | |
| 930 | onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( | |
| 931 | function* (error) { | |
| 932 | yield* logInstanceWarning("backend readiness check failed during bootstrap", { | |
| 933 | error: error.message, | |
| 934 | }); | |
| 935 | yield* backendOutputLog.persistFailureSnapshot({ | |
| 936 | details: error.message, | |
| 937 | }); | |
| 938 | }, | |
| 939 | ), | |
| 669 | 940 | onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), |
| 670 | 941 | }).pipe( |
| 671 | 942 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| ... | ... | @@ -750,41 +1021,92 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 750 | 1021 | const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { |
| 751 | 1022 | readonly timeout?: Duration.Duration; |
| 752 | 1023 | }) { |
| 753 | const { active, restartFiber } = yield* mutex.withPermits(1)( | |
| 1024 | const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)( | |
| 754 | 1025 | Effect.gen(function* () { |
| 755 | const result = yield* Ref.modify(state, (latest) => [ | |
| 756 | { | |
| 757 | active: latest.active, | |
| 758 | restartFiber: latest.restartFiber, | |
| 759 | }, | |
| 760 | { | |
| 761 | ...latest, | |
| 762 | desiredRunning: false, | |
| 763 | ready: false, | |
| 764 | active: Option.none<ActiveBackendRun>(), | |
| 765 | restartFiber: Option.none<Fiber.Fiber<void, never>>(), | |
| 766 | }, | |
| 767 | ]); | |
| 768 | // Ignore failures from spec.onShutdown so a downstream throw | |
| 769 | // can't abort the rest of stop(). Ref.modify above already | |
| 770 | // flipped state to "no active run / no restart fiber", and the | |
| 771 | // physical cleanup (Fiber.interrupt + closeRun) runs after the | |
| 772 | // mutex releases. If onShutdown were allowed to propagate, both | |
| 773 | // would be skipped and the child process + restart fiber would | |
| 774 | // be orphaned while state claimed nothing was running — the | |
| 775 | // next start() would then spawn a second backend on top. | |
| 776 | yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); | |
| 1026 | const result = yield* Ref.modify(state, (latest) => { | |
| 1027 | const active = Option.map(latest.active, (run) => | |
| 1028 | run.exitObserved ? run : { ...run, stopRequested: true }, | |
| 1029 | ); | |
| 1030 | return [ | |
| 1031 | { | |
| 1032 | active, | |
| 1033 | restartFiber: latest.restartFiber, | |
| 1034 | notifyShutdown: latest.ready, | |
| 1035 | }, | |
| 1036 | { | |
| 1037 | ...latest, | |
| 1038 | desiredRunning: false, | |
| 1039 | ready: false, | |
| 1040 | active, | |
| 1041 | restartFiber: Option.none<Fiber.Fiber<void, never>>(), | |
| 1042 | }, | |
| 1043 | ] as const; | |
| 1044 | }); | |
| 777 | 1045 | return result; |
| 778 | 1046 | }), |
| 779 | 1047 | ); |
| 780 | 1048 | |
| 1049 | if (notifyShutdown) { | |
| 1050 | yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); | |
| 1051 | } | |
| 781 | 1052 | yield* Option.match(restartFiber, { |
| 782 | 1053 | onNone: () => Effect.void, |
| 783 | 1054 | onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), |
| 784 | 1055 | }); |
| 785 | 1056 | yield* Option.match(active, { |
| 786 | 1057 | onNone: () => Effect.void, |
| 787 | onSome: (run) => closeRun(run, options), | |
| 1058 | onSome: (run) => | |
| 1059 | Effect.gen(function* () { | |
| 1060 | const closed = yield* closeRun(run, parentScope, options); | |
| 1061 | if (!closed) { | |
| 1062 | return; | |
| 1063 | } | |
| 1064 | const cleanup = yield* mutex.withPermits(1)( | |
| 1065 | Ref.modify( | |
| 1066 | state, | |
| 1067 | ( | |
| 1068 | latest, | |
| 1069 | ): readonly [ | |
| 1070 | { | |
| 1071 | readonly needsCleanup: boolean; | |
| 1072 | readonly shouldStart: boolean; | |
| 1073 | }, | |
| 1074 | BackendManagerState, | |
| 1075 | ] => { | |
| 1076 | const current = Option.getOrUndefined(latest.active); | |
| 1077 | if (current?.id !== run.id) { | |
| 1078 | return [ | |
| 1079 | { | |
| 1080 | needsCleanup: false, | |
| 1081 | shouldStart: | |
| 1082 | latest.desiredRunning && | |
| 1083 | Option.isNone(latest.active) && | |
| 1084 | Option.isNone(latest.restartFiber), | |
| 1085 | }, | |
| 1086 | latest, | |
| 1087 | ]; | |
| 1088 | } | |
| 1089 | return [ | |
| 1090 | { | |
| 1091 | needsCleanup: true, | |
| 1092 | shouldStart: latest.desiredRunning, | |
| 1093 | }, | |
| 1094 | { | |
| 1095 | ...latest, | |
| 1096 | active: Option.none<ActiveBackendRun>(), | |
| 1097 | }, | |
| 1098 | ]; | |
| 1099 | }, | |
| 1100 | ), | |
| 1101 | ); | |
| 1102 | if (cleanup.needsCleanup) { | |
| 1103 | yield* desktopTelemetryPublisher.removeControlSource(spec.id); | |
| 1104 | yield* backendOutputLog.discardSession; | |
| 1105 | } | |
| 1106 | if (cleanup.shouldStart) { | |
| 1107 | yield* start; | |
| 1108 | } | |
| 1109 | }), | |
| 788 | 1110 | }); |
| 789 | 1111 | }); |
| 790 | 1112 |
apps/desktop/src/backend/DesktopBackendPool.test.ts+14-1| ... | ... | @@ -5,11 +5,13 @@ import * as FileSystem from "effect/FileSystem"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import * as Ref from "effect/Ref"; |
| 8 | import * as Stream from "effect/Stream"; | |
| 8 | 9 | import { HttpClient } from "effect/unstable/http"; |
| 9 | 10 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 11 | |
| 11 | 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 12 | 13 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 14 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | |
| 13 | 15 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 14 | 16 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 15 | 17 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| ... | ... | @@ -56,10 +58,21 @@ function makePoolLayer( |
| 56 | 58 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { |
| 57 | 59 | forInstance: () => |
| 58 | 60 | Effect.succeed({ |
| 59 | writeSessionBoundary: () => Effect.void, | |
| 61 | beginSession: () => Effect.void, | |
| 60 | 62 | writeOutputChunk: () => Effect.void, |
| 63 | persistFailureSnapshot: () => Effect.void, | |
| 64 | persistFailure: () => Effect.void, | |
| 65 | discardSession: Effect.void, | |
| 61 | 66 | } satisfies DesktopObservability.DesktopBackendOutputLogShape), |
| 62 | 67 | } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), |
| 68 | Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { | |
| 69 | latest: Effect.succeed(Option.none()), | |
| 70 | changes: Stream.empty, | |
| 71 | encoded: Stream.empty, | |
| 72 | handleControl: () => Effect.void, | |
| 73 | handleControlForSource: () => Effect.void, | |
| 74 | removeControlSource: () => Effect.void, | |
| 75 | }), | |
| 63 | 76 | Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { |
| 64 | 77 | resolvePrimary: Effect.die("unexpected primary config resolve"), |
| 65 | 78 | resolvePrimaryLabel: Ref.get(labelRef), |
apps/desktop/src/backend/DesktopBackendPool.ts+3-1| ... | ... | @@ -97,6 +97,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 97 | 97 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 98 | 98 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 99 | 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 100 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | |
| 100 | 101 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 101 | 102 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 102 | 103 | |
| ... | ... | @@ -176,7 +177,8 @@ export type BackendInstanceFactoryRequirements = |
| 176 | 177 | | FileSystem.FileSystem |
| 177 | 178 | | ChildProcessSpawner.ChildProcessSpawner |
| 178 | 179 | | HttpClient.HttpClient |
| 179 | | DesktopObservability.DesktopBackendOutputLogFactory; | |
| 180 | | DesktopObservability.DesktopBackendOutputLogFactory | |
| 181 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher; | |
| 180 | 182 | |
| 181 | 183 | interface ActiveRegisteredInstance { |
| 182 | 184 | readonly _tag: "Active"; |
apps/desktop/src/electron/ElectronApp.ts+2| ... | ... | @@ -57,6 +57,7 @@ export class ElectronApp extends Context.Service< |
| 57 | 57 | ) => Effect.Effect<void>; |
| 58 | 58 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; |
| 59 | 59 | readonly requestSingleInstanceLock: Effect.Effect<boolean>; |
| 60 | readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>; | |
| 60 | 61 | readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>; |
| 61 | 62 | readonly setAsDefaultProtocolClient: ( |
| 62 | 63 | protocol: string, |
| ... | ... | @@ -153,6 +154,7 @@ export const make = ElectronApp.of({ |
| 153 | 154 | Electron.app.setAppUserModelId(id); |
| 154 | 155 | }), |
| 155 | 156 | requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), |
| 157 | getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), | |
| 156 | 158 | isDefaultProtocolClient: (protocol) => |
| 157 | 159 | Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), |
| 158 | 160 | setAsDefaultProtocolClient: (protocol, path, args) => |
apps/desktop/src/electron/ElectronPowerMonitor.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Scope from "effect/Scope"; | |
| 5 | ||
| 6 | import * as Electron from "electron"; | |
| 7 | ||
| 8 | export type ElectronThermalState = ReturnType<Electron.PowerMonitor["getCurrentThermalState"]>; | |
| 9 | export type ElectronIdleState = ReturnType<Electron.PowerMonitor["getSystemIdleState"]>; | |
| 10 | ||
| 11 | export class ElectronPowerMonitor extends Context.Service< | |
| 12 | ElectronPowerMonitor, | |
| 13 | { | |
| 14 | readonly isOnBatteryPower: Effect.Effect<boolean>; | |
| 15 | readonly getSystemIdleTime: Effect.Effect<number>; | |
| 16 | readonly getSystemIdleState: (idleThresholdSeconds: number) => Effect.Effect<ElectronIdleState>; | |
| 17 | readonly getCurrentThermalState: Effect.Effect<ElectronThermalState>; | |
| 18 | readonly onSimpleEvent: ( | |
| 19 | eventName: "lock-screen" | "unlock-screen" | "on-ac" | "on-battery" | "suspend" | "resume", | |
| 20 | listener: () => void, | |
| 21 | ) => Effect.Effect<void, never, Scope.Scope>; | |
| 22 | readonly onThermalStateChange: ( | |
| 23 | listener: (state: ElectronThermalState) => void, | |
| 24 | ) => Effect.Effect<void, never, Scope.Scope>; | |
| 25 | readonly onSpeedLimitChange: ( | |
| 26 | listener: (limit: number) => void, | |
| 27 | ) => Effect.Effect<void, never, Scope.Scope>; | |
| 28 | } | |
| 29 | >()("@t3tools/desktop/electron/ElectronPowerMonitor") {} | |
| 30 | ||
| 31 | const onSimpleEvent: ElectronPowerMonitor["Service"]["onSimpleEvent"] = (eventName, listener) => | |
| 32 | Effect.acquireRelease( | |
| 33 | Effect.sync(() => { | |
| 34 | Electron.powerMonitor.on(eventName as any, listener as any); | |
| 35 | }), | |
| 36 | () => | |
| 37 | Effect.sync(() => { | |
| 38 | Electron.powerMonitor.removeListener(eventName as any, listener as any); | |
| 39 | }), | |
| 40 | ).pipe(Effect.asVoid); | |
| 41 | ||
| 42 | const onThermalStateChange: ElectronPowerMonitor["Service"]["onThermalStateChange"] = ( | |
| 43 | listener, | |
| 44 | ) => { | |
| 45 | const wrapped = ( | |
| 46 | event: Electron.Event<Electron.PowerMonitorThermalStateChangeEventParams>, | |
| 47 | ): void => { | |
| 48 | listener(event.state); | |
| 49 | }; | |
| 50 | return Effect.acquireRelease( | |
| 51 | Effect.sync(() => { | |
| 52 | Electron.powerMonitor.on("thermal-state-change", wrapped); | |
| 53 | }), | |
| 54 | () => | |
| 55 | Effect.sync(() => { | |
| 56 | Electron.powerMonitor.removeListener("thermal-state-change", wrapped); | |
| 57 | }), | |
| 58 | ).pipe(Effect.asVoid); | |
| 59 | }; | |
| 60 | ||
| 61 | const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] = (listener) => { | |
| 62 | const wrapped = ( | |
| 63 | event: Electron.Event<Electron.PowerMonitorSpeedLimitChangeEventParams>, | |
| 64 | ): void => { | |
| 65 | listener(event.limit); | |
| 66 | }; | |
| 67 | return Effect.acquireRelease( | |
| 68 | Effect.sync(() => { | |
| 69 | Electron.powerMonitor.on("speed-limit-change", wrapped); | |
| 70 | }), | |
| 71 | () => | |
| 72 | Effect.sync(() => { | |
| 73 | Electron.powerMonitor.removeListener("speed-limit-change", wrapped); | |
| 74 | }), | |
| 75 | ).pipe(Effect.asVoid); | |
| 76 | }; | |
| 77 | ||
| 78 | export const make = ElectronPowerMonitor.of({ | |
| 79 | isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), | |
| 80 | getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), | |
| 81 | getSystemIdleState: (idleThresholdSeconds) => | |
| 82 | Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), | |
| 83 | getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), | |
| 84 | onSimpleEvent, | |
| 85 | onThermalStateChange, | |
| 86 | onSpeedLimitChange, | |
| 87 | }); | |
| 88 | ||
| 89 | export const layer = Layer.succeed(ElectronPowerMonitor, make); |
apps/desktop/src/main.ts+4| ... | ... | @@ -24,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; |
| 24 | 24 | import * as ElectronApp from "./electron/ElectronApp.ts"; |
| 25 | 25 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; |
| 26 | 26 | import * as ElectronMenu from "./electron/ElectronMenu.ts"; |
| 27 | import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; | |
| 27 | 28 | import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; |
| 28 | 29 | import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; |
| 29 | 30 | import * as ElectronShell from "./electron/ElectronShell.ts"; |
| ... | ... | @@ -52,6 +53,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; |
| 52 | 53 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 53 | 54 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 54 | 55 | import * as DesktopState from "./app/DesktopState.ts"; |
| 56 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; | |
| 55 | 57 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 56 | 58 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 57 | 59 | import * as PreviewManager from "./preview/Manager.ts"; |
| ... | ... | @@ -113,6 +115,7 @@ const electronLayer = Layer.mergeAll( |
| 113 | 115 | ElectronApp.layer, |
| 114 | 116 | ElectronDialog.layer, |
| 115 | 117 | ElectronMenu.layer, |
| 118 | ElectronPowerMonitor.layer, | |
| 116 | 119 | ElectronProtocol.layer, |
| 117 | 120 | ElectronSafeStorage.layer, |
| 118 | 121 | ElectronShell.layer, |
| ... | ... | @@ -160,6 +163,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( |
| 160 | 163 | Layer.provideMerge(DesktopAppIdentity.layer), |
| 161 | 164 | Layer.provideMerge(DesktopBackendConfiguration.layer), |
| 162 | 165 | Layer.provideMerge(DesktopWslEnvironment.layer), |
| 166 | Layer.provideMerge(DesktopTelemetryPublisher.layer), | |
| 163 | 167 | Layer.provideMerge(desktopWindowLayer), |
| 164 | 168 | ); |
| 165 | 169 |
apps/desktop/src/preview/Manager.test.ts+86| ... | ... | @@ -982,6 +982,92 @@ describe("PreviewManager", () => { |
| 982 | 982 | ), |
| 983 | 983 | ); |
| 984 | 984 | |
| 985 | effectIt.effect("emits debugger screencast frames only while recording is active", () => | |
| 986 | withManager((manager) => | |
| 987 | Effect.gen(function* () { | |
| 988 | let debuggerMessage: | |
| 989 | | ((event: unknown, method: string, params: Record<string, unknown>) => void) | |
| 990 | | undefined; | |
| 991 | const capturePage = vi.fn(async () => ({ | |
| 992 | toJPEG: () => Buffer.from("scheduled-recording-frame"), | |
| 993 | getSize: () => ({ width: 1280, height: 720 }), | |
| 994 | })); | |
| 995 | const sendCommand = vi.fn(async (method: string) => | |
| 996 | method === "Runtime.evaluate" ? { result: { value: null } } : undefined, | |
| 997 | ); | |
| 998 | fromId.mockReturnValue({ | |
| 999 | id: 42, | |
| 1000 | isDestroyed: () => false, | |
| 1001 | getType: () => "webview", | |
| 1002 | getURL: () => "https://example.com", | |
| 1003 | getTitle: () => "Example", | |
| 1004 | isLoading: () => false, | |
| 1005 | isDevToolsOpened: () => false, | |
| 1006 | getZoomFactor: () => 1, | |
| 1007 | setZoomFactor: vi.fn(), | |
| 1008 | on: vi.fn(), | |
| 1009 | off: vi.fn(), | |
| 1010 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 1011 | send: webviewSend, | |
| 1012 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 1013 | setWindowOpenHandler: vi.fn(), | |
| 1014 | debugger: { | |
| 1015 | isAttached: () => false, | |
| 1016 | attach: vi.fn(), | |
| 1017 | sendCommand, | |
| 1018 | on: vi.fn( | |
| 1019 | ( | |
| 1020 | event: string, | |
| 1021 | listener: (event: unknown, method: string, params: Record<string, unknown>) => void, | |
| 1022 | ) => { | |
| 1023 | if (event === "message") debuggerMessage = listener; | |
| 1024 | }, | |
| 1025 | ), | |
| 1026 | off: vi.fn(), | |
| 1027 | }, | |
| 1028 | capturePage, | |
| 1029 | } as never); | |
| 1030 | const recordingFrames: DesktopPreviewRecordingFrame[] = []; | |
| 1031 | ||
| 1032 | yield* manager.subscribeRecordingFrames((frame) => | |
| 1033 | Effect.sync(() => { | |
| 1034 | recordingFrames.push(frame); | |
| 1035 | }), | |
| 1036 | ); | |
| 1037 | yield* manager.createTab("tab_screencast_guard"); | |
| 1038 | yield* manager.registerWebview("tab_screencast_guard", 42); | |
| 1039 | yield* manager.automationEvaluate("tab_screencast_guard", { expression: "null" }); | |
| 1040 | ||
| 1041 | debuggerMessage?.({}, "Page.screencastFrame", { | |
| 1042 | sessionId: 1, | |
| 1043 | data: "inactive-frame", | |
| 1044 | metadata: { deviceWidth: 1280, deviceHeight: 720 }, | |
| 1045 | }); | |
| 1046 | yield* Effect.yieldNow; | |
| 1047 | expect(recordingFrames).toHaveLength(0); | |
| 1048 | ||
| 1049 | yield* manager.startRecording("tab_screencast_guard"); | |
| 1050 | recordingFrames.length = 0; | |
| 1051 | debuggerMessage?.({}, "Page.screencastFrame", { | |
| 1052 | sessionId: 2, | |
| 1053 | data: "active-frame", | |
| 1054 | metadata: { deviceWidth: 1280, deviceHeight: 720 }, | |
| 1055 | }); | |
| 1056 | yield* Effect.yieldNow; | |
| 1057 | ||
| 1058 | expect(recordingFrames).toEqual([ | |
| 1059 | expect.objectContaining({ | |
| 1060 | tabId: "tab_screencast_guard", | |
| 1061 | data: "active-frame", | |
| 1062 | width: 1280, | |
| 1063 | height: 720, | |
| 1064 | }), | |
| 1065 | ]); | |
| 1066 | yield* manager.stopRecording("tab_screencast_guard"); | |
| 1067 | }), | |
| 1068 | ), | |
| 1069 | ); | |
| 1070 | ||
| 985 | 1071 | effectIt.effect("shares background frame capture between recording and picture-in-picture", () => |
| 986 | 1072 | withManager((manager) => |
| 987 | 1073 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+55-6| ... | ... | @@ -671,10 +671,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 671 | 671 | ), |
| 672 | 672 | ); |
| 673 | 673 | |
| 674 | const tabIdForWebContents = Effect.fnUntraced(function* (webContentsId: number) { | |
| 675 | const tabs = yield* SynchronizedRef.get(tabsRef); | |
| 676 | return ( | |
| 677 | Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null | |
| 678 | ); | |
| 679 | }); | |
| 680 | ||
| 674 | 681 | const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => |
| 675 | 682 | [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); |
| 676 | 683 | |
| 677 | const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* ( | |
| 684 | const captureDiagnosticMessage = Effect.fnUntraced(function* ( | |
| 678 | 685 | webContentsId: number, |
| 679 | 686 | method: string, |
| 680 | 687 | params: Record<string, unknown>, |
| ... | ... | @@ -852,11 +859,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 852 | 859 | const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { |
| 853 | 860 | const semaphore = yield* Semaphore.make(1); |
| 854 | 861 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 855 | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( | |
| 856 | function* (method: string, params: Record<string, unknown>) { | |
| 857 | yield* captureDiagnosticMessage(wc.id, method, params); | |
| 858 | }, | |
| 859 | ); | |
| 862 | const handleDebuggerMessage = Effect.fnUntraced(function* ( | |
| 863 | method: string, | |
| 864 | params: Record<string, unknown>, | |
| 865 | ) { | |
| 866 | if (method === "Page.screencastFrame") { | |
| 867 | const sessionId = params["sessionId"]; | |
| 868 | if (typeof sessionId === "number") { | |
| 869 | yield* attemptPromise( | |
| 870 | { | |
| 871 | operation: "ackScreencastFrame", | |
| 872 | webContentsId: wc.id, | |
| 873 | }, | |
| 874 | () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), | |
| 875 | ).pipe(Effect.ignore); | |
| 876 | } | |
| 877 | const tabId = yield* tabIdForWebContents(wc.id); | |
| 878 | const metadata = | |
| 879 | typeof params["metadata"] === "object" && params["metadata"] !== null | |
| 880 | ? (params["metadata"] as Record<string, unknown>) | |
| 881 | : {}; | |
| 882 | if (tabId && typeof params["data"] === "string") { | |
| 883 | const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get( | |
| 884 | tabId, | |
| 885 | ); | |
| 886 | if (captureSession?.consumers.has("recording")) { | |
| 887 | const receivedAt = yield* currentIso; | |
| 888 | const listeners = yield* Ref.get(recordingFrameListenersRef); | |
| 889 | const frame: DesktopPreviewRecordingFrame = { | |
| 890 | tabId, | |
| 891 | data: params["data"], | |
| 892 | width: | |
| 893 | typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, | |
| 894 | height: | |
| 895 | typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, | |
| 896 | receivedAt, | |
| 897 | }; | |
| 898 | yield* Effect.forEach( | |
| 899 | listeners, | |
| 900 | (listener) => | |
| 901 | deliverEvent("recording-frame", frame.tabId, () => listener(frame)), | |
| 902 | { discard: true }, | |
| 903 | ); | |
| 904 | } | |
| 905 | } | |
| 906 | } | |
| 907 | yield* captureDiagnosticMessage(wc.id, method, params); | |
| 908 | }); | |
| 860 | 909 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { |
| 861 | 910 | runFork(handleDebuggerMessage(method, params)); |
| 862 | 911 | }; |
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts created+388| ... | ... | @@ -0,0 +1,388 @@ |
| 1 | import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Exit from "effect/Exit"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as Ref from "effect/Ref"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as Scope from "effect/Scope"; | |
| 13 | import * as Stream from "effect/Stream"; | |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 15 | ||
| 16 | import type * as Electron from "electron"; | |
| 17 | ||
| 18 | import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 19 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; | |
| 20 | import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; | |
| 21 | ||
| 22 | function makeElectronAppLayer( | |
| 23 | metrics: ReadonlyArray<Electron.ProcessMetric>, | |
| 24 | onMetricsRead: () => void = () => undefined, | |
| 25 | ) { | |
| 26 | return Layer.succeed(ElectronApp.ElectronApp, { | |
| 27 | metadata: Effect.die("unexpected metadata read"), | |
| 28 | name: Effect.succeed("T3 Code"), | |
| 29 | whenReady: Effect.void, | |
| 30 | quit: Effect.void, | |
| 31 | exit: () => Effect.void, | |
| 32 | relaunch: () => Effect.void, | |
| 33 | setPath: () => Effect.void, | |
| 34 | setName: () => Effect.void, | |
| 35 | setAboutPanelOptions: () => Effect.void, | |
| 36 | setAppUserModelId: () => Effect.void, | |
| 37 | requestSingleInstanceLock: Effect.succeed(true), | |
| 38 | getAppMetrics: Effect.sync(() => { | |
| 39 | onMetricsRead(); | |
| 40 | return metrics; | |
| 41 | }), | |
| 42 | isDefaultProtocolClient: () => Effect.succeed(false), | |
| 43 | setAsDefaultProtocolClient: () => Effect.succeed(true), | |
| 44 | setDesktopName: () => Effect.void, | |
| 45 | setDockIcon: () => Effect.void, | |
| 46 | appendCommandLineSwitch: () => Effect.void, | |
| 47 | onBeforeQuitForUpdate: () => Effect.void, | |
| 48 | on: () => Effect.void, | |
| 49 | } satisfies ElectronApp.ElectronApp["Service"]); | |
| 50 | } | |
| 51 | ||
| 52 | describe("DesktopTelemetryPublisher", () => { | |
| 53 | it.effect("stops when its scope closes during an Electron telemetry sample", () => | |
| 54 | Effect.gen(function* () { | |
| 55 | const pollStarted = yield* Deferred.make<void>(); | |
| 56 | const blockPoll = yield* Deferred.make<void>(); | |
| 57 | const powerLayer = Layer.succeed( | |
| 58 | ElectronPowerMonitor.ElectronPowerMonitor, | |
| 59 | ElectronPowerMonitor.ElectronPowerMonitor.of({ | |
| 60 | isOnBatteryPower: Effect.succeed(false), | |
| 61 | getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe( | |
| 62 | Effect.andThen(Deferred.await(blockPoll)), | |
| 63 | Effect.as(0), | |
| 64 | ), | |
| 65 | getSystemIdleState: () => Effect.succeed("active"), | |
| 66 | getCurrentThermalState: Effect.succeed("nominal"), | |
| 67 | onSimpleEvent: () => Effect.void, | |
| 68 | onThermalStateChange: () => Effect.void, | |
| 69 | onSpeedLimitChange: () => Effect.void, | |
| 70 | }), | |
| 71 | ); | |
| 72 | const layer = DesktopTelemetryPublisher.layer.pipe( | |
| 73 | Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), | |
| 74 | ); | |
| 75 | const scope = yield* Scope.make(); | |
| 76 | ||
| 77 | yield* Layer.buildWithScope(layer, scope); | |
| 78 | yield* Deferred.await(pollStarted); | |
| 79 | ||
| 80 | const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach); | |
| 81 | yield* Effect.yieldNow; | |
| 82 | yield* Effect.yieldNow; | |
| 83 | ||
| 84 | assert.isDefined(closeFiber.pollUnsafe()); | |
| 85 | }), | |
| 86 | ); | |
| 87 | ||
| 88 | it.effect("publishes Electron metrics and event-driven power state over NDJSON", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const onBattery = yield* Ref.make(false); | |
| 91 | const systemIdleState = yield* Ref.make<ElectronPowerMonitor.ElectronIdleState>("active"); | |
| 92 | let beforeSystemIdleState: Effect.Effect<void> = Effect.void; | |
| 93 | let metricsReadCount = 0; | |
| 94 | const simpleListeners = new Map<string, () => void>(); | |
| 95 | let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null = | |
| 96 | null; | |
| 97 | let speedLimitListener: ((limit: number) => void) | null = null; | |
| 98 | const metrics = [ | |
| 99 | { | |
| 100 | pid: 4_242, | |
| 101 | type: "Browser", | |
| 102 | creationTime: 1_000.75, | |
| 103 | name: "electron", | |
| 104 | cpu: { | |
| 105 | percentCPUUsage: 12.5, | |
| 106 | cumulativeCPUUsage: 3.25, | |
| 107 | idleWakeupsPerSecond: 7, | |
| 108 | }, | |
| 109 | memory: { | |
| 110 | workingSetSize: 2_048, | |
| 111 | peakWorkingSetSize: 4_096, | |
| 112 | }, | |
| 113 | } as Electron.ProcessMetric, | |
| 114 | ]; | |
| 115 | const powerLayer = Layer.succeed( | |
| 116 | ElectronPowerMonitor.ElectronPowerMonitor, | |
| 117 | ElectronPowerMonitor.ElectronPowerMonitor.of({ | |
| 118 | isOnBatteryPower: Ref.get(onBattery), | |
| 119 | getSystemIdleTime: Effect.succeed(5), | |
| 120 | getSystemIdleState: () => | |
| 121 | beforeSystemIdleState.pipe(Effect.andThen(Ref.get(systemIdleState))), | |
| 122 | getCurrentThermalState: Effect.succeed("nominal"), | |
| 123 | onSimpleEvent: (eventName, listener) => | |
| 124 | Effect.sync(() => { | |
| 125 | simpleListeners.set(eventName, listener); | |
| 126 | }), | |
| 127 | onThermalStateChange: (listener) => | |
| 128 | Effect.sync(() => { | |
| 129 | thermalListener = listener; | |
| 130 | }), | |
| 131 | onSpeedLimitChange: (listener) => | |
| 132 | Effect.sync(() => { | |
| 133 | speedLimitListener = listener; | |
| 134 | }), | |
| 135 | }), | |
| 136 | ); | |
| 137 | const layer = DesktopTelemetryPublisher.layer.pipe( | |
| 138 | Layer.provide( | |
| 139 | Layer.mergeAll( | |
| 140 | makeElectronAppLayer(metrics, () => { | |
| 141 | metricsReadCount += 1; | |
| 142 | }), | |
| 143 | powerLayer, | |
| 144 | ), | |
| 145 | ), | |
| 146 | ); | |
| 147 | ||
| 148 | yield* Effect.gen(function* () { | |
| 149 | const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; | |
| 150 | const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); | |
| 151 | const decoder = new TextDecoder(); | |
| 152 | const decodeMessage = Schema.decodeUnknownEffect( | |
| 153 | Schema.fromJsonString(DesktopHostTelemetryMessage), | |
| 154 | ); | |
| 155 | const messages = yield* Effect.forEach(encoded, (bytes) => | |
| 156 | decodeMessage(decoder.decode(bytes).trim()), | |
| 157 | ); | |
| 158 | ||
| 159 | assert.equal(messages[0]?.type, "desktopTelemetryHello"); | |
| 160 | assert.equal(messages[0]?.electronPid, process.pid); | |
| 161 | const initialSnapshot = messages[1]; | |
| 162 | if (initialSnapshot?.type !== "desktopTelemetry") { | |
| 163 | return assert.fail("Expected the second telemetry message to be a snapshot."); | |
| 164 | } | |
| 165 | assert.deepEqual(initialSnapshot.electronProcesses, []); | |
| 166 | assert.equal(initialSnapshot.electronPid, process.pid); | |
| 167 | assert.equal(metricsReadCount, 0); | |
| 168 | ||
| 169 | const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); | |
| 170 | yield* Effect.yieldNow; | |
| 171 | yield* publisher.handleControl({ | |
| 172 | version: 1, | |
| 173 | type: "setDiagnosticsDemand", | |
| 174 | enabled: true, | |
| 175 | }); | |
| 176 | const demandedSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber)); | |
| 177 | assert.equal(demandedSnapshot.electronProcesses[0]?.pid, 4_242); | |
| 178 | assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001); | |
| 179 | assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5); | |
| 180 | assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024); | |
| 181 | assert.equal(metricsReadCount, 1); | |
| 182 | yield* publisher.handleControlForSource("secondary-backend", { | |
| 183 | version: 1, | |
| 184 | type: "setDiagnosticsDemand", | |
| 185 | enabled: true, | |
| 186 | }); | |
| 187 | yield* publisher.handleControl({ | |
| 188 | version: 1, | |
| 189 | type: "setDiagnosticsDemand", | |
| 190 | enabled: false, | |
| 191 | }); | |
| 192 | yield* publisher.handleControlForSource("old-backend", { | |
| 193 | version: 1, | |
| 194 | type: "setDiagnosticsDemand", | |
| 195 | enabled: true, | |
| 196 | }); | |
| 197 | yield* publisher.handleControlForSource("secondary-backend", { | |
| 198 | version: 1, | |
| 199 | type: "setDiagnosticsDemand", | |
| 200 | enabled: false, | |
| 201 | }); | |
| 202 | yield* Effect.all( | |
| 203 | [ | |
| 204 | publisher.removeControlSource("old-backend"), | |
| 205 | publisher.handleControlForSource("replacement-backend", { | |
| 206 | version: 1, | |
| 207 | type: "setDiagnosticsDemand", | |
| 208 | enabled: true, | |
| 209 | }), | |
| 210 | ], | |
| 211 | { concurrency: "unbounded", discard: true }, | |
| 212 | ); | |
| 213 | ||
| 214 | const batterySnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 215 | Effect.forkChild, | |
| 216 | ); | |
| 217 | yield* Effect.yieldNow; | |
| 218 | simpleListeners.get("on-battery")?.(); | |
| 219 | const batterySnapshot = Option.getOrThrow(yield* Fiber.join(batterySnapshotFiber)); | |
| 220 | assert.equal(batterySnapshot.power.onBattery, "true"); | |
| 221 | yield* Ref.set(onBattery, true); | |
| 222 | ||
| 223 | const metricsAfterBatteryEvent = metricsReadCount; | |
| 224 | yield* TestClock.adjust(Duration.millis(4_999)); | |
| 225 | assert.equal(metricsReadCount, metricsAfterBatteryEvent); | |
| 226 | yield* TestClock.adjust(Duration.millis(1)); | |
| 227 | assert.equal(metricsReadCount, metricsAfterBatteryEvent + 1); | |
| 228 | assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "true"); | |
| 229 | ||
| 230 | yield* Ref.set(onBattery, false); | |
| 231 | const metricsBeforePolledAc = metricsReadCount; | |
| 232 | yield* TestClock.adjust(Duration.seconds(5)); | |
| 233 | assert.equal(metricsReadCount, metricsBeforePolledAc + 1); | |
| 234 | assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "false"); | |
| 235 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 236 | assert.equal(metricsReadCount, metricsBeforePolledAc + 2); | |
| 237 | ||
| 238 | const suspendedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 239 | Effect.forkChild, | |
| 240 | ); | |
| 241 | yield* Effect.yieldNow; | |
| 242 | simpleListeners.get("suspend")?.(); | |
| 243 | const suspendedSnapshot = Option.getOrThrow(yield* Fiber.join(suspendedSnapshotFiber)); | |
| 244 | assert.isTrue(suspendedSnapshot.power.suspended); | |
| 245 | ||
| 246 | const constrainedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 247 | Effect.forkChild, | |
| 248 | ); | |
| 249 | yield* Effect.yieldNow; | |
| 250 | thermalListener?.("serious"); | |
| 251 | const constrainedSnapshot = Option.getOrThrow(yield* Fiber.join(constrainedSnapshotFiber)); | |
| 252 | assert.equal(constrainedSnapshot.power.thermalState, "serious"); | |
| 253 | assert.isTrue(constrainedSnapshot.power.suspended); | |
| 254 | ||
| 255 | const metricsAfterThermalEvent = metricsReadCount; | |
| 256 | const recoveredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 257 | Effect.forkChild, | |
| 258 | ); | |
| 259 | yield* TestClock.adjust(Duration.millis(14_999)); | |
| 260 | assert.equal(metricsReadCount, metricsAfterThermalEvent); | |
| 261 | yield* TestClock.adjust(Duration.millis(1)); | |
| 262 | assert.equal(metricsReadCount, metricsAfterThermalEvent + 1); | |
| 263 | const recoveredSnapshot = Option.getOrThrow(yield* Fiber.join(recoveredSnapshotFiber)); | |
| 264 | assert.isFalse(recoveredSnapshot.power.suspended); | |
| 265 | ||
| 266 | const speedLimitSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 267 | Effect.forkChild, | |
| 268 | ); | |
| 269 | yield* Effect.yieldNow; | |
| 270 | speedLimitListener?.(65); | |
| 271 | const speedLimitSnapshot = Option.getOrThrow(yield* Fiber.join(speedLimitSnapshotFiber)); | |
| 272 | assert.equal(Option.getOrNull(speedLimitSnapshot.speedLimitPercent), 65); | |
| 273 | ||
| 274 | const encodedSpeedLimit = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); | |
| 275 | const decodedSpeedLimit = yield* decodeMessage(decoder.decode(encodedSpeedLimit[1]).trim()); | |
| 276 | if (decodedSpeedLimit.type !== "desktopTelemetry") { | |
| 277 | return assert.fail("Expected the encoded telemetry message to be a snapshot."); | |
| 278 | } | |
| 279 | assert.equal(Option.getOrNull(decodedSpeedLimit.speedLimitPercent), 65); | |
| 280 | assert.equal(decodedSpeedLimit.electronProcesses[0]?.pid, 4_242); | |
| 281 | assert.equal(decodedSpeedLimit.electronProcesses[0]?.creationTimeMs, 1_001); | |
| 282 | ||
| 283 | const metricsBeforeSecondaryOnlySample = metricsReadCount; | |
| 284 | yield* TestClock.adjust(Duration.seconds(15)); | |
| 285 | assert.equal(metricsReadCount, metricsBeforeSecondaryOnlySample + 1); | |
| 286 | assert.equal( | |
| 287 | (yield* publisher.latest).pipe(Option.getOrThrow).electronProcesses[0]?.pid, | |
| 288 | 4_242, | |
| 289 | ); | |
| 290 | ||
| 291 | const stoppedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 292 | Effect.forkChild, | |
| 293 | ); | |
| 294 | yield* Effect.yieldNow; | |
| 295 | yield* publisher.handleControlForSource("replacement-backend", { | |
| 296 | version: 1, | |
| 297 | type: "setDiagnosticsDemand", | |
| 298 | enabled: false, | |
| 299 | }); | |
| 300 | const stoppedSnapshot = Option.getOrThrow(yield* Fiber.join(stoppedSnapshotFiber)); | |
| 301 | assert.deepEqual(stoppedSnapshot.electronProcesses, []); | |
| 302 | const metricsAfterStopping = metricsReadCount; | |
| 303 | const configuredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 304 | Effect.forkChild, | |
| 305 | ); | |
| 306 | yield* Effect.yieldNow; | |
| 307 | yield* publisher.handleControl({ | |
| 308 | version: 1, | |
| 309 | type: "setHostPowerIntervals", | |
| 310 | activeIntervalMs: 7_000, | |
| 311 | idleIntervalMs: 11_000, | |
| 312 | }); | |
| 313 | const configuredSequence = Option.getOrThrow( | |
| 314 | yield* Fiber.join(configuredSnapshotFiber), | |
| 315 | ).sequence; | |
| 316 | ||
| 317 | yield* TestClock.adjust(Duration.millis(6_999)); | |
| 318 | assert.equal( | |
| 319 | (yield* publisher.latest).pipe(Option.getOrThrow).sequence, | |
| 320 | configuredSequence, | |
| 321 | ); | |
| 322 | assert.equal(metricsReadCount, metricsAfterStopping); | |
| 323 | yield* TestClock.adjust(Duration.millis(1)); | |
| 324 | assert.equal( | |
| 325 | (yield* publisher.latest).pipe(Option.getOrThrow).sequence, | |
| 326 | configuredSequence + 1, | |
| 327 | ); | |
| 328 | assert.equal(metricsReadCount, metricsAfterStopping); | |
| 329 | ||
| 330 | yield* Ref.set(systemIdleState, "locked"); | |
| 331 | yield* TestClock.adjust(Duration.seconds(7)); | |
| 332 | const lockedSequence = (yield* publisher.latest).pipe(Option.getOrThrow).sequence; | |
| 333 | assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.locked, "true"); | |
| 334 | yield* TestClock.adjust(Duration.millis(10_999)); | |
| 335 | assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).sequence, lockedSequence); | |
| 336 | yield* TestClock.adjust(Duration.millis(1)); | |
| 337 | assert.equal( | |
| 338 | (yield* publisher.latest).pipe(Option.getOrThrow).sequence, | |
| 339 | lockedSequence + 1, | |
| 340 | ); | |
| 341 | ||
| 342 | yield* Ref.set(systemIdleState, "active"); | |
| 343 | yield* TestClock.adjust(Duration.seconds(11)); | |
| 344 | const unlockedSnapshot = (yield* publisher.latest).pipe(Option.getOrThrow); | |
| 345 | assert.equal(unlockedSnapshot.power.locked, "false"); | |
| 346 | assert.equal(unlockedSnapshot.power.idle, "false"); | |
| 347 | ||
| 348 | yield* TestClock.adjust(Duration.millis(6_999)); | |
| 349 | assert.equal( | |
| 350 | (yield* publisher.latest).pipe(Option.getOrThrow).sequence, | |
| 351 | unlockedSnapshot.sequence, | |
| 352 | ); | |
| 353 | yield* TestClock.adjust(Duration.millis(1)); | |
| 354 | assert.equal( | |
| 355 | (yield* publisher.latest).pipe(Option.getOrThrow).sequence, | |
| 356 | unlockedSnapshot.sequence + 1, | |
| 357 | ); | |
| 358 | ||
| 359 | const pollStarted = yield* Deferred.make<void>(); | |
| 360 | const releasePoll = yield* Deferred.make<void>(); | |
| 361 | beforeSystemIdleState = Deferred.succeed(pollStarted, undefined).pipe( | |
| 362 | Effect.andThen(Deferred.await(releasePoll)), | |
| 363 | ); | |
| 364 | const concurrentEventSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( | |
| 365 | Effect.forkChild, | |
| 366 | ); | |
| 367 | yield* Effect.yieldNow; | |
| 368 | yield* publisher.handleControl({ | |
| 369 | version: 1, | |
| 370 | type: "setDiagnosticsDemand", | |
| 371 | enabled: true, | |
| 372 | }); | |
| 373 | yield* Deferred.await(pollStarted); | |
| 374 | simpleListeners.get("lock-screen")?.(); | |
| 375 | thermalListener?.("critical"); | |
| 376 | yield* Effect.yieldNow; | |
| 377 | yield* Effect.yieldNow; | |
| 378 | yield* Deferred.succeed(releasePoll, undefined); | |
| 379 | ||
| 380 | const concurrentEventSnapshot = Option.getOrThrow( | |
| 381 | yield* Fiber.join(concurrentEventSnapshotFiber), | |
| 382 | ); | |
| 383 | assert.equal(concurrentEventSnapshot.power.locked, "true"); | |
| 384 | assert.equal(concurrentEventSnapshot.power.thermalState, "critical"); | |
| 385 | }).pipe(Effect.provide(layer)); | |
| 386 | }), | |
| 387 | ); | |
| 388 | }); |
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts created+379| ... | ... | @@ -0,0 +1,379 @@ |
| 1 | import { | |
| 2 | DesktopHostTelemetryMessage, | |
| 3 | type DesktopHostTelemetrySnapshot, | |
| 4 | type DesktopTelemetryControlMessage, | |
| 5 | type HostPowerSnapshot, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Cause from "effect/Cause"; | |
| 8 | import * as Context from "effect/Context"; | |
| 9 | import * as DateTime from "effect/DateTime"; | |
| 10 | import * as Duration from "effect/Duration"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as PubSub from "effect/PubSub"; | |
| 15 | import * as Queue from "effect/Queue"; | |
| 16 | import * as Ref from "effect/Ref"; | |
| 17 | import * as Schema from "effect/Schema"; | |
| 18 | import * as Stream from "effect/Stream"; | |
| 19 | ||
| 20 | import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 21 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; | |
| 22 | ||
| 23 | const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); | |
| 24 | const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); | |
| 25 | const CONSTRAINED_SAMPLE_INTERVAL = Duration.seconds(15); | |
| 26 | const DEFAULT_HOST_POWER_ACTIVE_INTERVAL = Duration.seconds(30); | |
| 27 | const DEFAULT_HOST_POWER_IDLE_INTERVAL = Duration.minutes(2); | |
| 28 | const IDLE_THRESHOLD_SECONDS = 60; | |
| 29 | const encodeMessage = Schema.encodeSync(Schema.fromJsonString(DesktopHostTelemetryMessage)); | |
| 30 | const textEncoder = new TextEncoder(); | |
| 31 | ||
| 32 | type PowerEvent = | |
| 33 | | { readonly type: "locked"; readonly value: boolean } | |
| 34 | | { readonly type: "suspended"; readonly value: boolean } | |
| 35 | | { readonly type: "onBattery"; readonly value: boolean } | |
| 36 | | { readonly type: "thermal"; readonly value: HostPowerSnapshot["thermalState"] } | |
| 37 | | { readonly type: "speedLimit"; readonly value: number }; | |
| 38 | ||
| 39 | interface PowerState { | |
| 40 | readonly idle: HostPowerSnapshot["idle"]; | |
| 41 | readonly locked: HostPowerSnapshot["locked"]; | |
| 42 | readonly lockedEventPending: boolean; | |
| 43 | readonly suspended: boolean; | |
| 44 | readonly suspendedEventPending: boolean; | |
| 45 | readonly onBattery: HostPowerSnapshot["onBattery"]; | |
| 46 | readonly onBatteryEventPending: boolean; | |
| 47 | readonly thermalState: HostPowerSnapshot["thermalState"]; | |
| 48 | readonly speedLimitPercent: Option.Option<number>; | |
| 49 | } | |
| 50 | ||
| 51 | interface HostPowerIntervals { | |
| 52 | readonly active: Duration.Duration; | |
| 53 | readonly idle: Duration.Duration; | |
| 54 | } | |
| 55 | ||
| 56 | export class DesktopTelemetryPublisher extends Context.Service< | |
| 57 | DesktopTelemetryPublisher, | |
| 58 | { | |
| 59 | readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>; | |
| 60 | readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>; | |
| 61 | readonly encoded: Stream.Stream<Uint8Array>; | |
| 62 | readonly handleControl: (message: DesktopTelemetryControlMessage) => Effect.Effect<void>; | |
| 63 | readonly handleControlForSource: ( | |
| 64 | sourceId: string, | |
| 65 | message: DesktopTelemetryControlMessage, | |
| 66 | ) => Effect.Effect<void>; | |
| 67 | readonly removeControlSource: (sourceId: string) => Effect.Effect<void>; | |
| 68 | } | |
| 69 | >()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {} | |
| 70 | ||
| 71 | function booleanState(value: boolean): HostPowerSnapshot["onBattery"] { | |
| 72 | return value ? "true" : "false"; | |
| 73 | } | |
| 74 | ||
| 75 | function idleState(value: ElectronPowerMonitor.ElectronIdleState): HostPowerSnapshot["idle"] { | |
| 76 | switch (value) { | |
| 77 | case "active": | |
| 78 | return "false"; | |
| 79 | case "idle": | |
| 80 | case "locked": | |
| 81 | return "true"; | |
| 82 | case "unknown": | |
| 83 | return "unknown"; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | function updatePowerState(state: PowerState, event: PowerEvent): PowerState { | |
| 88 | switch (event.type) { | |
| 89 | case "locked": | |
| 90 | return { | |
| 91 | ...state, | |
| 92 | locked: booleanState(event.value), | |
| 93 | lockedEventPending: true, | |
| 94 | }; | |
| 95 | case "suspended": | |
| 96 | return { | |
| 97 | ...state, | |
| 98 | suspended: event.value, | |
| 99 | suspendedEventPending: event.value, | |
| 100 | }; | |
| 101 | case "onBattery": | |
| 102 | return { | |
| 103 | ...state, | |
| 104 | onBattery: booleanState(event.value), | |
| 105 | onBatteryEventPending: true, | |
| 106 | }; | |
| 107 | case "thermal": | |
| 108 | return { ...state, thermalState: event.value }; | |
| 109 | case "speedLimit": | |
| 110 | return { ...state, speedLimitPercent: Option.some(event.value) }; | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | function sampleInterval( | |
| 115 | power: PowerState, | |
| 116 | diagnosticsDemand: boolean, | |
| 117 | hostPowerIntervals: HostPowerIntervals, | |
| 118 | ): Duration.Duration { | |
| 119 | if (!diagnosticsDemand) { | |
| 120 | return power.suspended || power.locked === "true" || power.idle === "true" | |
| 121 | ? hostPowerIntervals.idle | |
| 122 | : hostPowerIntervals.active; | |
| 123 | } | |
| 124 | if ( | |
| 125 | power.suspended || | |
| 126 | power.locked === "true" || | |
| 127 | power.thermalState === "serious" || | |
| 128 | power.thermalState === "critical" | |
| 129 | ) { | |
| 130 | return CONSTRAINED_SAMPLE_INTERVAL; | |
| 131 | } | |
| 132 | if (power.onBattery === "true") return BATTERY_SAMPLE_INTERVAL; | |
| 133 | return LIVE_SAMPLE_INTERVAL; | |
| 134 | } | |
| 135 | ||
| 136 | export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { | |
| 137 | const electronApp = yield* ElectronApp.ElectronApp; | |
| 138 | const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; | |
| 139 | yield* electronApp.whenReady; | |
| 140 | ||
| 141 | const initialPowerState: PowerState = { | |
| 142 | idle: "unknown", | |
| 143 | locked: "unknown", | |
| 144 | lockedEventPending: false, | |
| 145 | suspended: false, | |
| 146 | suspendedEventPending: false, | |
| 147 | onBattery: booleanState(yield* powerMonitor.isOnBatteryPower), | |
| 148 | onBatteryEventPending: false, | |
| 149 | thermalState: yield* powerMonitor.getCurrentThermalState, | |
| 150 | speedLimitPercent: Option.none(), | |
| 151 | }; | |
| 152 | const powerState = yield* Ref.make(initialPowerState); | |
| 153 | const hostPowerIntervals = yield* Ref.make<HostPowerIntervals>({ | |
| 154 | active: DEFAULT_HOST_POWER_ACTIVE_INTERVAL, | |
| 155 | idle: DEFAULT_HOST_POWER_IDLE_INTERVAL, | |
| 156 | }); | |
| 157 | const powerEvents = yield* Queue.unbounded<PowerEvent>(); | |
| 158 | const sampleTriggers = yield* Queue.sliding<void>(1); | |
| 159 | const diagnosticsDemandSources = yield* Ref.make<ReadonlySet<string>>(new Set()); | |
| 160 | const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>()); | |
| 161 | const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8); | |
| 162 | const sequence = yield* Ref.make(0); | |
| 163 | ||
| 164 | const offer = (event: PowerEvent): void => { | |
| 165 | Queue.offerUnsafe(powerEvents, event); | |
| 166 | }; | |
| 167 | yield* Effect.all( | |
| 168 | [ | |
| 169 | powerMonitor.onSimpleEvent("lock-screen", () => offer({ type: "locked", value: true })), | |
| 170 | powerMonitor.onSimpleEvent("unlock-screen", () => offer({ type: "locked", value: false })), | |
| 171 | powerMonitor.onSimpleEvent("suspend", () => offer({ type: "suspended", value: true })), | |
| 172 | powerMonitor.onSimpleEvent("resume", () => offer({ type: "suspended", value: false })), | |
| 173 | powerMonitor.onSimpleEvent("on-battery", () => offer({ type: "onBattery", value: true })), | |
| 174 | powerMonitor.onSimpleEvent("on-ac", () => offer({ type: "onBattery", value: false })), | |
| 175 | powerMonitor.onThermalStateChange((value) => offer({ type: "thermal", value })), | |
| 176 | powerMonitor.onSpeedLimitChange((value) => offer({ type: "speedLimit", value })), | |
| 177 | ], | |
| 178 | { concurrency: "unbounded" }, | |
| 179 | ); | |
| 180 | yield* Effect.forever( | |
| 181 | Queue.take(powerEvents).pipe( | |
| 182 | Effect.flatMap((event) => Ref.update(powerState, (state) => updatePowerState(state, event))), | |
| 183 | Effect.andThen(Queue.offer(sampleTriggers, undefined)), | |
| 184 | ), | |
| 185 | ).pipe(Effect.forkScoped); | |
| 186 | ||
| 187 | const sampleOnce = (allowSuspendRecovery: boolean) => | |
| 188 | Effect.gen(function* () { | |
| 189 | const sampledAt = yield* DateTime.now; | |
| 190 | const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt); | |
| 191 | const demand = (yield* Ref.get(diagnosticsDemandSources)).size > 0; | |
| 192 | const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all( | |
| 193 | [ | |
| 194 | Ref.get(powerState), | |
| 195 | powerMonitor.getSystemIdleTime, | |
| 196 | powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), | |
| 197 | powerMonitor.isOnBatteryPower, | |
| 198 | demand ? electronApp.getAppMetrics : Effect.succeed([]), | |
| 199 | ], | |
| 200 | { concurrency: "unbounded" }, | |
| 201 | ); | |
| 202 | const polledLocked = | |
| 203 | systemIdleState === "unknown" | |
| 204 | ? currentPower.locked | |
| 205 | : booleanState(systemIdleState === "locked"); | |
| 206 | const polledOnBattery = booleanState(onBattery); | |
| 207 | const observedPower = yield* Ref.modify(powerState, (latestPower) => { | |
| 208 | const preserveEventLocked = | |
| 209 | latestPower.lockedEventPending || | |
| 210 | latestPower.locked !== currentPower.locked || | |
| 211 | latestPower.lockedEventPending !== currentPower.lockedEventPending; | |
| 212 | const preserveEventOnBattery = | |
| 213 | latestPower.onBatteryEventPending || | |
| 214 | latestPower.onBattery !== currentPower.onBattery || | |
| 215 | latestPower.onBatteryEventPending !== currentPower.onBatteryEventPending; | |
| 216 | const preserveEventSuspended = | |
| 217 | latestPower.suspendedEventPending || | |
| 218 | latestPower.suspended !== currentPower.suspended || | |
| 219 | latestPower.suspendedEventPending !== currentPower.suspendedEventPending; | |
| 220 | const next: PowerState = { | |
| 221 | ...latestPower, | |
| 222 | idle: idleState(systemIdleState), | |
| 223 | locked: preserveEventLocked ? latestPower.locked : polledLocked, | |
| 224 | lockedEventPending: | |
| 225 | latestPower.lockedEventPending && | |
| 226 | (systemIdleState === "unknown" || polledLocked !== latestPower.locked), | |
| 227 | suspended: | |
| 228 | allowSuspendRecovery && !preserveEventSuspended ? false : latestPower.suspended, | |
| 229 | suspendedEventPending: false, | |
| 230 | onBattery: preserveEventOnBattery ? latestPower.onBattery : polledOnBattery, | |
| 231 | onBatteryEventPending: | |
| 232 | latestPower.onBatteryEventPending && polledOnBattery !== latestPower.onBattery, | |
| 233 | }; | |
| 234 | return [next, next] as const; | |
| 235 | }); | |
| 236 | const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]); | |
| 237 | const snapshot: DesktopHostTelemetrySnapshot = { | |
| 238 | version: 1, | |
| 239 | type: "desktopTelemetry", | |
| 240 | sequence: nextSequence, | |
| 241 | sampledAtUnixMs, | |
| 242 | electronPid: process.pid, | |
| 243 | power: { | |
| 244 | source: "electron-main", | |
| 245 | idle: observedPower.idle, | |
| 246 | idleSeconds, | |
| 247 | locked: observedPower.locked, | |
| 248 | suspended: observedPower.suspended, | |
| 249 | onBattery: observedPower.onBattery, | |
| 250 | lowPowerMode: "unknown", | |
| 251 | thermalState: observedPower.thermalState, | |
| 252 | stale: false, | |
| 253 | updatedAt: sampledAt, | |
| 254 | }, | |
| 255 | speedLimitPercent: observedPower.speedLimitPercent, | |
| 256 | electronProcesses: metrics.map((metric) => ({ | |
| 257 | pid: metric.pid, | |
| 258 | creationTimeMs: Math.max(0, Math.round(metric.creationTime)), | |
| 259 | type: metric.type, | |
| 260 | ...(metric.name === undefined ? {} : { name: metric.name }), | |
| 261 | ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }), | |
| 262 | cpuPercent: metric.cpu.percentCPUUsage, | |
| 263 | ...(metric.cpu.cumulativeCPUUsage === undefined | |
| 264 | ? {} | |
| 265 | : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }), | |
| 266 | idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond, | |
| 267 | workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)), | |
| 268 | peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)), | |
| 269 | })), | |
| 270 | }; | |
| 271 | ||
| 272 | yield* Ref.set(latest, Option.some(snapshot)); | |
| 273 | yield* PubSub.publish(changes, snapshot); | |
| 274 | }).pipe( | |
| 275 | Effect.catchCause((cause) => | |
| 276 | Cause.hasInterrupts(cause) | |
| 277 | ? Effect.failCause(cause) | |
| 278 | : Effect.logWarning("Failed to sample Electron telemetry", { | |
| 279 | cause: String(cause), | |
| 280 | }), | |
| 281 | ), | |
| 282 | ); | |
| 283 | ||
| 284 | yield* Effect.gen(function* () { | |
| 285 | yield* sampleOnce(false); | |
| 286 | while (true) { | |
| 287 | const [currentPower, demand, intervals] = yield* Effect.all([ | |
| 288 | Ref.get(powerState), | |
| 289 | Ref.get(diagnosticsDemandSources).pipe(Effect.map((sources) => sources.size > 0)), | |
| 290 | Ref.get(hostPowerIntervals), | |
| 291 | ]); | |
| 292 | const allowSuspendRecovery = yield* Effect.raceFirst( | |
| 293 | Queue.take(sampleTriggers).pipe(Effect.as(false)), | |
| 294 | Effect.sleep(sampleInterval(currentPower, demand, intervals)).pipe(Effect.as(true)), | |
| 295 | ); | |
| 296 | yield* sampleOnce(allowSuspendRecovery); | |
| 297 | } | |
| 298 | }).pipe(Effect.forkScoped); | |
| 299 | ||
| 300 | const handleControlForSource: DesktopTelemetryPublisher["Service"]["handleControlForSource"] = ( | |
| 301 | sourceId, | |
| 302 | message, | |
| 303 | ) => { | |
| 304 | switch (message.type) { | |
| 305 | case "setDiagnosticsDemand": | |
| 306 | return Ref.modify(diagnosticsDemandSources, (sources) => { | |
| 307 | const previous = sources.size > 0; | |
| 308 | const next = new Set(sources); | |
| 309 | if (message.enabled) { | |
| 310 | next.add(sourceId); | |
| 311 | } else { | |
| 312 | next.delete(sourceId); | |
| 313 | } | |
| 314 | return [[previous, next.size > 0] as const, next] as const; | |
| 315 | }).pipe( | |
| 316 | Effect.flatMap(([previous, enabled]) => | |
| 317 | previous === enabled | |
| 318 | ? Effect.void | |
| 319 | : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid), | |
| 320 | ), | |
| 321 | ); | |
| 322 | case "setHostPowerIntervals": | |
| 323 | return Ref.set(hostPowerIntervals, { | |
| 324 | active: Duration.millis(message.activeIntervalMs), | |
| 325 | idle: Duration.millis(message.idleIntervalMs), | |
| 326 | }).pipe(Effect.andThen(Queue.offer(sampleTriggers, undefined)), Effect.asVoid); | |
| 327 | } | |
| 328 | }; | |
| 329 | const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = ( | |
| 330 | sourceId, | |
| 331 | ) => | |
| 332 | Ref.modify(diagnosticsDemandSources, (sources) => { | |
| 333 | const previous = sources.size > 0; | |
| 334 | const next = new Set(sources); | |
| 335 | next.delete(sourceId); | |
| 336 | return [[previous, next.size > 0] as const, next] as const; | |
| 337 | }).pipe( | |
| 338 | Effect.flatMap(([previous, enabled]) => | |
| 339 | previous === enabled | |
| 340 | ? Effect.void | |
| 341 | : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid), | |
| 342 | ), | |
| 343 | ); | |
| 344 | const handleControl: DesktopTelemetryPublisher["Service"]["handleControl"] = (message) => | |
| 345 | handleControlForSource("legacy", message); | |
| 346 | ||
| 347 | const snapshots = Stream.unwrap( | |
| 348 | Effect.gen(function* () { | |
| 349 | const subscription = yield* PubSub.subscribe(changes); | |
| 350 | const initial = yield* Ref.get(latest); | |
| 351 | return Stream.concat( | |
| 352 | Option.match(initial, { | |
| 353 | onNone: () => Stream.empty, | |
| 354 | onSome: Stream.make, | |
| 355 | }), | |
| 356 | Stream.fromSubscription(subscription), | |
| 357 | ); | |
| 358 | }), | |
| 359 | ); | |
| 360 | const encoded = Stream.concat( | |
| 361 | Stream.make({ | |
| 362 | version: 1, | |
| 363 | type: "desktopTelemetryHello", | |
| 364 | electronPid: process.pid, | |
| 365 | } as const), | |
| 366 | snapshots, | |
| 367 | ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`))); | |
| 368 | ||
| 369 | return DesktopTelemetryPublisher.of({ | |
| 370 | latest: Ref.get(latest), | |
| 371 | changes: Stream.fromPubSub(changes), | |
| 372 | encoded, | |
| 373 | handleControl, | |
| 374 | handleControlForSource, | |
| 375 | removeControlSource, | |
| 376 | }); | |
| 377 | }); | |
| 378 | ||
| 379 | export const layer = Layer.effect(DesktopTelemetryPublisher, make()); |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | ... | @@ -40,6 +40,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 40 | 40 | setAboutPanelOptions: () => Effect.void, |
| 41 | 41 | setAppUserModelId: () => Effect.void, |
| 42 | 42 | requestSingleInstanceLock: Effect.succeed(true), |
| 43 | getAppMetrics: Effect.succeed([]), | |
| 43 | 44 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 44 | 45 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 45 | 46 | setDesktopName: () => Effect.void, |
apps/mobile/src/connection/background-activity-scopes.ts created+92| ... | ... | @@ -0,0 +1,92 @@ |
| 1 | import type { EnvironmentRpcSubscriptionObservation } from "@t3tools/client-runtime/rpc"; | |
| 2 | import { type BackgroundScope, type EnvironmentId, WS_METHODS } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | interface RetainedScope { | |
| 6 | readonly environmentId: EnvironmentId; | |
| 7 | readonly scope: BackgroundScope; | |
| 8 | refCount: number; | |
| 9 | } | |
| 10 | ||
| 11 | const retainedScopes = new Map<string, RetainedScope>(); | |
| 12 | const listeners = new Set<() => void>(); | |
| 13 | ||
| 14 | function notify(): void { | |
| 15 | for (const listener of listeners) { | |
| 16 | try { | |
| 17 | listener(); | |
| 18 | } catch { | |
| 19 | // A failing observer must not corrupt retained-scope lifetime. | |
| 20 | } | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string { | |
| 25 | switch (scope.type) { | |
| 26 | case "server-config": | |
| 27 | case "diagnostics": | |
| 28 | return JSON.stringify([environmentId, scope.type]); | |
| 29 | case "provider-status": | |
| 30 | return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]); | |
| 31 | case "vcs-status": | |
| 32 | case "git-refs": | |
| 33 | return JSON.stringify([environmentId, scope.type, scope.cwd]); | |
| 34 | case "thread": | |
| 35 | return JSON.stringify([environmentId, scope.type, scope.threadId]); | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | function scopeForSubscription( | |
| 40 | observation: EnvironmentRpcSubscriptionObservation, | |
| 41 | ): BackgroundScope | null { | |
| 42 | if (observation.method === WS_METHODS.subscribeResourceTelemetry) { | |
| 43 | return { type: "diagnostics" }; | |
| 44 | } | |
| 45 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { | |
| 46 | return null; | |
| 47 | } | |
| 48 | const input = observation.input as { readonly cwd?: unknown }; | |
| 49 | return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; | |
| 50 | } | |
| 51 | ||
| 52 | export function retainedMobileBackgroundScopes( | |
| 53 | environmentId: EnvironmentId, | |
| 54 | ): ReadonlyArray<BackgroundScope> { | |
| 55 | return Array.from(retainedScopes.values(), (entry) => | |
| 56 | entry.environmentId === environmentId ? entry.scope : null, | |
| 57 | ).filter((scope): scope is BackgroundScope => scope !== null); | |
| 58 | } | |
| 59 | ||
| 60 | export function observeMobileBackgroundActivitySubscription( | |
| 61 | observation: EnvironmentRpcSubscriptionObservation, | |
| 62 | ): Effect.Effect<Effect.Effect<void>> { | |
| 63 | const scope = scopeForSubscription(observation); | |
| 64 | if (scope === null) return Effect.succeed(Effect.void); | |
| 65 | return Effect.sync(() => { | |
| 66 | const environmentId = observation.environmentId as EnvironmentId; | |
| 67 | const key = stableScopeKey(environmentId, scope); | |
| 68 | const current = retainedScopes.get(key); | |
| 69 | if (current) { | |
| 70 | current.refCount += 1; | |
| 71 | } else { | |
| 72 | retainedScopes.set(key, { environmentId, scope, refCount: 1 }); | |
| 73 | notify(); | |
| 74 | } | |
| 75 | return Effect.sync(() => { | |
| 76 | const retained = retainedScopes.get(key); | |
| 77 | if (!retained) return; | |
| 78 | retained.refCount -= 1; | |
| 79 | if (retained.refCount <= 0) { | |
| 80 | retainedScopes.delete(key); | |
| 81 | notify(); | |
| 82 | } | |
| 83 | }); | |
| 84 | }); | |
| 85 | } | |
| 86 | ||
| 87 | export function onRetainedMobileBackgroundScopesChange(listener: () => void): () => void { | |
| 88 | listeners.add(listener); | |
| 89 | return () => { | |
| 90 | listeners.delete(listener); | |
| 91 | }; | |
| 92 | } |
apps/mobile/src/connection/background-activity.test.ts created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | import { EnvironmentId, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | import { | |
| 6 | onRetainedMobileBackgroundScopesChange, | |
| 7 | observeMobileBackgroundActivitySubscription, | |
| 8 | retainedMobileBackgroundScopes, | |
| 9 | } from "./background-activity-scopes"; | |
| 10 | ||
| 11 | describe("mobile background activity", () => { | |
| 12 | it.effect("retains VCS demand only while the mobile subscription is active", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const environmentId = EnvironmentId.make("mobile-environment"); | |
| 15 | const release = yield* observeMobileBackgroundActivitySubscription({ | |
| 16 | environmentId, | |
| 17 | method: WS_METHODS.subscribeVcsStatus, | |
| 18 | input: { cwd: "/workspace" }, | |
| 19 | }); | |
| 20 | ||
| 21 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([ | |
| 22 | { type: "vcs-status", cwd: "/workspace" }, | |
| 23 | ]); | |
| 24 | ||
| 25 | yield* release; | |
| 26 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); | |
| 27 | }), | |
| 28 | ); | |
| 29 | ||
| 30 | it.effect("keeps delimiter-containing environment and scope values distinct", () => | |
| 31 | Effect.gen(function* () { | |
| 32 | const firstEnvironmentId = EnvironmentId.make("a"); | |
| 33 | const secondEnvironmentId = EnvironmentId.make("a:vcs-status:b"); | |
| 34 | const releaseFirst = yield* observeMobileBackgroundActivitySubscription({ | |
| 35 | environmentId: firstEnvironmentId, | |
| 36 | method: WS_METHODS.subscribeVcsStatus, | |
| 37 | input: { cwd: "b:vcs-status:c" }, | |
| 38 | }); | |
| 39 | const releaseSecond = yield* observeMobileBackgroundActivitySubscription({ | |
| 40 | environmentId: secondEnvironmentId, | |
| 41 | method: WS_METHODS.subscribeVcsStatus, | |
| 42 | input: { cwd: "c" }, | |
| 43 | }); | |
| 44 | ||
| 45 | expect(retainedMobileBackgroundScopes(firstEnvironmentId)).toEqual([ | |
| 46 | { type: "vcs-status", cwd: "b:vcs-status:c" }, | |
| 47 | ]); | |
| 48 | expect(retainedMobileBackgroundScopes(secondEnvironmentId)).toEqual([ | |
| 49 | { type: "vcs-status", cwd: "c" }, | |
| 50 | ]); | |
| 51 | ||
| 52 | yield* Effect.all([releaseFirst, releaseSecond]); | |
| 53 | }), | |
| 54 | ); | |
| 55 | ||
| 56 | it.effect("returns a release handle when a retained-scope listener throws", () => | |
| 57 | Effect.gen(function* () { | |
| 58 | const environmentId = EnvironmentId.make("throwing-listener-environment"); | |
| 59 | const removeListener = onRetainedMobileBackgroundScopesChange(() => { | |
| 60 | throw new Error("listener failed"); | |
| 61 | }); | |
| 62 | ||
| 63 | const release = yield* observeMobileBackgroundActivitySubscription({ | |
| 64 | environmentId, | |
| 65 | method: WS_METHODS.subscribeVcsStatus, | |
| 66 | input: { cwd: "/workspace" }, | |
| 67 | }); | |
| 68 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([ | |
| 69 | { type: "vcs-status", cwd: "/workspace" }, | |
| 70 | ]); | |
| 71 | ||
| 72 | yield* release; | |
| 73 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); | |
| 74 | removeListener(); | |
| 75 | }), | |
| 76 | ); | |
| 77 | }); |
apps/mobile/src/connection/background-activity.ts created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; | |
| 2 | import { EnvironmentRpcSubscriptionObserver, request } from "@t3tools/client-runtime/rpc"; | |
| 3 | import { | |
| 4 | type BackgroundScope, | |
| 5 | type ClientActivityReportInput, | |
| 6 | type EnvironmentId, | |
| 7 | WS_METHODS, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Clock from "effect/Clock"; | |
| 10 | import * as DateTime from "effect/DateTime"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Queue from "effect/Queue"; | |
| 14 | import * as Schedule from "effect/Schedule"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 17 | import { AppState, type AppStateStatus } from "react-native"; | |
| 18 | ||
| 19 | import * as MobileStorage from "../persistence/mobile-storage"; | |
| 20 | import { | |
| 21 | observeMobileBackgroundActivitySubscription, | |
| 22 | onRetainedMobileBackgroundScopesChange, | |
| 23 | retainedMobileBackgroundScopes, | |
| 24 | } from "./background-activity-scopes"; | |
| 25 | ||
| 26 | const REPORT_INTERVAL_MS = 25_000; | |
| 27 | const LEASE_TTL_MS = 45_000; | |
| 28 | const BASELINE_SCOPES: ReadonlyArray<BackgroundScope> = [{ type: "provider-status" }]; | |
| 29 | ||
| 30 | function normalizeAppState( | |
| 31 | state: AppStateStatus, | |
| 32 | ): NonNullable<ClientActivityReportInput["appState"]> { | |
| 33 | if (state === "active" || state === "inactive" || state === "background") return state; | |
| 34 | return "unknown"; | |
| 35 | } | |
| 36 | ||
| 37 | export const mobileBackgroundActivityObserverLayer = Layer.succeed( | |
| 38 | EnvironmentRpcSubscriptionObserver, | |
| 39 | EnvironmentRpcSubscriptionObserver.of({ | |
| 40 | observe: observeMobileBackgroundActivitySubscription, | |
| 41 | }), | |
| 42 | ); | |
| 43 | ||
| 44 | export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard( | |
| 45 | Effect.gen(function* () { | |
| 46 | const registry = yield* EnvironmentRegistry; | |
| 47 | const storage = yield* MobileStorage.MobileStorage; | |
| 48 | const clientId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( | |
| 49 | Effect.map((deviceId) => `mobile-${deviceId}`), | |
| 50 | Effect.orElseSucceed(() => "ephemeral-mobile-client"), | |
| 51 | ); | |
| 52 | const reportRequests = yield* Queue.sliding<void>(1); | |
| 53 | const requestReport = () => Queue.offerUnsafe(reportRequests, undefined); | |
| 54 | let appState = AppState.currentState; | |
| 55 | ||
| 56 | const report = Effect.gen(function* () { | |
| 57 | const observedAtMs = yield* Clock.currentTimeMillis; | |
| 58 | const active = appState === "active"; | |
| 59 | const entries = yield* SubscriptionRef.get(registry.entries); | |
| 60 | yield* Effect.forEach( | |
| 61 | entries.keys(), | |
| 62 | (environmentId) => | |
| 63 | registry | |
| 64 | .run( | |
| 65 | environmentId, | |
| 66 | request(WS_METHODS.serverReportClientActivity, { | |
| 67 | environmentId: environmentId as EnvironmentId, | |
| 68 | clientId, | |
| 69 | clientKind: "mobile", | |
| 70 | visible: active, | |
| 71 | focused: active, | |
| 72 | recentlyInteracted: active, | |
| 73 | appState: normalizeAppState(appState), | |
| 74 | scopes: [ | |
| 75 | ...BASELINE_SCOPES, | |
| 76 | ...retainedMobileBackgroundScopes(environmentId as EnvironmentId), | |
| 77 | ], | |
| 78 | ttlMs: LEASE_TTL_MS, | |
| 79 | observedAt: DateTime.makeUnsafe(observedAtMs), | |
| 80 | }), | |
| 81 | ) | |
| 82 | .pipe(Effect.ignore), | |
| 83 | { concurrency: "unbounded", discard: true }, | |
| 84 | ); | |
| 85 | }).pipe(Effect.withSpan("mobile.backgroundActivity.report")); | |
| 86 | ||
| 87 | yield* Effect.acquireRelease( | |
| 88 | Effect.sync(() => { | |
| 89 | const removeScopeListener = onRetainedMobileBackgroundScopesChange(requestReport); | |
| 90 | const subscription = AppState.addEventListener("change", (nextState) => { | |
| 91 | appState = nextState; | |
| 92 | requestReport(); | |
| 93 | }); | |
| 94 | return { removeScopeListener, subscription }; | |
| 95 | }), | |
| 96 | ({ removeScopeListener, subscription }) => | |
| 97 | Effect.sync(() => { | |
| 98 | removeScopeListener(); | |
| 99 | subscription.remove(); | |
| 100 | }), | |
| 101 | ); | |
| 102 | yield* SubscriptionRef.changes(registry.entries).pipe( | |
| 103 | Stream.runForEach(() => Effect.sync(requestReport)), | |
| 104 | Effect.forkScoped, | |
| 105 | ); | |
| 106 | yield* Stream.fromQueue(reportRequests).pipe( | |
| 107 | Stream.debounce("250 millis"), | |
| 108 | Stream.runForEach(() => report), | |
| 109 | Effect.forkScoped, | |
| 110 | ); | |
| 111 | yield* Effect.sync(requestReport).pipe( | |
| 112 | Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)), | |
| 113 | Effect.forkScoped, | |
| 114 | ); | |
| 115 | }), | |
| 116 | ); |
apps/mobile/src/connection/runtime.ts+19-3| ... | ... | @@ -5,6 +5,10 @@ import * as Layer from "effect/Layer"; |
| 5 | 5 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 6 | |
| 7 | 7 | import { runtimeContextLayer } from "../lib/runtime"; |
| 8 | import { | |
| 9 | mobileBackgroundActivityObserverLayer, | |
| 10 | mobileBackgroundActivityReporterLayer, | |
| 11 | } from "./background-activity"; | |
| 8 | 12 | import { connectionPlatformLayer } from "./platform"; |
| 9 | 13 | |
| 10 | 14 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( |
| ... | ... | @@ -17,10 +21,22 @@ type ConnectionLayerSource = |
| 17 | 21 | | typeof Connection.layer |
| 18 | 22 | | typeof snapshotLoaderLayer |
| 19 | 23 | | typeof runtimeContextLayer |
| 20 | | typeof connectionPlatformLayer; | |
| 24 | | typeof connectionPlatformLayer | |
| 25 | | typeof mobileBackgroundActivityObserverLayer | |
| 26 | | typeof mobileBackgroundActivityReporterLayer; | |
| 21 | 27 | |
| 22 | const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( | |
| 23 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), | |
| 28 | const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( | |
| 29 | Layer.provideMerge( | |
| 30 | Layer.mergeAll( | |
| 31 | runtimeContextLayer, | |
| 32 | providedConnectionPlatformLayer, | |
| 33 | mobileBackgroundActivityObserverLayer, | |
| 34 | ), | |
| 35 | ), | |
| 36 | ); | |
| 37 | ||
| 38 | const connectionLayer = mobileBackgroundActivityReporterLayer.pipe( | |
| 39 | Layer.provideMerge(providedClientConnectionLayer), | |
| 24 | 40 | ); |
| 25 | 41 | |
| 26 | 42 | export const connectionAtomRuntime: Atom.AtomRuntime< |
apps/server/src/auth/RpcAuthorization.test.ts created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthOrchestrationReadScope, | |
| 4 | AuthRelayReadScope, | |
| 5 | AuthRelayWriteScope, | |
| 6 | WS_METHODS, | |
| 7 | WsRpcGroup, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { describe, expect, it } from "@effect/vitest"; | |
| 10 | ||
| 11 | import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; | |
| 12 | ||
| 13 | describe("RPC authorization scopes", () => { | |
| 14 | it("declares exactly one scope for every RPC in the server group", () => { | |
| 15 | expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); | |
| 16 | }); | |
| 17 | ||
| 18 | it("authorizes background policy reporting and observation deliberately", () => { | |
| 19 | expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe( | |
| 20 | AuthOrchestrationReadScope, | |
| 21 | ); | |
| 22 | expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe( | |
| 23 | AuthOrchestrationOperateScope, | |
| 24 | ); | |
| 25 | expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe( | |
| 26 | AuthOrchestrationReadScope, | |
| 27 | ); | |
| 28 | expect(requiredScopeForRpcMethod(WS_METHODS.subscribeBackgroundPolicy)).toBe( | |
| 29 | AuthOrchestrationReadScope, | |
| 30 | ); | |
| 31 | }); | |
| 32 | ||
| 33 | it("allows relay status reads without granting relay installation access", () => { | |
| 34 | expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe( | |
| 35 | AuthRelayReadScope, | |
| 36 | ); | |
| 37 | expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); | |
| 38 | }); | |
| 39 | ||
| 40 | it("rejects unknown RPC method names", () => { | |
| 41 | for (const method of ["server.notRegistered", "toString", "constructor"]) { | |
| 42 | expect(() => requiredScopeForRpcMethod(method)).toThrow( | |
| 43 | `RPC method ${method} has no declared authorization scope.`, | |
| 44 | ); | |
| 45 | } | |
| 46 | }); | |
| 47 | }); |
apps/server/src/auth/RpcAuthorization.ts created+111| ... | ... | @@ -0,0 +1,111 @@ |
| 1 | import { | |
| 2 | AuthAccessReadScope, | |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | AuthOrchestrationReadScope, | |
| 5 | AuthRelayReadScope, | |
| 6 | AuthRelayWriteScope, | |
| 7 | AuthReviewWriteScope, | |
| 8 | AuthTerminalOperateScope, | |
| 9 | ORCHESTRATION_WS_METHODS, | |
| 10 | type AuthEnvironmentScope, | |
| 11 | WS_METHODS, | |
| 12 | WsRpcGroup, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | |
| 15 | ||
| 16 | type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"]; | |
| 17 | ||
| 18 | /** | |
| 19 | * Keep authorization coverage coupled to the RPC group itself. Adding an RPC to | |
| 20 | * `WsRpcGroup` without choosing a scope is a type error instead of a production | |
| 21 | * runtime failure. | |
| 22 | */ | |
| 23 | export const RPC_REQUIRED_SCOPES = { | |
| 24 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, | |
| 25 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, | |
| 26 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, | |
| 27 | [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, | |
| 28 | [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, | |
| 29 | [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, | |
| 30 | [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, | |
| 31 | [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, | |
| 32 | [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, | |
| 33 | [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, | |
| 34 | [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, | |
| 35 | [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, | |
| 36 | [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, | |
| 37 | [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, | |
| 38 | [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, | |
| 39 | [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, | |
| 40 | [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, | |
| 41 | [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, | |
| 42 | [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, | |
| 43 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, | |
| 44 | [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, | |
| 45 | [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, | |
| 46 | [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, | |
| 47 | [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, | |
| 48 | [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, | |
| 49 | [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, | |
| 50 | [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, | |
| 51 | [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, | |
| 52 | [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, | |
| 53 | [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, | |
| 54 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, | |
| 55 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, | |
| 56 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, | |
| 57 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, | |
| 58 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, | |
| 59 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, | |
| 60 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, | |
| 61 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, | |
| 62 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, | |
| 63 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, | |
| 64 | [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, | |
| 65 | [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, | |
| 66 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, | |
| 67 | [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, | |
| 68 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, | |
| 69 | [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, | |
| 70 | [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, | |
| 71 | [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, | |
| 72 | [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, | |
| 73 | [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, | |
| 74 | [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, | |
| 75 | [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, | |
| 76 | [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, | |
| 77 | [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, | |
| 78 | [WS_METHODS.terminalResize]: AuthTerminalOperateScope, | |
| 79 | [WS_METHODS.terminalClear]: AuthTerminalOperateScope, | |
| 80 | [WS_METHODS.terminalRestart]: AuthTerminalOperateScope, | |
| 81 | [WS_METHODS.terminalClose]: AuthTerminalOperateScope, | |
| 82 | [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, | |
| 83 | [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, | |
| 84 | [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope, | |
| 85 | [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope, | |
| 86 | [WS_METHODS.previewResize]: AuthOrchestrationOperateScope, | |
| 87 | [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope, | |
| 88 | [WS_METHODS.previewClose]: AuthOrchestrationOperateScope, | |
| 89 | [WS_METHODS.previewList]: AuthOrchestrationReadScope, | |
| 90 | [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, | |
| 91 | [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, | |
| 92 | [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope, | |
| 93 | [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope, | |
| 94 | [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, | |
| 95 | [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, | |
| 96 | [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, | |
| 97 | [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, | |
| 98 | [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, | |
| 99 | [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, | |
| 100 | } as const satisfies Readonly<Record<WsRpcMethod, AuthEnvironmentScope>>; | |
| 101 | ||
| 102 | export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope { | |
| 103 | if (!Object.hasOwn(RPC_REQUIRED_SCOPES, method)) { | |
| 104 | throw new Error(`RPC method ${method} has no declared authorization scope.`); | |
| 105 | } | |
| 106 | const requiredScope = RPC_REQUIRED_SCOPES[method as WsRpcMethod]; | |
| 107 | if (requiredScope === undefined) { | |
| 108 | throw new Error(`RPC method ${method} has no declared authorization scope.`); | |
| 109 | } | |
| 110 | return requiredScope; | |
| 111 | } |
apps/server/src/background/BackgroundPolicy.test.ts created+386| ... | ... | @@ -0,0 +1,386 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | AuthSessionId, | |
| 4 | RpcClientId, | |
| 5 | type HostPowerSnapshot, | |
| 6 | type ClientActivityReportInput, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as DateTime from "effect/DateTime"; | |
| 9 | import * as Deferred from "effect/Deferred"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as PubSub from "effect/PubSub"; | |
| 14 | import * as Ref from "effect/Ref"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | ||
| 17 | import { ServerSettingsService } from "../serverSettings.ts"; | |
| 18 | import * as BackgroundPolicy from "./BackgroundPolicy.ts"; | |
| 19 | import * as HostPowerMonitor from "./HostPowerMonitor.ts"; | |
| 20 | ||
| 21 | const TEST_NOW = DateTime.makeUnsafe("2026-05-13T00:00:00.000Z"); | |
| 22 | ||
| 23 | const nominalHostPower: HostPowerSnapshot = { | |
| 24 | source: "unknown", | |
| 25 | idle: "unknown", | |
| 26 | idleSeconds: null, | |
| 27 | locked: "unknown", | |
| 28 | suspended: false, | |
| 29 | onBattery: "unknown", | |
| 30 | lowPowerMode: "unknown", | |
| 31 | thermalState: "unknown", | |
| 32 | stale: true, | |
| 33 | updatedAt: TEST_NOW, | |
| 34 | }; | |
| 35 | ||
| 36 | const constrainedHostPower: HostPowerSnapshot = { | |
| 37 | ...nominalHostPower, | |
| 38 | lowPowerMode: "true", | |
| 39 | stale: false, | |
| 40 | }; | |
| 41 | ||
| 42 | function makeReport(overrides: Partial<ClientActivityReportInput> = {}): ClientActivityReportInput { | |
| 43 | return { | |
| 44 | clientId: "client-1", | |
| 45 | clientKind: "web", | |
| 46 | visible: true, | |
| 47 | focused: true, | |
| 48 | recentlyInteracted: true, | |
| 49 | scopes: [{ type: "vcs-status", cwd: "/repo" }], | |
| 50 | ttlMs: 45_000, | |
| 51 | observedAt: TEST_NOW, | |
| 52 | ...overrides, | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | function makeLayer( | |
| 57 | hostPower: HostPowerSnapshot, | |
| 58 | settingsOverrides: Parameters<typeof ServerSettingsService.layerTest>[0] = {}, | |
| 59 | ) { | |
| 60 | const hostLayer = Layer.effect( | |
| 61 | HostPowerMonitor.HostPowerMonitor, | |
| 62 | Effect.gen(function* () { | |
| 63 | const changes = yield* PubSub.sliding<HostPowerSnapshot>(1); | |
| 64 | let snapshot = hostPower; | |
| 65 | return HostPowerMonitor.HostPowerMonitor.of({ | |
| 66 | snapshot: Effect.sync(() => snapshot), | |
| 67 | report: (next) => | |
| 68 | Effect.sync(() => { | |
| 69 | snapshot = next; | |
| 70 | }).pipe(Effect.andThen(PubSub.publish(changes, next)), Effect.asVoid), | |
| 71 | streamChanges: Stream.fromPubSub(changes), | |
| 72 | }); | |
| 73 | }), | |
| 74 | ); | |
| 75 | return BackgroundPolicy.layer.pipe( | |
| 76 | Layer.provide(Layer.merge(hostLayer, ServerSettingsService.layerTest(settingsOverrides))), | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | describe("BackgroundPolicy", () => { | |
| 81 | it.effect("records foreground scoped client demand", () => | |
| 82 | Effect.gen(function* () { | |
| 83 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 84 | yield* policy.reportClientActivity( | |
| 85 | AuthSessionId.make("session-1"), | |
| 86 | RpcClientId.make(1), | |
| 87 | makeReport(), | |
| 88 | ); | |
| 89 | ||
| 90 | const snapshot = yield* policy.snapshot; | |
| 91 | assert.equal(snapshot.activeForegroundLeaseCount, 1); | |
| 92 | assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); | |
| 93 | assert.equal(snapshot.shouldRunOpportunisticWork, true); | |
| 94 | assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); | |
| 95 | assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/other" }), false); | |
| 96 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); | |
| 97 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/other" }), false); | |
| 98 | }).pipe(Effect.provide(makeLayer(nominalHostPower))), | |
| 99 | ); | |
| 100 | ||
| 101 | it.effect("removes all leases for a disconnected websocket connection", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 104 | yield* policy.reportClientActivity( | |
| 105 | AuthSessionId.make("session-1"), | |
| 106 | RpcClientId.make(1), | |
| 107 | makeReport(), | |
| 108 | ); | |
| 109 | yield* policy.removeRpcClient(AuthSessionId.make("session-1"), RpcClientId.make(1)); | |
| 110 | ||
| 111 | const snapshot = yield* policy.snapshot; | |
| 112 | assert.equal(snapshot.activeForegroundLeaseCount, 0); | |
| 113 | assert.deepStrictEqual(snapshot.activeScopeKeys, []); | |
| 114 | assert.equal(snapshot.shouldRunOpportunisticWork, false); | |
| 115 | }).pipe(Effect.provide(makeLayer(nominalHostPower))), | |
| 116 | ); | |
| 117 | ||
| 118 | it.effect("keeps leases from another session when rpc client ids are reused", () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 121 | const rpcClientId = RpcClientId.make(1); | |
| 122 | yield* policy.reportClientActivity( | |
| 123 | AuthSessionId.make("session-1"), | |
| 124 | rpcClientId, | |
| 125 | makeReport({ clientId: "client-1" }), | |
| 126 | ); | |
| 127 | yield* policy.reportClientActivity( | |
| 128 | AuthSessionId.make("session-2"), | |
| 129 | rpcClientId, | |
| 130 | makeReport({ clientId: "client-2" }), | |
| 131 | ); | |
| 132 | ||
| 133 | yield* policy.removeRpcClient(AuthSessionId.make("session-1"), rpcClientId); | |
| 134 | ||
| 135 | const snapshot = yield* policy.snapshot; | |
| 136 | assert.equal(snapshot.activeForegroundLeaseCount, 1); | |
| 137 | assert.equal(snapshot.leases[0]?.sessionId, AuthSessionId.make("session-2")); | |
| 138 | assert.equal(snapshot.leases[0]?.clientId, "client-2"); | |
| 139 | }).pipe(Effect.provide(makeLayer(nominalHostPower))), | |
| 140 | ); | |
| 141 | ||
| 142 | it.effect("serializes lease mutation publications", () => | |
| 143 | Effect.gen(function* () { | |
| 144 | const firstSnapshotStarted = yield* Deferred.make<void>(); | |
| 145 | const releaseFirstSnapshot = yield* Deferred.make<void>(); | |
| 146 | const snapshotReads = yield* Ref.make(0); | |
| 147 | const hostLayer = Layer.succeed( | |
| 148 | HostPowerMonitor.HostPowerMonitor, | |
| 149 | HostPowerMonitor.HostPowerMonitor.of({ | |
| 150 | snapshot: Ref.updateAndGet(snapshotReads, (count) => count + 1).pipe( | |
| 151 | Effect.flatMap((count) => | |
| 152 | count === 1 | |
| 153 | ? Deferred.succeed(firstSnapshotStarted, undefined).pipe( | |
| 154 | Effect.andThen(Deferred.await(releaseFirstSnapshot)), | |
| 155 | Effect.as(nominalHostPower), | |
| 156 | ) | |
| 157 | : Effect.succeed(nominalHostPower), | |
| 158 | ), | |
| 159 | ), | |
| 160 | report: () => Effect.void, | |
| 161 | streamChanges: Stream.empty, | |
| 162 | }), | |
| 163 | ); | |
| 164 | const layer = BackgroundPolicy.layer.pipe( | |
| 165 | Layer.provide(Layer.merge(hostLayer, ServerSettingsService.layerTest())), | |
| 166 | ); | |
| 167 | ||
| 168 | yield* Effect.gen(function* () { | |
| 169 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 170 | const updatesFiber = yield* policy.streamChanges.pipe( | |
| 171 | Stream.take(2), | |
| 172 | Stream.runCollect, | |
| 173 | Effect.forkChild, | |
| 174 | ); | |
| 175 | yield* Effect.yieldNow; | |
| 176 | const reportFiber = yield* policy | |
| 177 | .reportClientActivity(AuthSessionId.make("session-1"), RpcClientId.make(1), makeReport()) | |
| 178 | .pipe(Effect.forkChild); | |
| 179 | yield* Deferred.await(firstSnapshotStarted); | |
| 180 | const removeFiber = yield* policy | |
| 181 | .removeRpcClient(AuthSessionId.make("session-1"), RpcClientId.make(1)) | |
| 182 | .pipe(Effect.forkChild); | |
| 183 | yield* Effect.yieldNow; | |
| 184 | yield* Deferred.succeed(releaseFirstSnapshot, undefined); | |
| 185 | yield* Fiber.join(reportFiber); | |
| 186 | yield* Fiber.join(removeFiber); | |
| 187 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 188 | ||
| 189 | assert.equal(updates.at(-1)?.activeForegroundLeaseCount, 0); | |
| 190 | assert.deepStrictEqual(updates.at(-1)?.activeScopeKeys, []); | |
| 191 | }).pipe(Effect.provide(layer)); | |
| 192 | }), | |
| 193 | ); | |
| 194 | ||
| 195 | it.effect("bounds client-id churn for one websocket connection", () => | |
| 196 | Effect.gen(function* () { | |
| 197 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 198 | const sessionId = AuthSessionId.make("session-1"); | |
| 199 | const rpcClientId = RpcClientId.make(1); | |
| 200 | for ( | |
| 201 | let index = 0; | |
| 202 | index <= BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT; | |
| 203 | index += 1 | |
| 204 | ) { | |
| 205 | yield* policy.reportClientActivity( | |
| 206 | sessionId, | |
| 207 | rpcClientId, | |
| 208 | makeReport({ clientId: `client-${index}` }), | |
| 209 | ); | |
| 210 | } | |
| 211 | ||
| 212 | const snapshot = yield* policy.snapshot; | |
| 213 | assert.equal( | |
| 214 | snapshot.leases.length, | |
| 215 | BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT, | |
| 216 | ); | |
| 217 | assert.isTrue( | |
| 218 | snapshot.leases.some( | |
| 219 | (lease) => | |
| 220 | lease.clientId === | |
| 221 | `client-${BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT}`, | |
| 222 | ), | |
| 223 | ); | |
| 224 | }).pipe(Effect.provide(makeLayer(nominalHostPower))), | |
| 225 | ); | |
| 226 | ||
| 227 | it.effect("host low power mode disables opportunistic work without dropping scoped demand", () => | |
| 228 | Effect.gen(function* () { | |
| 229 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 230 | yield* policy.reportClientActivity( | |
| 231 | AuthSessionId.make("session-1"), | |
| 232 | RpcClientId.make(1), | |
| 233 | makeReport(), | |
| 234 | ); | |
| 235 | ||
| 236 | const snapshot = yield* policy.snapshot; | |
| 237 | assert.equal(snapshot.activeForegroundLeaseCount, 1); | |
| 238 | assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); | |
| 239 | assert.equal(snapshot.shouldRunOpportunisticWork, false); | |
| 240 | assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); | |
| 241 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); | |
| 242 | }).pipe(Effect.provide(makeLayer(constrainedHostPower))), | |
| 243 | ); | |
| 244 | ||
| 245 | it.effect("host suspension disables scoped and opportunistic work", () => | |
| 246 | Effect.gen(function* () { | |
| 247 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 248 | yield* policy.reportClientActivity( | |
| 249 | AuthSessionId.make("session-1"), | |
| 250 | RpcClientId.make(1), | |
| 251 | makeReport(), | |
| 252 | ); | |
| 253 | ||
| 254 | const snapshot = yield* policy.snapshot; | |
| 255 | assert.equal(snapshot.activeForegroundLeaseCount, 1); | |
| 256 | assert.equal(snapshot.shouldRunOpportunisticWork, false); | |
| 257 | assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); | |
| 258 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); | |
| 259 | }).pipe( | |
| 260 | Effect.provide( | |
| 261 | makeLayer({ | |
| 262 | ...nominalHostPower, | |
| 263 | suspended: true, | |
| 264 | stale: false, | |
| 265 | }), | |
| 266 | ), | |
| 267 | ), | |
| 268 | ); | |
| 269 | ||
| 270 | it.effect("keeps background demand visible while preventing scoped work", () => | |
| 271 | Effect.gen(function* () { | |
| 272 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 273 | yield* policy.reportClientActivity( | |
| 274 | AuthSessionId.make("session-1"), | |
| 275 | RpcClientId.make(1), | |
| 276 | makeReport({ focused: false, visible: false }), | |
| 277 | ); | |
| 278 | ||
| 279 | const snapshot = yield* policy.snapshot; | |
| 280 | assert.equal(snapshot.activeForegroundLeaseCount, 0); | |
| 281 | assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); | |
| 282 | assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); | |
| 283 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); | |
| 284 | }).pipe(Effect.provide(makeLayer(nominalHostPower))), | |
| 285 | ); | |
| 286 | ||
| 287 | it.effect("keeps scoped work active after a recently used visible window loses focus", () => | |
| 288 | Effect.gen(function* () { | |
| 289 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 290 | yield* policy.reportClientActivity( | |
| 291 | AuthSessionId.make("session-1"), | |
| 292 | RpcClientId.make(1), | |
| 293 | makeReport({ focused: false, recentlyInteracted: true }), | |
| 294 | ); | |
| 295 | ||
| 296 | const snapshot = yield* policy.snapshot; | |
| 297 | assert.equal(snapshot.activeForegroundLeaseCount, 1); | |
| 298 | assert.equal(snapshot.shouldRunOpportunisticWork, true); | |
| 299 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); | |
| 300 | }).pipe(Effect.provide(makeLayer(nominalHostPower))), | |
| 301 | ); | |
| 302 | ||
| 303 | it.effect("pauses scoped work for a visible unfocused client without recent interaction", () => | |
| 304 | Effect.gen(function* () { | |
| 305 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 306 | yield* policy.reportClientActivity( | |
| 307 | AuthSessionId.make("session-1"), | |
| 308 | RpcClientId.make(1), | |
| 309 | makeReport({ focused: false, recentlyInteracted: false }), | |
| 310 | ); | |
| 311 | ||
| 312 | const snapshot = yield* policy.snapshot; | |
| 313 | assert.equal(snapshot.activeForegroundLeaseCount, 0); | |
| 314 | assert.equal(snapshot.shouldRunOpportunisticWork, false); | |
| 315 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); | |
| 316 | }).pipe(Effect.provide(makeLayer(nominalHostPower))), | |
| 317 | ); | |
| 318 | ||
| 319 | it.effect( | |
| 320 | "performance profile allows background scoped work while a scoped lease is active", | |
| 321 | () => | |
| 322 | Effect.gen(function* () { | |
| 323 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 324 | yield* policy.reportClientActivity( | |
| 325 | AuthSessionId.make("session-1"), | |
| 326 | RpcClientId.make(1), | |
| 327 | makeReport({ focused: false, visible: false }), | |
| 328 | ); | |
| 329 | ||
| 330 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); | |
| 331 | }).pipe( | |
| 332 | Effect.provide(makeLayer(nominalHostPower, { backgroundActivityProfile: "performance" })), | |
| 333 | ), | |
| 334 | ); | |
| 335 | ||
| 336 | it.effect("battery saver profile pauses scoped work on battery", () => | |
| 337 | Effect.gen(function* () { | |
| 338 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 339 | yield* policy.reportClientActivity( | |
| 340 | AuthSessionId.make("session-1"), | |
| 341 | RpcClientId.make(1), | |
| 342 | makeReport(), | |
| 343 | ); | |
| 344 | ||
| 345 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); | |
| 346 | }).pipe( | |
| 347 | Effect.provide( | |
| 348 | makeLayer( | |
| 349 | { | |
| 350 | ...nominalHostPower, | |
| 351 | onBattery: "true", | |
| 352 | stale: false, | |
| 353 | }, | |
| 354 | { backgroundActivityProfile: "battery-saver" }, | |
| 355 | ), | |
| 356 | ), | |
| 357 | ), | |
| 358 | ); | |
| 359 | ||
| 360 | it.effect("does not gate work on stale host power values", () => | |
| 361 | Effect.gen(function* () { | |
| 362 | const policy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 363 | yield* policy.reportClientActivity( | |
| 364 | AuthSessionId.make("session-1"), | |
| 365 | RpcClientId.make(1), | |
| 366 | makeReport(), | |
| 367 | ); | |
| 368 | ||
| 369 | assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); | |
| 370 | }).pipe( | |
| 371 | Effect.provide( | |
| 372 | makeLayer( | |
| 373 | { | |
| 374 | ...nominalHostPower, | |
| 375 | locked: "true", | |
| 376 | onBattery: "true", | |
| 377 | lowPowerMode: "true", | |
| 378 | thermalState: "critical", | |
| 379 | stale: true, | |
| 380 | }, | |
| 381 | { backgroundActivityProfile: "battery-saver" }, | |
| 382 | ), | |
| 383 | ), | |
| 384 | ), | |
| 385 | ); | |
| 386 | }); |
apps/server/src/background/BackgroundPolicy.ts created+349| ... | ... | @@ -0,0 +1,349 @@ |
| 1 | import { | |
| 2 | type AuthSessionId, | |
| 3 | type BackgroundPolicySnapshot, | |
| 4 | type BackgroundScope, | |
| 5 | type ClientActivityLease, | |
| 6 | type ClientActivityReportInput, | |
| 7 | type HostPowerSnapshot, | |
| 8 | type RpcClientId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { | |
| 11 | getBackgroundActivityPresetSettings, | |
| 12 | resolveServerBackgroundActivitySettings, | |
| 13 | type ResolvedBackgroundActivitySettings, | |
| 14 | } from "@t3tools/shared/backgroundActivitySettings"; | |
| 15 | import * as DateTime from "effect/DateTime"; | |
| 16 | import * as Context from "effect/Context"; | |
| 17 | import * as Effect from "effect/Effect"; | |
| 18 | import * as Layer from "effect/Layer"; | |
| 19 | import * as PubSub from "effect/PubSub"; | |
| 20 | import * as Ref from "effect/Ref"; | |
| 21 | import * as Scope from "effect/Scope"; | |
| 22 | import * as Semaphore from "effect/Semaphore"; | |
| 23 | import * as Stream from "effect/Stream"; | |
| 24 | ||
| 25 | import { ServerSettingsService } from "../serverSettings.ts"; | |
| 26 | import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts"; | |
| 27 | import * as HostPowerMonitor from "./HostPowerMonitor.ts"; | |
| 28 | ||
| 29 | export class BackgroundPolicy extends Context.Service< | |
| 30 | BackgroundPolicy, | |
| 31 | { | |
| 32 | readonly reportClientActivity: ( | |
| 33 | sessionId: AuthSessionId, | |
| 34 | rpcClientId: RpcClientId, | |
| 35 | input: ClientActivityReportInput, | |
| 36 | ) => Effect.Effect<void>; | |
| 37 | readonly removeRpcClient: ( | |
| 38 | sessionId: AuthSessionId, | |
| 39 | rpcClientId: RpcClientId, | |
| 40 | ) => Effect.Effect<void>; | |
| 41 | readonly reportHostPowerState: (snapshot: HostPowerSnapshot) => Effect.Effect<void>; | |
| 42 | readonly snapshot: Effect.Effect<BackgroundPolicySnapshot>; | |
| 43 | readonly streamChanges: Stream.Stream<BackgroundPolicySnapshot>; | |
| 44 | readonly subscribe: Effect.Effect< | |
| 45 | { | |
| 46 | readonly latest: BackgroundPolicySnapshot; | |
| 47 | readonly changes: Stream.Stream<BackgroundPolicySnapshot>; | |
| 48 | }, | |
| 49 | never, | |
| 50 | Scope.Scope | |
| 51 | >; | |
| 52 | readonly hasDemand: (scope: BackgroundScope) => Effect.Effect<boolean>; | |
| 53 | readonly shouldRunScopeWork: (scope: BackgroundScope) => Effect.Effect<boolean>; | |
| 54 | readonly shouldRunOpportunisticWork: Effect.Effect<boolean>; | |
| 55 | } | |
| 56 | >()("t3/background/BackgroundPolicy") {} | |
| 57 | ||
| 58 | const DEFAULT_LEASE_TTL_MS = 45_000; | |
| 59 | const MAX_LEASE_TTL_MS = 120_000; | |
| 60 | export const MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT = 16; | |
| 61 | ||
| 62 | function scopeKey(scope: BackgroundScope): string { | |
| 63 | switch (scope.type) { | |
| 64 | case "server-config": | |
| 65 | case "diagnostics": | |
| 66 | return scope.type; | |
| 67 | case "provider-status": | |
| 68 | return scope.instanceId ? `${scope.type}:${scope.instanceId}` : scope.type; | |
| 69 | case "vcs-status": | |
| 70 | case "git-refs": | |
| 71 | return `${scope.type}:${scope.cwd}`; | |
| 72 | case "thread": | |
| 73 | return `${scope.type}:${scope.threadId}`; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | function isLeaseActive(lease: ClientActivityLease, now: DateTime.Utc): boolean { | |
| 78 | return DateTime.isGreaterThan(lease.expiresAt, now); | |
| 79 | } | |
| 80 | ||
| 81 | function leaseKey(lease: Pick<ClientActivityLease, "sessionId" | "rpcClientId" | "clientId">) { | |
| 82 | return JSON.stringify([lease.sessionId, lease.rpcClientId, lease.clientId]); | |
| 83 | } | |
| 84 | ||
| 85 | export function upsertClientActivityLease( | |
| 86 | leases: ReadonlyMap<string, ClientActivityLease>, | |
| 87 | lease: ClientActivityLease, | |
| 88 | now: DateTime.Utc, | |
| 89 | ): Map<string, ClientActivityLease> { | |
| 90 | const next = new Map(leases); | |
| 91 | for (const [key, current] of next) { | |
| 92 | if (!isLeaseActive(current, now)) { | |
| 93 | next.delete(key); | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | const key = leaseKey(lease); | |
| 98 | if (!next.has(key)) { | |
| 99 | let connectionLeaseCount = 0; | |
| 100 | let oldestConnectionLease: | |
| 101 | | { | |
| 102 | readonly key: string; | |
| 103 | readonly updatedAtMs: number; | |
| 104 | } | |
| 105 | | undefined; | |
| 106 | for (const [currentKey, current] of next) { | |
| 107 | if (current.sessionId !== lease.sessionId || current.rpcClientId !== lease.rpcClientId) { | |
| 108 | continue; | |
| 109 | } | |
| 110 | connectionLeaseCount += 1; | |
| 111 | const updatedAtMs = DateTime.toEpochMillis(current.updatedAt); | |
| 112 | if (oldestConnectionLease === undefined || updatedAtMs < oldestConnectionLease.updatedAtMs) { | |
| 113 | oldestConnectionLease = { key: currentKey, updatedAtMs }; | |
| 114 | } | |
| 115 | } | |
| 116 | if ( | |
| 117 | connectionLeaseCount >= MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT && | |
| 118 | oldestConnectionLease !== undefined | |
| 119 | ) { | |
| 120 | next.delete(oldestConnectionLease.key); | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | next.set(key, lease); | |
| 125 | return next; | |
| 126 | } | |
| 127 | ||
| 128 | function isForegroundLease(lease: ClientActivityLease, now: DateTime.Utc): boolean { | |
| 129 | return isLeaseActive(lease, now) && lease.visible && (lease.focused || lease.recentlyInteracted); | |
| 130 | } | |
| 131 | ||
| 132 | function leaseHasScope(lease: ClientActivityLease, scope: BackgroundScope): boolean { | |
| 133 | const key = scopeKey(scope); | |
| 134 | return lease.scopes.some((leaseScope) => scopeKey(leaseScope) === key); | |
| 135 | } | |
| 136 | ||
| 137 | function hasThermalPressure(hostPower: HostPowerSnapshot): boolean { | |
| 138 | return hostPower.thermalState === "serious" || hostPower.thermalState === "critical"; | |
| 139 | } | |
| 140 | ||
| 141 | function isHostConstrained( | |
| 142 | hostPower: HostPowerSnapshot, | |
| 143 | settings: ResolvedBackgroundActivitySettings, | |
| 144 | ): boolean { | |
| 145 | if (hostPower.stale) return false; | |
| 146 | if ( | |
| 147 | hostPower.suspended || | |
| 148 | (settings.pauseWhenHostLocked && hostPower.locked === "true") || | |
| 149 | hasThermalPressure(hostPower) | |
| 150 | ) { | |
| 151 | return true; | |
| 152 | } | |
| 153 | if (settings.pauseWhenHostLowPower && hostPower.lowPowerMode === "true") return true; | |
| 154 | return settings.pauseWhenOnBattery && hostPower.onBattery === "true"; | |
| 155 | } | |
| 156 | ||
| 157 | function isClientConstrained( | |
| 158 | lease: ClientActivityLease, | |
| 159 | settings: ResolvedBackgroundActivitySettings, | |
| 160 | ): boolean { | |
| 161 | if (settings.pauseWhenClientLowPower && lease.lowPowerMode === "true") return true; | |
| 162 | return settings.pauseWhenOnBattery && lease.batteryState === "unplugged"; | |
| 163 | } | |
| 164 | ||
| 165 | function leaseMayRunScopedWork( | |
| 166 | lease: ClientActivityLease, | |
| 167 | scope: BackgroundScope, | |
| 168 | now: DateTime.Utc, | |
| 169 | settings: ResolvedBackgroundActivitySettings, | |
| 170 | ): boolean { | |
| 171 | const activeWithScope = isLeaseActive(lease, now) && leaseHasScope(lease, scope); | |
| 172 | if (!activeWithScope || isClientConstrained(lease, settings)) { | |
| 173 | return false; | |
| 174 | } | |
| 175 | if (settings.profile === "performance") { | |
| 176 | return true; | |
| 177 | } | |
| 178 | return isForegroundLease(lease, now); | |
| 179 | } | |
| 180 | ||
| 181 | function computeSnapshot(input: { | |
| 182 | readonly hostPower: HostPowerSnapshot; | |
| 183 | readonly leases: ReadonlyMap<string, ClientActivityLease>; | |
| 184 | readonly now: DateTime.Utc; | |
| 185 | readonly settings: ResolvedBackgroundActivitySettings; | |
| 186 | readonly updatedAt: DateTime.Utc; | |
| 187 | }): BackgroundPolicySnapshot { | |
| 188 | const activeLeases = [...input.leases.values()].filter((lease) => | |
| 189 | isLeaseActive(lease, input.now), | |
| 190 | ); | |
| 191 | const foregroundLeases = activeLeases.filter((lease) => isForegroundLease(lease, input.now)); | |
| 192 | const activeScopeKeys = new Set<string>(); | |
| 193 | for (const lease of activeLeases) { | |
| 194 | for (const scope of lease.scopes) { | |
| 195 | activeScopeKeys.add(scopeKey(scope)); | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | return { | |
| 200 | hostPower: input.hostPower, | |
| 201 | leases: activeLeases, | |
| 202 | activeForegroundLeaseCount: foregroundLeases.length, | |
| 203 | activeScopeKeys: [...activeScopeKeys].toSorted(), | |
| 204 | shouldRunOpportunisticWork: | |
| 205 | foregroundLeases.some((lease) => !isClientConstrained(lease, input.settings)) && | |
| 206 | !isHostConstrained(input.hostPower, input.settings), | |
| 207 | updatedAt: input.updatedAt, | |
| 208 | }; | |
| 209 | } | |
| 210 | ||
| 211 | export const make = Effect.fn("background.policy.make")(function* () { | |
| 212 | const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor; | |
| 213 | const serverSettings = yield* ServerSettingsService; | |
| 214 | const leasesRef = yield* Ref.make(new Map<string, ClientActivityLease>()); | |
| 215 | const changes = yield* PubSub.sliding<BackgroundPolicySnapshot>(1); | |
| 216 | const publishMutex = yield* Semaphore.make(1); | |
| 217 | ||
| 218 | const backgroundActivitySettings = serverSettings.getSettings.pipe( | |
| 219 | Effect.map(resolveServerBackgroundActivitySettings), | |
| 220 | Effect.orElseSucceed(() => getBackgroundActivityPresetSettings("balanced")), | |
| 221 | ); | |
| 222 | ||
| 223 | const snapshot = Effect.gen(function* () { | |
| 224 | const [hostPower, leases, now, settings] = yield* Effect.all([ | |
| 225 | hostPowerMonitor.snapshot, | |
| 226 | Ref.get(leasesRef), | |
| 227 | DateTime.now, | |
| 228 | backgroundActivitySettings, | |
| 229 | ]); | |
| 230 | return computeSnapshot({ hostPower, leases, now, settings, updatedAt: now }); | |
| 231 | }); | |
| 232 | ||
| 233 | const publishSnapshotUnlocked = snapshot.pipe( | |
| 234 | Effect.flatMap((next) => PubSub.publish(changes, next)), | |
| 235 | ); | |
| 236 | const publishSnapshot = publishMutex.withPermits(1)(publishSnapshotUnlocked); | |
| 237 | ||
| 238 | const reportClientActivity: BackgroundPolicy["Service"]["reportClientActivity"] = ( | |
| 239 | sessionId, | |
| 240 | rpcClientId, | |
| 241 | input, | |
| 242 | ) => | |
| 243 | publishMutex.withPermits(1)( | |
| 244 | Effect.gen(function* () { | |
| 245 | const ttlMs = Math.min( | |
| 246 | Math.max(input.ttlMs ?? DEFAULT_LEASE_TTL_MS, 1_000), | |
| 247 | MAX_LEASE_TTL_MS, | |
| 248 | ); | |
| 249 | const now = yield* DateTime.now; | |
| 250 | const expiresAt = DateTime.add(now, { milliseconds: ttlMs }); | |
| 251 | const lease: ClientActivityLease = { | |
| 252 | sessionId, | |
| 253 | rpcClientId, | |
| 254 | clientId: input.clientId, | |
| 255 | clientKind: input.clientKind, | |
| 256 | visible: input.visible, | |
| 257 | focused: input.focused, | |
| 258 | recentlyInteracted: input.recentlyInteracted, | |
| 259 | ...(input.appState !== undefined ? { appState: input.appState } : {}), | |
| 260 | ...(input.lowPowerMode !== undefined ? { lowPowerMode: input.lowPowerMode } : {}), | |
| 261 | ...(input.batteryState !== undefined ? { batteryState: input.batteryState } : {}), | |
| 262 | ...(input.networkType !== undefined ? { networkType: input.networkType } : {}), | |
| 263 | scopes: input.scopes, | |
| 264 | updatedAt: now, | |
| 265 | expiresAt, | |
| 266 | }; | |
| 267 | yield* Ref.update(leasesRef, (leases) => upsertClientActivityLease(leases, lease, now)); | |
| 268 | yield* publishSnapshotUnlocked; | |
| 269 | }), | |
| 270 | ); | |
| 271 | ||
| 272 | const removeRpcClient: BackgroundPolicy["Service"]["removeRpcClient"] = ( | |
| 273 | sessionId, | |
| 274 | rpcClientId, | |
| 275 | ) => | |
| 276 | publishMutex.withPermits(1)( | |
| 277 | Ref.update(leasesRef, (leases) => { | |
| 278 | const next = new Map(leases); | |
| 279 | for (const [key, lease] of next) { | |
| 280 | if (lease.sessionId === sessionId && lease.rpcClientId === rpcClientId) { | |
| 281 | next.delete(key); | |
| 282 | } | |
| 283 | } | |
| 284 | return next; | |
| 285 | }).pipe(Effect.andThen(publishSnapshotUnlocked), Effect.asVoid), | |
| 286 | ); | |
| 287 | ||
| 288 | const hasDemand: BackgroundPolicy["Service"]["hasDemand"] = (scope) => | |
| 289 | Effect.map(snapshot, (current) => current.activeScopeKeys.includes(scopeKey(scope))); | |
| 290 | ||
| 291 | const shouldRunScopeWork: BackgroundPolicy["Service"]["shouldRunScopeWork"] = (scope) => | |
| 292 | Effect.gen(function* () { | |
| 293 | const [current, settings] = yield* Effect.all([snapshot, backgroundActivitySettings]); | |
| 294 | if (isHostConstrained(current.hostPower, settings)) { | |
| 295 | return false; | |
| 296 | } | |
| 297 | return current.leases.some((lease) => | |
| 298 | leaseMayRunScopedWork(lease, scope, current.updatedAt, settings), | |
| 299 | ); | |
| 300 | }); | |
| 301 | ||
| 302 | const shouldRunOpportunisticWork = Effect.map( | |
| 303 | snapshot, | |
| 304 | (current) => current.shouldRunOpportunisticWork, | |
| 305 | ); | |
| 306 | ||
| 307 | yield* Stream.runForEach(hostPowerMonitor.streamChanges, () => publishSnapshot).pipe( | |
| 308 | Effect.forkScoped, | |
| 309 | ); | |
| 310 | yield* Stream.runForEach(serverSettings.streamChanges, () => publishSnapshot).pipe( | |
| 311 | Effect.forkScoped, | |
| 312 | ); | |
| 313 | ||
| 314 | yield* Effect.forever( | |
| 315 | Effect.sleep("15 seconds").pipe( | |
| 316 | Effect.andThen( | |
| 317 | publishMutex.withPermits(1)( | |
| 318 | Effect.gen(function* () { | |
| 319 | const now = yield* DateTime.now; | |
| 320 | yield* Ref.update(leasesRef, (leases) => { | |
| 321 | const next = new Map(leases); | |
| 322 | for (const [key, lease] of next) { | |
| 323 | if (!isLeaseActive(lease, now)) { | |
| 324 | next.delete(key); | |
| 325 | } | |
| 326 | } | |
| 327 | return next; | |
| 328 | }); | |
| 329 | yield* publishSnapshotUnlocked; | |
| 330 | }), | |
| 331 | ), | |
| 332 | ), | |
| 333 | ), | |
| 334 | ).pipe(Effect.forkScoped); | |
| 335 | ||
| 336 | return BackgroundPolicy.of({ | |
| 337 | reportClientActivity, | |
| 338 | removeRpcClient, | |
| 339 | reportHostPowerState: hostPowerMonitor.report, | |
| 340 | snapshot, | |
| 341 | streamChanges: Stream.fromPubSub(changes), | |
| 342 | subscribe: subscribeBeforeSnapshot(changes, snapshot, publishMutex), | |
| 343 | hasDemand, | |
| 344 | shouldRunScopeWork, | |
| 345 | shouldRunOpportunisticWork, | |
| 346 | }); | |
| 347 | }); | |
| 348 | ||
| 349 | export const layer = Layer.effect(BackgroundPolicy, make()); |
apps/server/src/background/HostPowerMonitor.test.ts created+188| ... | ... | @@ -0,0 +1,188 @@ |
| 1 | import type { DesktopHostTelemetrySnapshot } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as PubSub from "effect/PubSub"; | |
| 9 | import * as Ref from "effect/Ref"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 11 | ||
| 12 | import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; | |
| 13 | import * as HostPowerMonitor from "./HostPowerMonitor.ts"; | |
| 14 | ||
| 15 | describe("HostPowerMonitor", () => { | |
| 16 | it.effect("publishes semantic power changes without idle-time heartbeat churn", () => | |
| 17 | Effect.gen(function* () { | |
| 18 | const monitor = yield* HostPowerMonitor.make(); | |
| 19 | const initial = { | |
| 20 | source: "electron-main", | |
| 21 | idle: "false", | |
| 22 | idleSeconds: 0, | |
| 23 | locked: "false", | |
| 24 | suspended: false, | |
| 25 | onBattery: "false", | |
| 26 | lowPowerMode: "unknown", | |
| 27 | thermalState: "nominal", | |
| 28 | stale: false, | |
| 29 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), | |
| 30 | } as const; | |
| 31 | yield* monitor.report(initial); | |
| 32 | ||
| 33 | const nextChange = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild); | |
| 34 | yield* Effect.yieldNow; | |
| 35 | yield* monitor.report({ | |
| 36 | ...initial, | |
| 37 | idleSeconds: 1, | |
| 38 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"), | |
| 39 | }); | |
| 40 | yield* monitor.report({ | |
| 41 | ...initial, | |
| 42 | locked: "true", | |
| 43 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:02.000Z"), | |
| 44 | }); | |
| 45 | ||
| 46 | expect(Option.getOrThrow(yield* Fiber.join(nextChange)).locked).toBe("true"); | |
| 47 | }), | |
| 48 | ); | |
| 49 | ||
| 50 | it.effect("ignores host power reports older than the latest accepted snapshot", () => | |
| 51 | Effect.gen(function* () { | |
| 52 | const initial = { | |
| 53 | source: "electron-main", | |
| 54 | idle: "false", | |
| 55 | idleSeconds: 0, | |
| 56 | locked: "false", | |
| 57 | suspended: false, | |
| 58 | onBattery: "false", | |
| 59 | lowPowerMode: "false", | |
| 60 | thermalState: "nominal", | |
| 61 | stale: false, | |
| 62 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), | |
| 63 | } as const; | |
| 64 | const monitor = yield* HostPowerMonitor.make(initial); | |
| 65 | const latestAt = DateTime.makeUnsafe("2026-06-17T12:00:02.000Z"); | |
| 66 | yield* monitor.report({ | |
| 67 | ...initial, | |
| 68 | locked: "true", | |
| 69 | updatedAt: latestAt, | |
| 70 | }); | |
| 71 | yield* monitor.report({ | |
| 72 | ...initial, | |
| 73 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"), | |
| 74 | }); | |
| 75 | ||
| 76 | const snapshot = yield* monitor.snapshot; | |
| 77 | expect(snapshot.locked).toBe("true"); | |
| 78 | expect(DateTime.toEpochMillis(snapshot.updatedAt)).toBe(DateTime.toEpochMillis(latestAt)); | |
| 79 | }), | |
| 80 | ); | |
| 81 | ||
| 82 | it.effect("consumes desktop power directly without retaining diagnostics telemetry", () => | |
| 83 | Effect.gen(function* () { | |
| 84 | const sampledAt = DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"); | |
| 85 | const desktopChanges = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(1); | |
| 86 | const diagnosticsDemandWrites = yield* Ref.make(0); | |
| 87 | const receiverLayer = DesktopTelemetryReceiver.layerTest({ | |
| 88 | changes: Stream.fromPubSub(desktopChanges), | |
| 89 | setDiagnosticsDemand: () => Ref.update(diagnosticsDemandWrites, (count) => count + 1), | |
| 90 | }); | |
| 91 | const layer = HostPowerMonitor.layer.pipe(Layer.provide(receiverLayer)); | |
| 92 | ||
| 93 | yield* Effect.gen(function* () { | |
| 94 | const monitor = yield* HostPowerMonitor.HostPowerMonitor; | |
| 95 | const nextPower = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild); | |
| 96 | yield* Effect.yieldNow; | |
| 97 | yield* PubSub.publish(desktopChanges, { | |
| 98 | version: 1, | |
| 99 | type: "desktopTelemetry", | |
| 100 | sequence: 1, | |
| 101 | sampledAtUnixMs: DateTime.toEpochMillis(sampledAt), | |
| 102 | electronPid: 100, | |
| 103 | power: { | |
| 104 | source: "electron-main", | |
| 105 | idle: "false", | |
| 106 | idleSeconds: 0, | |
| 107 | locked: "false", | |
| 108 | suspended: false, | |
| 109 | onBattery: "true", | |
| 110 | lowPowerMode: "unknown", | |
| 111 | thermalState: "nominal", | |
| 112 | stale: false, | |
| 113 | updatedAt: sampledAt, | |
| 114 | }, | |
| 115 | speedLimitPercent: Option.none(), | |
| 116 | electronProcesses: [], | |
| 117 | }); | |
| 118 | ||
| 119 | expect(Option.getOrThrow(yield* Fiber.join(nextPower)).onBattery).toBe("true"); | |
| 120 | expect(yield* Ref.get(diagnosticsDemandWrites)).toBe(0); | |
| 121 | }).pipe(Effect.provide(layer)); | |
| 122 | }), | |
| 123 | ); | |
| 124 | ||
| 125 | it.effect( | |
| 126 | "subscribes before reading the desktop snapshot so concurrent power updates survive", | |
| 127 | () => | |
| 128 | Effect.gen(function* () { | |
| 129 | const sampledAt = DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"); | |
| 130 | const initial: DesktopHostTelemetrySnapshot = { | |
| 131 | version: 1, | |
| 132 | type: "desktopTelemetry", | |
| 133 | sequence: 1, | |
| 134 | sampledAtUnixMs: DateTime.toEpochMillis(sampledAt), | |
| 135 | electronPid: 100, | |
| 136 | power: { | |
| 137 | source: "electron-main", | |
| 138 | idle: "false", | |
| 139 | idleSeconds: 0, | |
| 140 | locked: "false", | |
| 141 | suspended: false, | |
| 142 | onBattery: "false", | |
| 143 | lowPowerMode: "unknown", | |
| 144 | thermalState: "nominal", | |
| 145 | stale: false, | |
| 146 | updatedAt: sampledAt, | |
| 147 | }, | |
| 148 | speedLimitPercent: Option.none(), | |
| 149 | electronProcesses: [], | |
| 150 | }; | |
| 151 | const updated: DesktopHostTelemetrySnapshot = { | |
| 152 | ...initial, | |
| 153 | sequence: 2, | |
| 154 | power: { | |
| 155 | ...initial.power, | |
| 156 | onBattery: "true", | |
| 157 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"), | |
| 158 | }, | |
| 159 | }; | |
| 160 | const desktopChanges = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(1); | |
| 161 | const receiverLayer = DesktopTelemetryReceiver.layerTest({ | |
| 162 | latest: Effect.succeedSome(initial), | |
| 163 | changes: Stream.empty, | |
| 164 | subscribe: Effect.gen(function* () { | |
| 165 | const subscription = yield* PubSub.subscribe(desktopChanges); | |
| 166 | yield* PubSub.publish(desktopChanges, updated); | |
| 167 | return { | |
| 168 | latest: Option.some(initial), | |
| 169 | changes: Stream.fromSubscription(subscription), | |
| 170 | }; | |
| 171 | }), | |
| 172 | }); | |
| 173 | const layer = HostPowerMonitor.layer.pipe(Layer.provide(receiverLayer)); | |
| 174 | ||
| 175 | yield* Effect.gen(function* () { | |
| 176 | const monitor = yield* HostPowerMonitor.HostPowerMonitor; | |
| 177 | for ( | |
| 178 | let attempt = 0; | |
| 179 | attempt < 100 && (yield* monitor.snapshot).onBattery !== "true"; | |
| 180 | attempt += 1 | |
| 181 | ) { | |
| 182 | yield* Effect.yieldNow; | |
| 183 | } | |
| 184 | expect((yield* monitor.snapshot).onBattery).toBe("true"); | |
| 185 | }).pipe(Effect.provide(layer)); | |
| 186 | }), | |
| 187 | ); | |
| 188 | }); |
apps/server/src/background/HostPowerMonitor.ts created+101| ... | ... | @@ -0,0 +1,101 @@ |
| 1 | import type { HostPowerSnapshot } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as PubSub from "effect/PubSub"; | |
| 8 | import * as Ref from "effect/Ref"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | ||
| 11 | import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; | |
| 12 | ||
| 13 | export class HostPowerMonitor extends Context.Service< | |
| 14 | HostPowerMonitor, | |
| 15 | { | |
| 16 | readonly snapshot: Effect.Effect<HostPowerSnapshot>; | |
| 17 | readonly report: (snapshot: HostPowerSnapshot) => Effect.Effect<void>; | |
| 18 | readonly streamChanges: Stream.Stream<HostPowerSnapshot>; | |
| 19 | } | |
| 20 | >()("t3/background/HostPowerMonitor") {} | |
| 21 | ||
| 22 | export const makeUnknownSnapshot = ( | |
| 23 | source: HostPowerSnapshot["source"], | |
| 24 | updatedAt: HostPowerSnapshot["updatedAt"], | |
| 25 | ): HostPowerSnapshot => ({ | |
| 26 | source, | |
| 27 | idle: "unknown", | |
| 28 | idleSeconds: null, | |
| 29 | locked: "unknown", | |
| 30 | suspended: false, | |
| 31 | onBattery: "unknown", | |
| 32 | lowPowerMode: "unknown", | |
| 33 | thermalState: "unknown", | |
| 34 | stale: true, | |
| 35 | updatedAt, | |
| 36 | }); | |
| 37 | ||
| 38 | function samePowerState(left: HostPowerSnapshot, right: HostPowerSnapshot): boolean { | |
| 39 | return ( | |
| 40 | left.source === right.source && | |
| 41 | left.idle === right.idle && | |
| 42 | left.locked === right.locked && | |
| 43 | left.suspended === right.suspended && | |
| 44 | left.onBattery === right.onBattery && | |
| 45 | left.lowPowerMode === right.lowPowerMode && | |
| 46 | left.thermalState === right.thermalState && | |
| 47 | left.stale === right.stale | |
| 48 | ); | |
| 49 | } | |
| 50 | ||
| 51 | export const make = Effect.fn("background.hostPower.make")(function* ( | |
| 52 | initialSnapshot?: HostPowerSnapshot, | |
| 53 | ) { | |
| 54 | const initial = initialSnapshot ?? makeUnknownSnapshot("unknown", yield* DateTime.now); | |
| 55 | const latestRef = yield* Ref.make(initial); | |
| 56 | const changes = yield* PubSub.sliding<HostPowerSnapshot>(1); | |
| 57 | ||
| 58 | const report: HostPowerMonitor["Service"]["report"] = (snapshot) => | |
| 59 | Ref.modify(latestRef, (current) => { | |
| 60 | if (DateTime.isLessThan(snapshot.updatedAt, current.updatedAt)) { | |
| 61 | return [Option.none<HostPowerSnapshot>(), current] as const; | |
| 62 | } | |
| 63 | return [ | |
| 64 | samePowerState(current, snapshot) ? Option.none() : Option.some(snapshot), | |
| 65 | snapshot, | |
| 66 | ] as const; | |
| 67 | }).pipe( | |
| 68 | Effect.flatMap( | |
| 69 | Option.match({ | |
| 70 | onNone: () => Effect.void, | |
| 71 | onSome: (next) => PubSub.publish(changes, next), | |
| 72 | }), | |
| 73 | ), | |
| 74 | Effect.asVoid, | |
| 75 | ); | |
| 76 | ||
| 77 | return HostPowerMonitor.of({ | |
| 78 | snapshot: Ref.get(latestRef), | |
| 79 | report, | |
| 80 | streamChanges: Stream.fromPubSub(changes), | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 84 | export const layer = Layer.effect( | |
| 85 | HostPowerMonitor, | |
| 86 | Effect.gen(function* () { | |
| 87 | const desktopTelemetry = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; | |
| 88 | const desktopSubscription = yield* desktopTelemetry.subscribe; | |
| 89 | const initial = desktopSubscription.latest; | |
| 90 | const monitor = yield* Option.match(initial, { | |
| 91 | onNone: () => make(), | |
| 92 | onSome: (snapshot) => make(snapshot.power), | |
| 93 | }); | |
| 94 | yield* desktopSubscription.changes.pipe( | |
| 95 | Stream.map((snapshot) => snapshot.power), | |
| 96 | Stream.runForEach(monitor.report), | |
| 97 | Effect.forkScoped, | |
| 98 | ); | |
| 99 | return monitor; | |
| 100 | }), | |
| 101 | ); |
apps/server/src/cli/config.test.ts+8-1| ... | ... | @@ -41,7 +41,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 41 | 41 | const defaultObservabilityConfig = { |
| 42 | 42 | traceMinLevel: "Info", |
| 43 | 43 | traceTimingEnabled: true, |
| 44 | traceBatchWindowMs: 200, | |
| 44 | traceBatchWindowMs: 1_000, | |
| 45 | 45 | traceMaxBytes: 10 * 1024 * 1024, |
| 46 | 46 | traceMaxFiles: 10, |
| 47 | 47 | otlpTracesUrl: undefined, |
| ... | ... | @@ -286,6 +286,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 286 | 286 | t3Home: baseDir, |
| 287 | 287 | noBrowser: true, |
| 288 | 288 | desktopBootstrapToken: "desktop-token", |
| 289 | desktopTelemetryFd: 4, | |
| 290 | desktopTelemetryControlFd: 5, | |
| 289 | 291 | tailscaleServeEnabled: false, |
| 290 | 292 | tailscaleServePort: 443, |
| 291 | 293 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| ... | ... | @@ -341,12 +343,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 341 | 343 | noBrowser: true, |
| 342 | 344 | startupPresentation: "browser", |
| 343 | 345 | desktopBootstrapToken: "desktop-token", |
| 346 | desktopTelemetryFd: 4, | |
| 347 | desktopTelemetryControlFd: 5, | |
| 348 | resourceMonitorPath: undefined, | |
| 344 | 349 | autoBootstrapProjectFromCwd: false, |
| 345 | 350 | logWebSocketEvents: false, |
| 346 | 351 | tailscaleServeEnabled: false, |
| 347 | 352 | tailscaleServePort: 443, |
| 348 | 353 | }); |
| 349 | 354 | assert.equal(join(baseDir, "userdata"), resolved.stateDir); |
| 355 | assert.equal(resolved.desktopTelemetryFd, 4); | |
| 356 | assert.equal(resolved.desktopTelemetryControlFd, 5); | |
| 350 | 357 | }), |
| 351 | 358 | ); |
| 352 | 359 |
apps/server/src/cli/config.ts+7-1| ... | ... | @@ -85,7 +85,7 @@ const EnvServerConfig = Config.all({ |
| 85 | 85 | ), |
| 86 | 86 | traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), |
| 87 | 87 | traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), |
| 88 | traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)), | |
| 88 | traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)), | |
| 89 | 89 | otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( |
| 90 | 90 | Config.option, |
| 91 | 91 | Config.map(Option.getOrUndefined), |
| ... | ... | @@ -303,6 +303,9 @@ export const resolveServerConfig = ( |
| 303 | 303 | () => mode === "desktop", |
| 304 | 304 | ); |
| 305 | 305 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; |
| 306 | const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; | |
| 307 | const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; | |
| 308 | const resourceMonitorPath = bootstrap?.resourceMonitorPath; | |
| 306 | 309 | const autoBootstrapProjectFromCwd = Option.getOrElse( |
| 307 | 310 | resolveOptionPrecedence( |
| 308 | 311 | Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), |
| ... | ... | @@ -376,6 +379,9 @@ export const resolveServerConfig = ( |
| 376 | 379 | noBrowser, |
| 377 | 380 | startupPresentation, |
| 378 | 381 | desktopBootstrapToken, |
| 382 | desktopTelemetryFd, | |
| 383 | desktopTelemetryControlFd, | |
| 384 | resourceMonitorPath, | |
| 379 | 385 | autoBootstrapProjectFromCwd, |
| 380 | 386 | logWebSocketEvents, |
| 381 | 387 | tailscaleServeEnabled, |
apps/server/src/config.ts+6| ... | ... | @@ -76,6 +76,9 @@ export class ServerConfig extends Context.Service< |
| 76 | 76 | readonly noBrowser: boolean; |
| 77 | 77 | readonly startupPresentation: StartupPresentation; |
| 78 | 78 | readonly desktopBootstrapToken: string | undefined; |
| 79 | readonly desktopTelemetryFd?: number | undefined; | |
| 80 | readonly desktopTelemetryControlFd?: number | undefined; | |
| 81 | readonly resourceMonitorPath?: string | undefined; | |
| 79 | 82 | readonly autoBootstrapProjectFromCwd: boolean; |
| 80 | 83 | readonly logWebSocketEvents: boolean; |
| 81 | 84 | readonly tailscaleServeEnabled: boolean; |
| ... | ... | @@ -186,6 +189,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
| 186 | 189 | port: 0, |
| 187 | 190 | host: undefined, |
| 188 | 191 | desktopBootstrapToken: undefined, |
| 192 | desktopTelemetryFd: undefined, | |
| 193 | desktopTelemetryControlFd: undefined, | |
| 194 | resourceMonitorPath: undefined, | |
| 189 | 195 | staticDir: undefined, |
| 190 | 196 | devUrl, |
| 191 | 197 | devAllowedOrigins: [], |
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+235-233| ... | ... | @@ -1,291 +1,293 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { | |
| 3 | DesktopHostTelemetrySnapshot, | |
| 4 | ResourceMonitorSnapshotEvent, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import * as DateTime from "effect/DateTime"; |
| 3 | 7 | import * as Effect from "effect/Effect"; |
| 4 | 8 | import * as Layer from "effect/Layer"; |
| 5 | 9 | import * as Option from "effect/Option"; |
| 6 | import * as Sink from "effect/Sink"; | |
| 7 | 10 | import * as Stream from "effect/Stream"; |
| 8 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 10 | 11 | |
| 12 | import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; | |
| 13 | import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; | |
| 14 | import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; | |
| 15 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; | |
| 11 | 16 | import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; |
| 12 | 17 | |
| 13 | const encoder = new TextEncoder(); | |
| 18 | function makeNativeSnapshot( | |
| 19 | processes: ResourceMonitorSnapshotEvent["processes"], | |
| 20 | ): ResourceMonitorSnapshotEvent { | |
| 21 | return { | |
| 22 | version: 2, | |
| 23 | type: "snapshot", | |
| 24 | sequence: 1, | |
| 25 | sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")), | |
| 26 | collectionDurationMicros: 250, | |
| 27 | scannedProcessCount: processes.length, | |
| 28 | retainedProcessCount: processes.length, | |
| 29 | inaccessibleProcessCount: 0, | |
| 30 | processes, | |
| 31 | }; | |
| 32 | } | |
| 14 | 33 | |
| 15 | function mockHandle(result: { | |
| 16 | readonly stdout?: string; | |
| 17 | readonly stderr?: string; | |
| 18 | readonly code?: number; | |
| 19 | }) { | |
| 20 | return ChildProcessSpawner.makeHandle({ | |
| 21 | pid: ChildProcessSpawner.ProcessId(1), | |
| 22 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), | |
| 23 | isRunning: Effect.succeed(false), | |
| 24 | kill: () => Effect.void, | |
| 25 | unref: Effect.succeed(Effect.void), | |
| 26 | stdin: Sink.drain, | |
| 27 | stdout: Stream.make(encoder.encode(result.stdout ?? "")), | |
| 28 | stderr: Stream.make(encoder.encode(result.stderr ?? "")), | |
| 29 | all: Stream.empty, | |
| 30 | getInputFd: () => Sink.drain, | |
| 31 | getOutputFd: () => Stream.empty, | |
| 34 | function makeTelemetryLayer( | |
| 35 | snapshot: ResourceMonitorSnapshotEvent, | |
| 36 | desktopSnapshot?: DesktopHostTelemetrySnapshot, | |
| 37 | ) { | |
| 38 | const nativeLayer = NativeTelemetryClient.layerTest({ | |
| 39 | sampleNow: Effect.succeed({ generation: 0, snapshot }), | |
| 40 | health: Effect.succeed({ | |
| 41 | status: "healthy", | |
| 42 | hello: Option.none(), | |
| 43 | lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)), | |
| 44 | lastError: Option.none(), | |
| 45 | restartCount: 0, | |
| 46 | sampleIntervalMs: 1_000, | |
| 47 | }), | |
| 32 | 48 | }); |
| 49 | const desktopLayer = desktopSnapshot | |
| 50 | ? DesktopTelemetryReceiver.layerTest({ | |
| 51 | latest: Effect.succeedSome(desktopSnapshot), | |
| 52 | health: Effect.succeed({ | |
| 53 | status: "healthy", | |
| 54 | lastSampleAt: Option.some(DateTime.makeUnsafe(desktopSnapshot.sampledAtUnixMs)), | |
| 55 | lastError: Option.none(), | |
| 56 | }), | |
| 57 | }) | |
| 58 | : DesktopTelemetryReceiver.layerTest(); | |
| 59 | return ResourceTelemetry.layer.pipe( | |
| 60 | Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), | |
| 61 | ); | |
| 33 | 62 | } |
| 34 | 63 | |
| 35 | 64 | describe("ProcessDiagnostics", () => { |
| 36 | it.effect("parses POSIX ps rows with full commands", () => | |
| 37 | Effect.sync(() => { | |
| 38 | const rows = ProcessDiagnostics.parsePosixProcessRows( | |
| 39 | [ | |
| 40 | " 10 1 10 Ss 0.0 1024 01:02.03 /usr/bin/node server.js", | |
| 41 | " 11 10 10 S+ 12.5 20480 00:04 codex app-server --config /tmp/one two", | |
| 42 | ].join("\n"), | |
| 43 | ); | |
| 44 | ||
| 45 | expect(rows).toEqual([ | |
| 65 | it.effect("projects live process data from resource telemetry", () => | |
| 66 | Effect.gen(function* () { | |
| 67 | const snapshot = makeNativeSnapshot([ | |
| 46 | 68 | { |
| 47 | pid: 10, | |
| 69 | pid: process.pid, | |
| 48 | 70 | ppid: 1, |
| 49 | pgid: 10, | |
| 50 | status: "Ss", | |
| 71 | startTimeMs: 1_000, | |
| 72 | runTimeMs: 60_000, | |
| 73 | name: "node", | |
| 74 | command: "t3 server", | |
| 75 | status: "Running", | |
| 51 | 76 | cpuPercent: 0, |
| 52 | rssBytes: 1024 * 1024, | |
| 53 | elapsed: "01:02.03", | |
| 54 | command: "/usr/bin/node server.js", | |
| 77 | cpuTimeMs: 100, | |
| 78 | residentBytes: 1_024, | |
| 79 | virtualBytes: 2_048, | |
| 80 | ioReadBytes: 100, | |
| 81 | ioWriteBytes: 200, | |
| 82 | ioSemantics: "storage", | |
| 55 | 83 | }, |
| 56 | 84 | { |
| 57 | pid: 11, | |
| 58 | ppid: 10, | |
| 59 | pgid: 10, | |
| 60 | status: "S+", | |
| 61 | cpuPercent: 12.5, | |
| 62 | rssBytes: 20480 * 1024, | |
| 63 | elapsed: "00:04", | |
| 64 | command: "codex app-server --config /tmp/one two", | |
| 85 | pid: 4_242, | |
| 86 | ppid: process.pid, | |
| 87 | startTimeMs: 2_000, | |
| 88 | runTimeMs: 4_000, | |
| 89 | name: "agent", | |
| 90 | command: "codex app-server", | |
| 91 | status: "Running", | |
| 92 | cpuPercent: 1.5, | |
| 93 | cpuTimeMs: 60, | |
| 94 | residentBytes: 2_048, | |
| 95 | virtualBytes: 4_096, | |
| 96 | ioReadBytes: 300, | |
| 97 | ioWriteBytes: 400, | |
| 98 | ioSemantics: "storage", | |
| 65 | 99 | }, |
| 66 | 100 | ]); |
| 67 | }), | |
| 68 | ); | |
| 69 | ||
| 70 | it.effect("aggregates only descendants of the server process", () => | |
| 71 | Effect.sync(() => { | |
| 72 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ | |
| 73 | serverPid: 100, | |
| 74 | readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), | |
| 75 | rows: [ | |
| 76 | { | |
| 77 | pid: 100, | |
| 78 | ppid: 1, | |
| 79 | pgid: 100, | |
| 80 | status: "S", | |
| 81 | cpuPercent: 0, | |
| 82 | rssBytes: 1_000, | |
| 83 | elapsed: "01:00", | |
| 84 | command: "t3 server", | |
| 85 | }, | |
| 86 | { | |
| 87 | pid: 101, | |
| 88 | ppid: 100, | |
| 89 | pgid: 100, | |
| 90 | status: "S", | |
| 91 | cpuPercent: 1.5, | |
| 92 | rssBytes: 2_000, | |
| 93 | elapsed: "00:20", | |
| 94 | command: "codex app-server", | |
| 95 | }, | |
| 96 | { | |
| 97 | pid: 102, | |
| 98 | ppid: 101, | |
| 99 | pgid: 100, | |
| 100 | status: "R", | |
| 101 | cpuPercent: 3.25, | |
| 102 | rssBytes: 4_000, | |
| 103 | elapsed: "00:05", | |
| 104 | command: "git status", | |
| 105 | }, | |
| 106 | { | |
| 107 | pid: 200, | |
| 108 | ppid: 1, | |
| 109 | pgid: 200, | |
| 110 | status: "S", | |
| 111 | cpuPercent: 99, | |
| 112 | rssBytes: 8_000, | |
| 113 | elapsed: "00:01", | |
| 114 | command: "unrelated", | |
| 115 | }, | |
| 116 | { | |
| 117 | pid: 201, | |
| 118 | ppid: 100, | |
| 119 | pgid: 100, | |
| 120 | status: "R", | |
| 121 | cpuPercent: 9, | |
| 122 | rssBytes: 9_000, | |
| 123 | elapsed: "00:00", | |
| 124 | command: "ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=", | |
| 125 | }, | |
| 126 | ], | |
| 127 | }); | |
| 101 | const telemetryLayer = makeTelemetryLayer(snapshot); | |
| 102 | const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer)); | |
| 128 | 103 | |
| 129 | expect(diagnostics.serverPid).toBe(100); | |
| 130 | expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z"); | |
| 131 | expect(diagnostics.processCount).toBe(2); | |
| 132 | expect(diagnostics.totalRssBytes).toBe(6_000); | |
| 133 | expect(diagnostics.totalCpuPercent).toBe(4.75); | |
| 134 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]); | |
| 135 | expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]); | |
| 136 | expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100); | |
| 137 | expect(diagnostics.processes[0]?.childPids).toEqual([102]); | |
| 138 | }), | |
| 139 | ); | |
| 104 | const diagnostics = yield* Effect.gen(function* () { | |
| 105 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; | |
| 106 | return yield* processDiagnostics.read; | |
| 107 | }).pipe(Effect.provide(layer)); | |
| 140 | 108 | |
| 141 | it.effect("preserves ascending sibling order for nested descendants", () => | |
| 142 | Effect.sync(() => { | |
| 143 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ | |
| 144 | serverPid: 100, | |
| 145 | readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), | |
| 146 | rows: [ | |
| 147 | { | |
| 148 | pid: 101, | |
| 149 | ppid: 100, | |
| 150 | pgid: 100, | |
| 151 | status: "S", | |
| 152 | cpuPercent: 0, | |
| 153 | rssBytes: 100, | |
| 154 | elapsed: "00:10", | |
| 155 | command: "agent", | |
| 156 | }, | |
| 157 | { | |
| 158 | pid: 103, | |
| 159 | ppid: 101, | |
| 160 | pgid: 100, | |
| 161 | status: "S", | |
| 162 | cpuPercent: 0, | |
| 163 | rssBytes: 100, | |
| 164 | elapsed: "00:10", | |
| 165 | command: "child-b", | |
| 166 | }, | |
| 167 | { | |
| 168 | pid: 102, | |
| 169 | ppid: 101, | |
| 170 | pgid: 100, | |
| 171 | status: "S", | |
| 172 | cpuPercent: 0, | |
| 173 | rssBytes: 100, | |
| 174 | elapsed: "00:10", | |
| 175 | command: "child-a", | |
| 176 | }, | |
| 177 | ], | |
| 178 | }); | |
| 179 | ||
| 180 | 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); | |
| 181 | 113 | }), |
| 182 | 114 | ); |
| 183 | 115 | |
| 184 | it.effect("queries processes through the ChildProcessSpawner service", () => | |
| 116 | it.effect("rejects stale process identities before signaling", () => | |
| 185 | 117 | Effect.gen(function* () { |
| 186 | const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = | |
| 187 | []; | |
| 188 | const spawnerLayer = Layer.succeed( | |
| 189 | ChildProcessSpawner.ChildProcessSpawner, | |
| 190 | ChildProcessSpawner.make((command) => { | |
| 191 | const childProcess = command as unknown as { | |
| 192 | readonly command: string; | |
| 193 | readonly args: ReadonlyArray<string>; | |
| 194 | }; | |
| 195 | commands.push({ command: childProcess.command, args: childProcess.args }); | |
| 196 | return Effect.succeed( | |
| 197 | mockHandle({ | |
| 198 | stdout: [ | |
| 199 | ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`, | |
| 200 | ` 4242 ${process.pid} ${process.pid} S 1.5 2048 00:04 agent`, | |
| 201 | ].join("\n"), | |
| 202 | }), | |
| 203 | ); | |
| 204 | }), | |
| 205 | ); | |
| 206 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer)); | |
| 118 | const snapshot = makeNativeSnapshot([]); | |
| 119 | const telemetryLayer = makeTelemetryLayer(snapshot); | |
| 120 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); | |
| 207 | 121 | |
| 208 | const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | |
| 209 | Effect.flatMap((pd) => pd.read), | |
| 122 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | |
| 123 | Effect.flatMap((processDiagnostics) => | |
| 124 | processDiagnostics.signal({ | |
| 125 | pid: 4_242, | |
| 126 | startTimeMs: 2_000, | |
| 127 | signal: "SIGINT", | |
| 128 | }), | |
| 129 | ), | |
| 210 | 130 | Effect.provide(layer), |
| 211 | 131 | ); |
| 212 | 132 | |
| 213 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); | |
| 214 | expect(commands).toEqual([ | |
| 215 | { | |
| 216 | command: "ps", | |
| 217 | args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="], | |
| 218 | }, | |
| 219 | ]); | |
| 133 | expect(result).toEqual({ | |
| 134 | pid: 4242, | |
| 135 | signal: "SIGINT", | |
| 136 | signaled: false, | |
| 137 | message: Option.some("Process 4242 no longer matches the selected process identity."), | |
| 138 | }); | |
| 220 | 139 | }), |
| 221 | 140 | ); |
| 222 | 141 | |
| 223 | it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () => | |
| 142 | it.effect("refuses to signal when a fresh identity check cannot be collected", () => | |
| 224 | 143 | Effect.gen(function* () { |
| 225 | const spawnerLayer = Layer.succeed( | |
| 226 | ChildProcessSpawner.ChildProcessSpawner, | |
| 227 | ChildProcessSpawner.make(() => | |
| 228 | Effect.succeed( | |
| 229 | mockHandle({ | |
| 230 | code: 17, | |
| 231 | stdout: "partial process output", | |
| 232 | stderr: "process access denied", | |
| 144 | const snapshot = makeNativeSnapshot([ | |
| 145 | { | |
| 146 | pid: 4_242, | |
| 147 | ppid: process.pid, | |
| 148 | startTimeMs: 2_000, | |
| 149 | runTimeMs: 4_000, | |
| 150 | name: "agent", | |
| 151 | command: "codex app-server", | |
| 152 | status: "Running", | |
| 153 | cpuPercent: 1.5, | |
| 154 | cpuTimeMs: 60, | |
| 155 | residentBytes: 2_048, | |
| 156 | virtualBytes: 4_096, | |
| 157 | ioReadBytes: 300, | |
| 158 | ioWriteBytes: 400, | |
| 159 | ioSemantics: "storage", | |
| 160 | }, | |
| 161 | ]); | |
| 162 | const staleTelemetry = yield* Effect.service(ResourceTelemetry.ResourceTelemetry).pipe( | |
| 163 | Effect.flatMap((telemetry) => telemetry.latest), | |
| 164 | Effect.provide(makeTelemetryLayer(snapshot)), | |
| 165 | ); | |
| 166 | const telemetryLayer = Layer.succeed( | |
| 167 | ResourceTelemetry.ResourceTelemetry, | |
| 168 | ResourceTelemetry.ResourceTelemetry.of({ | |
| 169 | latest: Effect.succeed(staleTelemetry), | |
| 170 | changes: Stream.empty, | |
| 171 | subscribe: Effect.die("unused"), | |
| 172 | readHistory: () => Effect.die("unused"), | |
| 173 | refresh: Effect.fail( | |
| 174 | new ResourceTelemetry.ResourceTelemetryRefreshFailed({ | |
| 175 | operation: "refresh", | |
| 176 | cause: new Error("collector unavailable"), | |
| 233 | 177 | }), |
| 234 | 178 | ), |
| 235 | ), | |
| 179 | validateProcessIdentity: () => Effect.die("unused"), | |
| 180 | retry: Effect.die("unused"), | |
| 181 | }), | |
| 236 | 182 | ); |
| 183 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); | |
| 237 | 184 | |
| 238 | const error = yield* ProcessDiagnostics.readProcessRows.pipe( | |
| 239 | Effect.provide(spawnerLayer), | |
| 240 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 241 | Effect.flip, | |
| 185 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | |
| 186 | Effect.flatMap((processDiagnostics) => | |
| 187 | processDiagnostics.signal({ | |
| 188 | pid: 4_242, | |
| 189 | startTimeMs: 2_000, | |
| 190 | signal: "SIGINT", | |
| 191 | }), | |
| 192 | ), | |
| 193 | Effect.provide(layer), | |
| 242 | 194 | ); |
| 243 | 195 | |
| 244 | expect(error).toMatchObject({ | |
| 245 | _tag: "ProcessDiagnosticsQueryFailedError", | |
| 246 | command: "ps", | |
| 247 | argCount: 2, | |
| 248 | cwd: process.cwd(), | |
| 249 | exitCode: 17, | |
| 250 | stdoutBytes: 22, | |
| 251 | stderrBytes: 21, | |
| 252 | stdoutTruncated: false, | |
| 253 | stderrTruncated: false, | |
| 196 | expect(result).toEqual({ | |
| 197 | pid: 4_242, | |
| 198 | signal: "SIGINT", | |
| 199 | signaled: false, | |
| 200 | message: Option.some( | |
| 201 | "Could not refresh process 4242; refusing to signal a stale identity.", | |
| 202 | ), | |
| 254 | 203 | }); |
| 255 | expect(error.message).toBe( | |
| 256 | `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, | |
| 257 | ); | |
| 258 | 204 | }), |
| 259 | 205 | ); |
| 260 | 206 | |
| 261 | it.effect("does not allow signaling the diagnostics query process", () => | |
| 207 | it.effect("rejects Electron processes as signal targets", () => | |
| 262 | 208 | Effect.gen(function* () { |
| 263 | const spawnerLayer = Layer.succeed( | |
| 264 | ChildProcessSpawner.ChildProcessSpawner, | |
| 265 | ChildProcessSpawner.make(() => | |
| 266 | Effect.succeed( | |
| 267 | mockHandle({ | |
| 268 | stdout: [ | |
| 269 | ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`, | |
| 270 | ` 4242 ${process.pid} ${process.pid} R 1.5 2048 00:00 ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=`, | |
| 271 | ].join("\n"), | |
| 272 | }), | |
| 273 | ), | |
| 274 | ), | |
| 209 | const sampledAtUnixMs = DateTime.toEpochMillis( | |
| 210 | DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), | |
| 275 | 211 | ); |
| 276 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer)); | |
| 212 | const snapshot = makeNativeSnapshot([ | |
| 213 | { | |
| 214 | pid: 4_242, | |
| 215 | ppid: 1, | |
| 216 | startTimeMs: 2_000, | |
| 217 | runTimeMs: 4_000, | |
| 218 | name: "electron", | |
| 219 | command: "electron", | |
| 220 | status: "Running", | |
| 221 | cpuPercent: 1.5, | |
| 222 | cpuTimeMs: 60, | |
| 223 | residentBytes: 2_048, | |
| 224 | virtualBytes: 4_096, | |
| 225 | ioReadBytes: 300, | |
| 226 | ioWriteBytes: 400, | |
| 227 | ioSemantics: "storage", | |
| 228 | }, | |
| 229 | ]); | |
| 230 | const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); | |
| 231 | const telemetryLayer = makeTelemetryLayer(snapshot, { | |
| 232 | version: 1, | |
| 233 | type: "desktopTelemetry", | |
| 234 | sequence: 1, | |
| 235 | sampledAtUnixMs, | |
| 236 | electronPid: 4_242, | |
| 237 | power: { | |
| 238 | source: "electron-main", | |
| 239 | idle: "false", | |
| 240 | idleSeconds: 0, | |
| 241 | locked: "false", | |
| 242 | suspended: false, | |
| 243 | onBattery: "false", | |
| 244 | lowPowerMode: "unknown", | |
| 245 | thermalState: "nominal", | |
| 246 | stale: false, | |
| 247 | updatedAt: sampledAt, | |
| 248 | }, | |
| 249 | speedLimitPercent: Option.none(), | |
| 250 | electronProcesses: [ | |
| 251 | { | |
| 252 | pid: 4_242, | |
| 253 | creationTimeMs: 2_000, | |
| 254 | type: "Browser", | |
| 255 | name: "electron", | |
| 256 | cpuPercent: 1.5, | |
| 257 | idleWakeupsPerSecond: 0, | |
| 258 | workingSetBytes: 2_048, | |
| 259 | peakWorkingSetBytes: 2_048, | |
| 260 | }, | |
| 261 | ], | |
| 262 | }); | |
| 263 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); | |
| 277 | 264 | |
| 278 | 265 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( |
| 279 | Effect.flatMap((pd) => pd.signal({ pid: 4242, signal: "SIGINT" })), | |
| 266 | Effect.flatMap((processDiagnostics) => | |
| 267 | processDiagnostics.signal({ | |
| 268 | pid: 4_242, | |
| 269 | startTimeMs: 2_000, | |
| 270 | signal: "SIGKILL", | |
| 271 | }), | |
| 272 | ), | |
| 280 | 273 | Effect.provide(layer), |
| 281 | 274 | ); |
| 282 | 275 | |
| 283 | 276 | expect(result).toEqual({ |
| 284 | pid: 4242, | |
| 285 | signal: "SIGINT", | |
| 277 | pid: 4_242, | |
| 278 | signal: "SIGKILL", | |
| 286 | 279 | signaled: false, |
| 287 | message: Option.some("Process 4242 is not a live descendant of the T3 server."), | |
| 280 | message: Option.some("Process 4242 is not a signalable T3 backend descendant."), | |
| 288 | 281 | }); |
| 282 | ||
| 283 | const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | |
| 284 | Effect.flatMap((processDiagnostics) => processDiagnostics.read), | |
| 285 | Effect.provide(layer), | |
| 286 | ); | |
| 287 | expect(diagnostics.processes).toEqual([]); | |
| 288 | expect(diagnostics.processCount).toBe(0); | |
| 289 | expect(diagnostics.totalCpuPercent).toBe(0); | |
| 290 | expect(diagnostics.totalRssBytes).toBe(0); | |
| 289 | 291 | }), |
| 290 | 292 | ); |
| 291 | 293 | }); |
apps/server/src/diagnostics/ProcessDiagnostics.ts+116-523| ... | ... | @@ -1,105 +1,20 @@ |
| 1 | 1 | import type { |
| 2 | ResourceTelemetryProcessCategory, | |
| 2 | 3 | ServerProcessDiagnosticsEntry, |
| 3 | 4 | ServerProcessDiagnosticsResult, |
| 4 | 5 | ServerProcessSignal, |
| 5 | 6 | ServerSignalProcessResult, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 8 | 8 | import * as Context from "effect/Context"; |
| 9 | import * as DateTime from "effect/DateTime"; | |
| 10 | import * as Duration from "effect/Duration"; | |
| 11 | 9 | import * as Effect from "effect/Effect"; |
| 12 | 10 | import * as Layer from "effect/Layer"; |
| 13 | 11 | import * as Option from "effect/Option"; |
| 14 | 12 | import * as Schema from "effect/Schema"; |
| 15 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | |
| 16 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 17 | 13 | |
| 18 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | |
| 14 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; | |
| 19 | 15 | |
| 20 | export interface ProcessRow { | |
| 21 | readonly pid: number; | |
| 22 | readonly ppid: number; | |
| 23 | readonly pgid: number | null; | |
| 24 | readonly status: string; | |
| 25 | readonly cpuPercent: number; | |
| 26 | readonly rssBytes: number; | |
| 27 | readonly elapsed: string; | |
| 28 | readonly command: string; | |
| 29 | } | |
| 30 | ||
| 31 | const PROCESS_QUERY_TIMEOUT_MS = 1_000; | |
| 32 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; | |
| 33 | const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; | |
| 34 | ||
| 35 | export class ProcessDiagnostics extends Context.Service< | |
| 36 | ProcessDiagnostics, | |
| 37 | { | |
| 38 | readonly read: Effect.Effect<ServerProcessDiagnosticsResult>; | |
| 39 | readonly signal: (input: { | |
| 40 | readonly pid: number; | |
| 41 | readonly signal: ServerProcessSignal; | |
| 42 | }) => Effect.Effect<ServerSignalProcessResult>; | |
| 43 | } | |
| 44 | >()("t3/diagnostics/ProcessDiagnostics") {} | |
| 45 | ||
| 46 | class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()( | |
| 47 | "ProcessDiagnosticsQueryTimeoutError", | |
| 48 | { | |
| 49 | command: Schema.String, | |
| 50 | argCount: Schema.Number, | |
| 51 | cwd: Schema.String, | |
| 52 | timeoutMillis: Schema.Number, | |
| 53 | }, | |
| 54 | ) { | |
| 55 | override get message(): string { | |
| 56 | return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryFailedError>()( | |
| 61 | "ProcessDiagnosticsQueryFailedError", | |
| 62 | { | |
| 63 | command: Schema.String, | |
| 64 | argCount: Schema.Number, | |
| 65 | cwd: Schema.String, | |
| 66 | exitCode: Schema.optional(Schema.Number), | |
| 67 | stdoutBytes: Schema.optional(Schema.Number), | |
| 68 | stderrBytes: Schema.optional(Schema.Number), | |
| 69 | stdoutTruncated: Schema.optional(Schema.Boolean), | |
| 70 | stderrTruncated: Schema.optional(Schema.Boolean), | |
| 71 | cause: Schema.optional(Schema.Defect()), | |
| 72 | }, | |
| 73 | ) { | |
| 74 | override get message(): string { | |
| 75 | const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`; | |
| 76 | return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`; | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass<ProcessDiagnosticsServerProcessSignalError>()( | |
| 81 | "ProcessDiagnosticsServerProcessSignalError", | |
| 82 | { pid: Schema.Number }, | |
| 83 | ) { | |
| 84 | override get message(): string { | |
| 85 | return "Refusing to signal the T3 server process."; | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()( | |
| 90 | "ProcessDiagnosticsNotDescendantError", | |
| 91 | { | |
| 92 | pid: Schema.Number, | |
| 93 | serverPid: Schema.Number, | |
| 94 | }, | |
| 95 | ) { | |
| 96 | override get message(): string { | |
| 97 | return `Process ${this.pid} is not a live descendant of the T3 server.`; | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsSignalFailedError>()( | |
| 102 | "ProcessDiagnosticsSignalFailedError", | |
| 16 | export class ProcessSignalFailed extends Schema.TaggedErrorClass<ProcessSignalFailed>()( | |
| 17 | "ProcessSignalFailed", | |
| 103 | 18 | { |
| 104 | 19 | pid: Schema.Number, |
| 105 | 20 | signal: Schema.String, |
| ... | ... | @@ -111,458 +26,136 @@ class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<Proces |
| 111 | 26 | } |
| 112 | 27 | } |
| 113 | 28 | |
| 114 | const ProcessDiagnosticsError = Schema.Union([ | |
| 115 | ProcessDiagnosticsQueryTimeoutError, | |
| 116 | ProcessDiagnosticsQueryFailedError, | |
| 117 | ProcessDiagnosticsServerProcessSignalError, | |
| 118 | ProcessDiagnosticsNotDescendantError, | |
| 119 | ProcessDiagnosticsSignalFailedError, | |
| 120 | ]); | |
| 121 | type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; | |
| 122 | const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); | |
| 123 | ||
| 124 | function parsePositiveInt(value: string): number | null { | |
| 125 | const parsed = Number.parseInt(value, 10); | |
| 126 | return Number.isInteger(parsed) && parsed > 0 ? parsed : null; | |
| 127 | } | |
| 128 | ||
| 129 | function parseNonNegativeInt(value: string): number | null { | |
| 130 | const parsed = Number.parseInt(value, 10); | |
| 131 | return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; | |
| 132 | } | |
| 133 | ||
| 134 | function parseNumber(value: string): number | null { | |
| 135 | const parsed = Number.parseFloat(value); | |
| 136 | return Number.isFinite(parsed) ? parsed : null; | |
| 137 | } | |
| 138 | ||
| 139 | export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> { | |
| 140 | const rows: ProcessRow[] = []; | |
| 141 | const rowPattern = | |
| 142 | /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+([+-]?(?:\d+\.?\d*|\.\d+))\s+(\d+)\s+(\S+)\s+(.+)$/; | |
| 143 | ||
| 144 | for (const line of output.split(/\r?\n/)) { | |
| 145 | if (line.trim().length === 0) continue; | |
| 146 | ||
| 147 | const match = rowPattern.exec(line); | |
| 148 | if (!match) continue; | |
| 149 | ||
| 150 | const pidText = match[1]; | |
| 151 | const ppidText = match[2]; | |
| 152 | const pgidText = match[3]; | |
| 153 | const status = match[4]; | |
| 154 | const cpuText = match[5]; | |
| 155 | const rssText = match[6]; | |
| 156 | const elapsed = match[7]; | |
| 157 | const command = match[8]; | |
| 158 | if ( | |
| 159 | pidText === undefined || | |
| 160 | ppidText === undefined || | |
| 161 | pgidText === undefined || | |
| 162 | status === undefined || | |
| 163 | cpuText === undefined || | |
| 164 | rssText === undefined || | |
| 165 | elapsed === undefined || | |
| 166 | command === undefined | |
| 167 | ) { | |
| 168 | continue; | |
| 169 | } | |
| 170 | ||
| 171 | const pid = parsePositiveInt(pidText); | |
| 172 | const ppid = parseNonNegativeInt(ppidText); | |
| 173 | const pgid = Number.parseInt(pgidText, 10); | |
| 174 | const cpuPercent = parseNumber(cpuText); | |
| 175 | const rssKiB = parseNonNegativeInt(rssText); | |
| 176 | if ( | |
| 177 | pid === null || | |
| 178 | ppid === null || | |
| 179 | !Number.isInteger(pgid) || | |
| 180 | cpuPercent === null || | |
| 181 | rssKiB === null || | |
| 182 | !status || | |
| 183 | !elapsed || | |
| 184 | !command | |
| 185 | ) { | |
| 186 | continue; | |
| 187 | } | |
| 188 | ||
| 189 | rows.push({ | |
| 190 | pid, | |
| 191 | ppid, | |
| 192 | pgid, | |
| 193 | status, | |
| 194 | cpuPercent, | |
| 195 | rssBytes: rssKiB * 1024, | |
| 196 | elapsed, | |
| 197 | command, | |
| 198 | }); | |
| 199 | } | |
| 200 | ||
| 201 | return rows; | |
| 202 | } | |
| 203 | ||
| 204 | function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { | |
| 205 | if (typeof value !== "object" || value === null) return null; | |
| 206 | const record = value as Record<string, unknown>; | |
| 207 | const pid = typeof record.ProcessId === "number" ? record.ProcessId : null; | |
| 208 | const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null; | |
| 209 | const commandLine = | |
| 210 | typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0 | |
| 211 | ? record.CommandLine | |
| 212 | : typeof record.Name === "string" | |
| 213 | ? record.Name | |
| 214 | : null; | |
| 215 | const workingSet = | |
| 216 | typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) | |
| 217 | ? Math.max(0, Math.round(record.WorkingSetSize)) | |
| 218 | : 0; | |
| 219 | const cpuPercent = | |
| 220 | typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) | |
| 221 | ? Math.max(0, record.PercentProcessorTime) | |
| 222 | : 0; | |
| 223 | ||
| 224 | if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; | |
| 225 | return { | |
| 226 | pid, | |
| 227 | ppid, | |
| 228 | pgid: null, | |
| 229 | status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", | |
| 230 | cpuPercent, | |
| 231 | rssBytes: workingSet, | |
| 232 | elapsed: "", | |
| 233 | command: commandLine, | |
| 234 | }; | |
| 235 | } | |
| 236 | ||
| 237 | function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { | |
| 238 | if (output.trim().length === 0) return []; | |
| 239 | try { | |
| 240 | const parsed = JSON.parse(output) as unknown; | |
| 241 | const records = Array.isArray(parsed) ? parsed : [parsed]; | |
| 242 | return records.flatMap((record) => { | |
| 243 | const row = normalizeWindowsProcessRow(record); | |
| 244 | return row ? [row] : []; | |
| 245 | }); | |
| 246 | } catch { | |
| 247 | return []; | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | export function buildDescendantEntries( | |
| 252 | rows: ReadonlyArray<ProcessRow>, | |
| 253 | serverPid: number, | |
| 254 | ): ReadonlyArray<ServerProcessDiagnosticsEntry> { | |
| 255 | const childrenByParent = new Map<number, ProcessRow[]>(); | |
| 256 | for (const row of rows) { | |
| 257 | const children = childrenByParent.get(row.ppid) ?? []; | |
| 258 | children.push(row); | |
| 259 | childrenByParent.set(row.ppid, children); | |
| 260 | } | |
| 261 | ||
| 262 | const entries: ServerProcessDiagnosticsEntry[] = []; | |
| 263 | const visited = new Set<number>(); | |
| 264 | const stack = [...(childrenByParent.get(serverPid) ?? [])] | |
| 265 | .toSorted((left, right) => left.pid - right.pid) | |
| 266 | .map((row) => ({ row, depth: 0 })); | |
| 267 | ||
| 268 | while (stack.length > 0) { | |
| 269 | const item = stack.shift(); | |
| 270 | if (!item || visited.has(item.row.pid)) continue; | |
| 271 | visited.add(item.row.pid); | |
| 272 | ||
| 273 | const children = [...(childrenByParent.get(item.row.pid) ?? [])].toSorted( | |
| 274 | (left, right) => left.pid - right.pid, | |
| 275 | ); | |
| 276 | entries.push({ | |
| 277 | pid: item.row.pid, | |
| 278 | ppid: item.row.ppid, | |
| 279 | pgid: Option.fromNullishOr(item.row.pgid), | |
| 280 | status: item.row.status, | |
| 281 | cpuPercent: item.row.cpuPercent, | |
| 282 | rssBytes: item.row.rssBytes, | |
| 283 | elapsed: item.row.elapsed || "n/a", | |
| 284 | command: item.row.command, | |
| 285 | depth: item.depth, | |
| 286 | childPids: children.map((child) => child.pid), | |
| 287 | }); | |
| 288 | ||
| 289 | stack.unshift(...children.map((row) => ({ row, depth: item.depth + 1 }))); | |
| 29 | export class ProcessDiagnostics extends Context.Service< | |
| 30 | ProcessDiagnostics, | |
| 31 | { | |
| 32 | readonly read: Effect.Effect<ServerProcessDiagnosticsResult>; | |
| 33 | readonly signal: (input: { | |
| 34 | readonly pid: number; | |
| 35 | readonly startTimeMs: number; | |
| 36 | readonly signal: ServerProcessSignal; | |
| 37 | }) => Effect.Effect<ServerSignalProcessResult>; | |
| 290 | 38 | } |
| 39 | >()("t3/diagnostics/ProcessDiagnostics") {} | |
| 291 | 40 | |
| 292 | return entries; | |
| 41 | function formatElapsed(runTimeMs: number): string { | |
| 42 | const totalSeconds = Math.max(0, Math.floor(runTimeMs / 1_000)); | |
| 43 | const hours = Math.floor(totalSeconds / 3_600); | |
| 44 | const minutes = Math.floor((totalSeconds % 3_600) / 60); | |
| 45 | const seconds = totalSeconds % 60; | |
| 46 | return hours > 0 | |
| 47 | ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` | |
| 48 | : `${minutes}:${String(seconds).padStart(2, "0")}`; | |
| 293 | 49 | } |
| 294 | 50 | |
| 295 | export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean { | |
| 296 | if (row.ppid !== serverPid) return false; | |
| 297 | ||
| 298 | const command = row.command.trim(); | |
| 51 | function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean { | |
| 299 | 52 | return ( |
| 300 | /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) || | |
| 301 | (/\bpowershell(?:\.exe)?\b/i.test(command) && | |
| 302 | /\bGet-CimInstance\s+Win32_Process\b/i.test(command)) | |
| 53 | category === "server-child" || category === "provider-root" || category === "terminal-root" | |
| 303 | 54 | ); |
| 304 | 55 | } |
| 305 | 56 | |
| 306 | function makeResult(input: { | |
| 307 | readonly serverPid: number; | |
| 308 | readonly rows: ReadonlyArray<ProcessRow>; | |
| 309 | readonly readAt: DateTime.Utc; | |
| 310 | readonly error?: string; | |
| 311 | }): ServerProcessDiagnosticsResult { | |
| 312 | const readAt = input.readAt; | |
| 313 | const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); | |
| 314 | const processes = buildDescendantEntries(rows, input.serverPid); | |
| 315 | const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0); | |
| 316 | const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0); | |
| 317 | ||
| 318 | return { | |
| 319 | serverPid: input.serverPid, | |
| 320 | readAt, | |
| 321 | processCount: processes.length, | |
| 322 | totalRssBytes, | |
| 323 | totalCpuPercent, | |
| 324 | processes, | |
| 325 | error: input.error ? Option.some({ message: input.error }) : Option.none(), | |
| 326 | }; | |
| 327 | } | |
| 328 | ||
| 329 | interface ProcessOutput { | |
| 330 | readonly cwd: string; | |
| 331 | readonly exitCode: number; | |
| 332 | readonly stdout: string; | |
| 333 | readonly stdoutBytes: number; | |
| 334 | readonly stdoutTruncated: boolean; | |
| 335 | readonly stderr: string; | |
| 336 | readonly stderrBytes: number; | |
| 337 | readonly stderrTruncated: boolean; | |
| 338 | } | |
| 339 | ||
| 340 | const runProcess = Effect.fn("runProcess")(function* (input: { | |
| 341 | readonly command: string; | |
| 342 | readonly args: ReadonlyArray<string>; | |
| 343 | }) { | |
| 344 | const cwd = process.cwd(); | |
| 345 | return yield* Effect.gen(function* () { | |
| 346 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 347 | // `ps` and `powershell.exe` are real executables; spawning through cmd.exe | |
| 348 | // shell mode would re-tokenize the PowerShell `-Command` payload (which | |
| 349 | // contains pipes) before PowerShell ever sees it. | |
| 350 | const child = yield* spawner.spawn( | |
| 351 | ChildProcess.make(input.command, input.args, { | |
| 352 | cwd, | |
| 353 | }), | |
| 354 | ); | |
| 355 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 356 | [ | |
| 357 | collectUint8StreamText({ | |
| 358 | stream: child.stdout, | |
| 359 | maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, | |
| 360 | truncatedMarker: "\n\n[truncated]", | |
| 361 | }), | |
| 362 | collectUint8StreamText({ | |
| 363 | stream: child.stderr, | |
| 364 | maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, | |
| 365 | truncatedMarker: "\n\n[truncated]", | |
| 366 | }), | |
| 367 | child.exitCode, | |
| 368 | ], | |
| 369 | { concurrency: "unbounded" }, | |
| 370 | ); | |
| 371 | ||
| 372 | return { | |
| 373 | cwd, | |
| 374 | exitCode, | |
| 375 | stdout: stdout.text, | |
| 376 | stdoutBytes: stdout.bytes, | |
| 377 | stdoutTruncated: stdout.truncated, | |
| 378 | stderr: stderr.text, | |
| 379 | stderrBytes: stderr.bytes, | |
| 380 | stderrTruncated: stderr.truncated, | |
| 381 | } satisfies ProcessOutput; | |
| 382 | }).pipe( | |
| 383 | Effect.scoped, | |
| 384 | Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), | |
| 385 | Effect.flatMap((result) => | |
| 386 | Option.match(result, { | |
| 387 | onNone: () => | |
| 388 | Effect.fail( | |
| 389 | new ProcessDiagnosticsQueryTimeoutError({ | |
| 390 | command: input.command, | |
| 391 | argCount: input.args.length, | |
| 392 | cwd, | |
| 393 | timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, | |
| 394 | }), | |
| 395 | ), | |
| 396 | onSome: Effect.succeed, | |
| 397 | }), | |
| 398 | ), | |
| 399 | Effect.mapError((cause) => | |
| 400 | isProcessDiagnosticsError(cause) | |
| 401 | ? cause | |
| 402 | : new ProcessDiagnosticsQueryFailedError({ | |
| 403 | command: input.command, | |
| 404 | argCount: input.args.length, | |
| 405 | cwd, | |
| 406 | cause, | |
| 57 | export const make = Effect.fn("makeProcessDiagnostics")(function* () { | |
| 58 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 59 | const refreshedTelemetry = telemetry.refresh.pipe(Effect.catch(() => telemetry.latest)); | |
| 60 | const read: ProcessDiagnostics["Service"]["read"] = refreshedTelemetry.pipe( | |
| 61 | Effect.map((snapshot) => { | |
| 62 | const processes = snapshot.processes | |
| 63 | .filter((entry) => canSignalCategory(entry.category)) | |
| 64 | .map( | |
| 65 | (entry): ServerProcessDiagnosticsEntry => ({ | |
| 66 | pid: entry.identity.pid, | |
| 67 | startTimeMs: entry.identity.startTimeMs, | |
| 68 | ppid: entry.ppid, | |
| 69 | pgid: Option.none(), | |
| 70 | status: entry.status || "Unknown", | |
| 71 | cpuPercent: entry.cpuPercent, | |
| 72 | rssBytes: entry.residentBytes, | |
| 73 | elapsed: formatElapsed(entry.runTimeMs), | |
| 74 | command: entry.command || entry.name || "unknown", | |
| 75 | depth: Math.max(0, entry.depth - 1), | |
| 76 | childPids: entry.childPids, | |
| 407 | 77 | }), |
| 408 | ), | |
| 409 | ); | |
| 410 | }); | |
| 411 | ||
| 412 | function readPosixProcessRows(): Effect.Effect< | |
| 413 | ReadonlyArray<ProcessRow>, | |
| 414 | ProcessDiagnosticsError, | |
| 415 | ChildProcessSpawner.ChildProcessSpawner | |
| 416 | > { | |
| 417 | return runProcess({ | |
| 418 | command: "ps", | |
| 419 | args: ["-axo", POSIX_PROCESS_QUERY_COMMAND], | |
| 420 | }).pipe( | |
| 421 | Effect.flatMap((result) => | |
| 422 | result.exitCode !== 0 | |
| 423 | ? Effect.fail( | |
| 424 | new ProcessDiagnosticsQueryFailedError({ | |
| 425 | command: "ps", | |
| 426 | argCount: 2, | |
| 427 | cwd: result.cwd, | |
| 428 | exitCode: result.exitCode, | |
| 429 | stdoutBytes: result.stdoutBytes, | |
| 430 | stderrBytes: result.stderrBytes, | |
| 431 | stdoutTruncated: result.stdoutTruncated, | |
| 432 | stderrTruncated: result.stderrTruncated, | |
| 433 | }), | |
| 434 | ) | |
| 435 | : Effect.succeed(parsePosixProcessRows(result.stdout)), | |
| 436 | ), | |
| 437 | ); | |
| 438 | } | |
| 439 | ||
| 440 | function readWindowsProcessRows(): Effect.Effect< | |
| 441 | ReadonlyArray<ProcessRow>, | |
| 442 | ProcessDiagnosticsError, | |
| 443 | ChildProcessSpawner.ChildProcessSpawner | |
| 444 | > { | |
| 445 | const command = [ | |
| 446 | "$processes = Get-CimInstance Win32_Process | ForEach-Object {", | |
| 447 | '$perf = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess = $($_.ProcessId)" -ErrorAction SilentlyContinue;', | |
| 448 | "[pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; Name = $_.Name; CommandLine = $_.CommandLine; Status = $_.Status; WorkingSetSize = $_.WorkingSetSize; PercentProcessorTime = if ($perf) { $perf.PercentProcessorTime } else { 0 } }", | |
| 449 | "};", | |
| 450 | "$processes | ConvertTo-Json -Compress -Depth 3", | |
| 451 | ].join(" "); | |
| 452 | ||
| 453 | return runProcess({ | |
| 454 | command: "powershell.exe", | |
| 455 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 456 | }).pipe( | |
| 457 | Effect.flatMap((result) => | |
| 458 | result.exitCode !== 0 | |
| 459 | ? Effect.fail( | |
| 460 | new ProcessDiagnosticsQueryFailedError({ | |
| 461 | command: "powershell.exe", | |
| 462 | argCount: 4, | |
| 463 | cwd: result.cwd, | |
| 464 | exitCode: result.exitCode, | |
| 465 | stdoutBytes: result.stdoutBytes, | |
| 466 | stderrBytes: result.stderrBytes, | |
| 467 | stdoutTruncated: result.stdoutTruncated, | |
| 468 | stderrTruncated: result.stderrTruncated, | |
| 469 | }), | |
| 470 | ) | |
| 471 | : Effect.succeed(parseWindowsProcessRows(result.stdout)), | |
| 472 | ), | |
| 473 | ); | |
| 474 | } | |
| 475 | ||
| 476 | export const readProcessRows = Effect.gen(function* () { | |
| 477 | const platform = yield* HostProcessPlatform; | |
| 478 | return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); | |
| 479 | }); | |
| 480 | ||
| 481 | export function aggregateProcessDiagnostics(input: { | |
| 482 | readonly serverPid: number; | |
| 483 | readonly rows: ReadonlyArray<ProcessRow>; | |
| 484 | readonly readAt: DateTime.Utc; | |
| 485 | }): ServerProcessDiagnosticsResult { | |
| 486 | return makeResult(input); | |
| 487 | } | |
| 488 | ||
| 489 | function assertDescendantPid( | |
| 490 | pid: number, | |
| 491 | ): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> { | |
| 492 | if (pid === process.pid) { | |
| 493 | return Effect.fail( | |
| 494 | new ProcessDiagnosticsServerProcessSignalError({ | |
| 495 | pid, | |
| 496 | }), | |
| 497 | ); | |
| 498 | } | |
| 499 | ||
| 500 | return readProcessRows.pipe( | |
| 501 | Effect.flatMap((rows) => { | |
| 502 | const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); | |
| 503 | const descendant = buildDescendantEntries(filteredRows, process.pid).some( | |
| 504 | (entry) => entry.pid === pid, | |
| 505 | ); | |
| 506 | return descendant | |
| 507 | ? Effect.void | |
| 508 | : Effect.fail( | |
| 509 | new ProcessDiagnosticsNotDescendantError({ | |
| 510 | pid, | |
| 511 | serverPid: process.pid, | |
| 512 | }), | |
| 513 | ); | |
| 78 | ); | |
| 79 | return { | |
| 80 | serverPid: process.pid, | |
| 81 | readAt: snapshot.readAt, | |
| 82 | processCount: processes.length, | |
| 83 | totalRssBytes: processes.reduce((total, entry) => total + entry.rssBytes, 0), | |
| 84 | totalCpuPercent: processes.reduce((total, entry) => total + entry.cpuPercent, 0), | |
| 85 | processes, | |
| 86 | error: Option.map(snapshot.health.native.lastError, (message) => ({ message })), | |
| 87 | }; | |
| 514 | 88 | }), |
| 515 | 89 | ); |
| 516 | } | |
| 517 | ||
| 518 | export const make = Effect.gen(function* () { | |
| 519 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 520 | ||
| 521 | const read: ProcessDiagnostics["Service"]["read"] = Effect.gen(function* () { | |
| 522 | const readAt = yield* DateTime.now; | |
| 523 | const rows = yield* readProcessRows.pipe( | |
| 524 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 525 | ); | |
| 526 | return makeResult({ serverPid: process.pid, rows, readAt }); | |
| 527 | }).pipe( | |
| 528 | Effect.catch((error: ProcessDiagnosticsError) => | |
| 529 | DateTime.now.pipe( | |
| 530 | Effect.map((readAt) => | |
| 531 | makeResult({ serverPid: process.pid, rows: [], readAt, error: error.message }), | |
| 532 | ), | |
| 533 | ), | |
| 534 | ), | |
| 535 | ); | |
| 536 | 90 | |
| 537 | 91 | const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")( |
| 538 | 92 | function* (input) { |
| 539 | return yield* assertDescendantPid(input.pid).pipe( | |
| 540 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 541 | Effect.flatMap(() => | |
| 542 | Effect.try({ | |
| 543 | try: () => { | |
| 544 | process.kill(input.pid, input.signal); | |
| 545 | return { | |
| 546 | pid: input.pid, | |
| 547 | signal: input.signal, | |
| 548 | signaled: true, | |
| 549 | message: Option.none(), | |
| 550 | }; | |
| 551 | }, | |
| 552 | catch: (cause) => | |
| 553 | new ProcessDiagnosticsSignalFailedError({ | |
| 554 | pid: input.pid, | |
| 555 | signal: input.signal, | |
| 556 | cause, | |
| 557 | }), | |
| 93 | if (input.pid === process.pid) { | |
| 94 | return { | |
| 95 | pid: input.pid, | |
| 96 | signal: input.signal, | |
| 97 | signaled: false, | |
| 98 | message: Option.some("Refusing to signal the T3 server process."), | |
| 99 | }; | |
| 100 | } | |
| 101 | const current = yield* telemetry.refresh.pipe(Effect.option); | |
| 102 | if (Option.isNone(current)) { | |
| 103 | return { | |
| 104 | pid: input.pid, | |
| 105 | signal: input.signal, | |
| 106 | signaled: false, | |
| 107 | message: Option.some( | |
| 108 | `Could not refresh process ${input.pid}; refusing to signal a stale identity.`, | |
| 109 | ), | |
| 110 | }; | |
| 111 | } | |
| 112 | const selected = current.value.processes.find( | |
| 113 | (entry) => | |
| 114 | entry.identity.pid === input.pid && entry.identity.startTimeMs === input.startTimeMs, | |
| 115 | ); | |
| 116 | if (!selected) { | |
| 117 | return { | |
| 118 | pid: input.pid, | |
| 119 | signal: input.signal, | |
| 120 | signaled: false, | |
| 121 | message: Option.some( | |
| 122 | `Process ${input.pid} no longer matches the selected process identity.`, | |
| 123 | ), | |
| 124 | }; | |
| 125 | } | |
| 126 | if (!canSignalCategory(selected.category)) { | |
| 127 | return { | |
| 128 | pid: input.pid, | |
| 129 | signal: input.signal, | |
| 130 | signaled: false, | |
| 131 | message: Option.some(`Process ${input.pid} is not a signalable T3 backend descendant.`), | |
| 132 | }; | |
| 133 | } | |
| 134 | return yield* Effect.try({ | |
| 135 | try: () => { | |
| 136 | process.kill(input.pid, input.signal); | |
| 137 | return { | |
| 138 | pid: input.pid, | |
| 139 | signal: input.signal, | |
| 140 | signaled: true, | |
| 141 | message: Option.none(), | |
| 142 | }; | |
| 143 | }, | |
| 144 | catch: (cause) => | |
| 145 | new ProcessSignalFailed({ | |
| 146 | pid: input.pid, | |
| 147 | signal: input.signal, | |
| 148 | cause, | |
| 558 | 149 | }), |
| 559 | ), | |
| 560 | Effect.catch((error: ProcessDiagnosticsError) => | |
| 150 | }).pipe( | |
| 151 | Effect.catch((error) => | |
| 561 | 152 | Effect.succeed({ |
| 562 | 153 | pid: input.pid, |
| 563 | 154 | signal: input.signal, |
| 564 | 155 | signaled: false, |
| 565 | message: Option.some(error.message), | |
| 156 | message: Option.some( | |
| 157 | error instanceof Error ? error.message : "Failed to signal process.", | |
| 158 | ), | |
| 566 | 159 | }), |
| 567 | 160 | ), |
| 568 | 161 | ); |
| ... | ... | @@ -572,4 +165,4 @@ export const make = Effect.gen(function* () { |
| 572 | 165 | return ProcessDiagnostics.of({ read, signal }); |
| 573 | 166 | }); |
| 574 | 167 | |
| 575 | export const layer = Layer.effect(ProcessDiagnostics, make); | |
| 168 | export const layer = Layer.effect(ProcessDiagnostics, make()); |
apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+145-230| ... | ... | @@ -1,255 +1,170 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | |
| 4 | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Stream from "effect/Stream"; | |
| 5 | 7 | |
| 8 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; | |
| 9 | import type { ResourceTelemetryHistoryWithLegacyBuckets } from "../resourceTelemetry/ResourceTelemetryHistory.ts"; | |
| 6 | 10 | import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; |
| 7 | 11 | |
| 8 | 12 | describe("ProcessResourceMonitor", () => { |
| 9 | it.effect("samples the server root process and descendants", () => | |
| 10 | Effect.sync(() => { | |
| 11 | const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | |
| 12 | const samples = ProcessResourceMonitor.collectMonitoredSamples({ | |
| 13 | serverPid: 100, | |
| 14 | sampledAt, | |
| 15 | sampledAtMs: DateTime.toEpochMillis(sampledAt), | |
| 16 | rows: [ | |
| 17 | { | |
| 18 | pid: 100, | |
| 19 | ppid: 1, | |
| 20 | pgid: 100, | |
| 21 | status: "S", | |
| 22 | cpuPercent: 2, | |
| 23 | rssBytes: 1_000, | |
| 24 | elapsed: "01:00", | |
| 25 | command: "t3 server", | |
| 26 | }, | |
| 13 | it.effect("projects resource telemetry history into the legacy diagnostics contract", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | |
| 16 | const history: ResourceTelemetryHistoryWithLegacyBuckets = { | |
| 17 | readAt, | |
| 18 | windowMs: 60_000, | |
| 19 | bucketMs: 10_000, | |
| 20 | sampleIntervalMs: 1_000, | |
| 21 | retainedSampleCount: 2, | |
| 22 | buckets: [ | |
| 27 | 23 | { |
| 28 | pid: 101, | |
| 29 | ppid: 100, | |
| 30 | pgid: 100, | |
| 31 | status: "S", | |
| 32 | cpuPercent: 10, | |
| 33 | rssBytes: 2_000, | |
| 34 | elapsed: "00:20", | |
| 35 | command: "codex app-server", | |
| 24 | startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), | |
| 25 | endedAt: readAt, | |
| 26 | avgCpuPercent: 15, | |
| 27 | maxCpuPercent: 25, | |
| 28 | maxRssBytes: 4_096, | |
| 29 | ioReadBytes: 1_024, | |
| 30 | ioWriteBytes: 2_048, | |
| 31 | maxProcessCount: 2, | |
| 36 | 32 | }, |
| 33 | ], | |
| 34 | legacyBackendBuckets: [ | |
| 37 | 35 | { |
| 38 | pid: 102, | |
| 39 | ppid: 101, | |
| 40 | pgid: 100, | |
| 41 | status: "R", | |
| 42 | cpuPercent: 50, | |
| 43 | rssBytes: 3_000, | |
| 44 | elapsed: "00:05", | |
| 45 | command: "rg needle", | |
| 36 | startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), | |
| 37 | endedAt: readAt, | |
| 38 | avgCpuPercent: 5, | |
| 39 | maxCpuPercent: 8, | |
| 40 | maxRssBytes: 4_096, | |
| 41 | ioReadBytes: 1_024, | |
| 42 | ioWriteBytes: 2_048, | |
| 43 | maxProcessCount: 1, | |
| 46 | 44 | }, |
| 45 | ], | |
| 46 | topProcesses: [ | |
| 47 | 47 | { |
| 48 | pid: 200, | |
| 48 | identity: { pid: process.pid, startTimeMs: 100 }, | |
| 49 | 49 | ppid: 1, |
| 50 | pgid: 200, | |
| 51 | status: "R", | |
| 52 | cpuPercent: 99, | |
| 53 | rssBytes: 9_000, | |
| 54 | elapsed: "00:05", | |
| 55 | command: "unrelated", | |
| 50 | depth: 0, | |
| 51 | name: "node", | |
| 52 | command: "t3 server", | |
| 53 | category: "server", | |
| 54 | firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), | |
| 55 | lastSeenAt: readAt, | |
| 56 | currentCpuPercent: 5, | |
| 57 | avgCpuPercent: 4, | |
| 58 | maxCpuPercent: 8, | |
| 59 | cpuTimeMs: 1_500, | |
| 60 | currentRssBytes: 2_048, | |
| 61 | peakRssBytes: 4_096, | |
| 62 | ioReadBytes: 1_024, | |
| 63 | ioWriteBytes: 2_048, | |
| 64 | ioSemantics: "storage", | |
| 65 | sampleCount: 2, | |
| 56 | 66 | }, |
| 57 | ], | |
| 58 | }); | |
| 59 | ||
| 60 | expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]); | |
| 61 | expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]); | |
| 62 | expect(samples[0]?.isServerRoot).toBe(true); | |
| 63 | expect(samples[1]?.isServerRoot).toBe(false); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("rolls samples up by process and CPU time", () => | |
| 68 | Effect.sync(() => { | |
| 69 | const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | |
| 70 | const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z"); | |
| 71 | const samples = [ | |
| 72 | ...ProcessResourceMonitor.collectMonitoredSamples({ | |
| 73 | serverPid: 100, | |
| 74 | sampledAt: firstAt, | |
| 75 | sampledAtMs: DateTime.toEpochMillis(firstAt), | |
| 76 | rows: [ | |
| 77 | { | |
| 78 | pid: 100, | |
| 79 | ppid: 1, | |
| 80 | pgid: 100, | |
| 81 | status: "S", | |
| 82 | cpuPercent: 10, | |
| 83 | rssBytes: 1_000, | |
| 84 | elapsed: "01:00", | |
| 85 | command: "t3 server", | |
| 86 | }, | |
| 87 | ], | |
| 88 | }), | |
| 89 | ...ProcessResourceMonitor.collectMonitoredSamples({ | |
| 90 | serverPid: 100, | |
| 91 | sampledAt: secondAt, | |
| 92 | sampledAtMs: DateTime.toEpochMillis(secondAt), | |
| 93 | rows: [ | |
| 94 | { | |
| 95 | pid: 100, | |
| 96 | ppid: 1, | |
| 97 | pgid: 100, | |
| 98 | status: "S", | |
| 99 | cpuPercent: 30, | |
| 100 | rssBytes: 2_000, | |
| 101 | elapsed: "01:05", | |
| 102 | command: "t3 server", | |
| 103 | }, | |
| 104 | ], | |
| 105 | }), | |
| 106 | ]; | |
| 107 | ||
| 108 | const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ | |
| 109 | samples, | |
| 110 | readAt: secondAt, | |
| 111 | readAtMs: DateTime.toEpochMillis(secondAt), | |
| 112 | windowMs: 60_000, | |
| 113 | bucketMs: 10_000, | |
| 114 | lastFailure: null, | |
| 115 | }); | |
| 116 | ||
| 117 | expect(Option.isNone(result.error)).toBe(true); | |
| 118 | expect(result.topProcesses).toHaveLength(1); | |
| 119 | expect(result.topProcesses[0]?.avgCpuPercent).toBe(20); | |
| 120 | expect(result.topProcesses[0]?.maxCpuPercent).toBe(30); | |
| 121 | expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2); | |
| 122 | expect(result.totalCpuSecondsApprox).toBe(2); | |
| 123 | expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true); | |
| 124 | }), | |
| 125 | ); | |
| 126 | ||
| 127 | it.effect("keeps a process grouped when elapsed time drifts between samples", () => | |
| 128 | Effect.sync(() => { | |
| 129 | const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z"); | |
| 130 | const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z"); | |
| 131 | const samples = [ | |
| 132 | ...ProcessResourceMonitor.collectMonitoredSamples({ | |
| 133 | serverPid: 100, | |
| 134 | sampledAt: firstAt, | |
| 135 | sampledAtMs: DateTime.toEpochMillis(firstAt), | |
| 136 | rows: [ | |
| 137 | { | |
| 138 | pid: 100, | |
| 139 | ppid: 1, | |
| 140 | pgid: 100, | |
| 141 | status: "S", | |
| 142 | cpuPercent: 1, | |
| 143 | rssBytes: 1_000, | |
| 144 | elapsed: "01:00", | |
| 145 | command: "t3 server", | |
| 146 | }, | |
| 147 | ], | |
| 148 | }), | |
| 149 | ...ProcessResourceMonitor.collectMonitoredSamples({ | |
| 150 | serverPid: 100, | |
| 151 | sampledAt: secondAt, | |
| 152 | sampledAtMs: DateTime.toEpochMillis(secondAt), | |
| 153 | rows: [ | |
| 154 | { | |
| 155 | pid: 100, | |
| 156 | ppid: 1, | |
| 157 | pgid: 100, | |
| 158 | status: "S", | |
| 159 | cpuPercent: 2, | |
| 160 | rssBytes: 2_000, | |
| 161 | elapsed: "01:06", | |
| 162 | command: "t3 server", | |
| 163 | }, | |
| 164 | ], | |
| 165 | }), | |
| 166 | ]; | |
| 167 | ||
| 168 | const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ | |
| 169 | samples, | |
| 170 | readAt: secondAt, | |
| 171 | readAtMs: DateTime.toEpochMillis(secondAt), | |
| 172 | windowMs: 60_000, | |
| 173 | bucketMs: 10_000, | |
| 174 | lastFailure: null, | |
| 175 | }); | |
| 176 | ||
| 177 | expect(result.topProcesses).toHaveLength(1); | |
| 178 | expect(result.topProcesses[0]?.isServerRoot).toBe(true); | |
| 179 | expect(result.topProcesses[0]?.sampleCount).toBe(2); | |
| 180 | expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000); | |
| 181 | }), | |
| 182 | ); | |
| 183 | ||
| 184 | it.effect("returns all process summaries in the selected window", () => | |
| 185 | Effect.sync(() => { | |
| 186 | const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | |
| 187 | const samples = ProcessResourceMonitor.collectMonitoredSamples({ | |
| 188 | serverPid: 100, | |
| 189 | sampledAt, | |
| 190 | sampledAtMs: DateTime.toEpochMillis(sampledAt), | |
| 191 | rows: [ | |
| 192 | 67 | { |
| 193 | pid: 100, | |
| 68 | identity: { pid: 5_000, startTimeMs: 200 }, | |
| 194 | 69 | ppid: 1, |
| 195 | pgid: 100, | |
| 196 | status: "S", | |
| 197 | cpuPercent: 1, | |
| 198 | rssBytes: 1_000, | |
| 199 | elapsed: "01:00", | |
| 200 | command: "t3 server", | |
| 70 | depth: 0, | |
| 71 | name: "electron", | |
| 72 | command: "electron", | |
| 73 | category: "electron-main", | |
| 74 | firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), | |
| 75 | lastSeenAt: readAt, | |
| 76 | currentCpuPercent: 50, | |
| 77 | avgCpuPercent: 40, | |
| 78 | maxCpuPercent: 80, | |
| 79 | cpuTimeMs: 15_000, | |
| 80 | currentRssBytes: 20_480, | |
| 81 | peakRssBytes: 40_960, | |
| 82 | ioReadBytes: 10_240, | |
| 83 | ioWriteBytes: 20_480, | |
| 84 | ioSemantics: "storage", | |
| 85 | sampleCount: 2, | |
| 201 | 86 | }, |
| 202 | ...Array.from({ length: 35 }, (_, index) => ({ | |
| 203 | pid: 200 + index, | |
| 204 | ppid: index === 0 ? 100 : 199 + index, | |
| 205 | pgid: 100, | |
| 206 | status: "S", | |
| 207 | cpuPercent: 35 - index, | |
| 208 | rssBytes: 2_000 + index, | |
| 209 | elapsed: "00:10", | |
| 210 | command: `worker ${index}`, | |
| 211 | })), | |
| 212 | 87 | ], |
| 213 | }); | |
| 214 | ||
| 215 | const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ | |
| 216 | samples, | |
| 217 | readAt: sampledAt, | |
| 218 | readAtMs: DateTime.toEpochMillis(sampledAt), | |
| 219 | windowMs: 60_000, | |
| 220 | bucketMs: 10_000, | |
| 221 | lastFailure: null, | |
| 222 | }); | |
| 223 | ||
| 224 | expect(result.topProcesses).toHaveLength(36); | |
| 225 | expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); | |
| 226 | }), | |
| 227 | ); | |
| 228 | ||
| 229 | it.effect("exposes bounded failure diagnostics while retaining the exact cause", () => | |
| 230 | Effect.sync(() => { | |
| 231 | const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | |
| 232 | const cause = new Error("stderr included credential=secret-value"); | |
| 233 | const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({ | |
| 234 | failureTag: "ProcessDiagnosticsQueryFailedError", | |
| 235 | cause, | |
| 236 | }); | |
| 88 | health: { | |
| 89 | native: { | |
| 90 | status: "degraded", | |
| 91 | lastSampleAt: Option.some(readAt), | |
| 92 | lastError: Option.some("collector stalled"), | |
| 93 | }, | |
| 94 | desktop: { | |
| 95 | status: "healthy", | |
| 96 | lastSampleAt: Option.some(readAt), | |
| 97 | lastError: Option.none(), | |
| 98 | }, | |
| 99 | sidecarVersion: Option.some("0.1.0"), | |
| 100 | sidecarPid: Option.some(9_000), | |
| 101 | restartCount: 1, | |
| 102 | collectionDurationMicros: 250, | |
| 103 | scannedProcessCount: 80, | |
| 104 | retainedProcessCount: 2, | |
| 105 | inaccessibleProcessCount: 0, | |
| 106 | }, | |
| 107 | }; | |
| 108 | const telemetry: ResourceTelemetry.ResourceTelemetry["Service"] = { | |
| 109 | latest: Effect.die("unused"), | |
| 110 | changes: Stream.empty, | |
| 111 | subscribe: Effect.die("unused"), | |
| 112 | readHistory: () => Effect.succeed(history), | |
| 113 | refresh: Effect.die("unused"), | |
| 114 | validateProcessIdentity: () => Effect.die("unused"), | |
| 115 | retry: Effect.die("unused"), | |
| 116 | }; | |
| 117 | const layer = ProcessResourceMonitor.layer.pipe( | |
| 118 | Layer.provide( | |
| 119 | Layer.succeed( | |
| 120 | ResourceTelemetry.ResourceTelemetry, | |
| 121 | ResourceTelemetry.ResourceTelemetry.of(telemetry), | |
| 122 | ), | |
| 123 | ), | |
| 124 | ); | |
| 237 | 125 | |
| 238 | const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ | |
| 239 | samples: [], | |
| 240 | readAt, | |
| 241 | readAtMs: DateTime.toEpochMillis(readAt), | |
| 242 | windowMs: 60_000, | |
| 243 | bucketMs: 10_000, | |
| 244 | lastFailure: failure, | |
| 245 | }); | |
| 126 | const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe( | |
| 127 | Effect.flatMap((monitor) => | |
| 128 | monitor.readHistory({ | |
| 129 | windowMs: 60_000, | |
| 130 | bucketMs: 10_000, | |
| 131 | }), | |
| 132 | ), | |
| 133 | Effect.provide(layer), | |
| 134 | ); | |
| 246 | 135 | |
| 247 | expect(failure.cause).toBe(cause); | |
| 248 | expect(Option.getOrThrow(result.error)).toEqual({ | |
| 249 | failureTag: "ProcessDiagnosticsQueryFailedError", | |
| 250 | message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).", | |
| 136 | expect(result.totalCpuSecondsApprox).toBe(1.5); | |
| 137 | expect(result.topProcesses).toEqual([ | |
| 138 | { | |
| 139 | processKey: `${process.pid}:100`, | |
| 140 | pid: process.pid, | |
| 141 | ppid: 1, | |
| 142 | command: "t3 server", | |
| 143 | depth: 0, | |
| 144 | isServerRoot: true, | |
| 145 | firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), | |
| 146 | lastSeenAt: readAt, | |
| 147 | currentCpuPercent: 5, | |
| 148 | avgCpuPercent: 4, | |
| 149 | maxCpuPercent: 8, | |
| 150 | cpuSecondsApprox: 1.5, | |
| 151 | currentRssBytes: 2_048, | |
| 152 | maxRssBytes: 4_096, | |
| 153 | sampleCount: 2, | |
| 154 | }, | |
| 155 | ]); | |
| 156 | expect(result.buckets[0]).toMatchObject({ | |
| 157 | avgCpuPercent: 5, | |
| 158 | maxCpuPercent: 8, | |
| 159 | maxRssBytes: 4_096, | |
| 160 | maxProcessCount: 1, | |
| 251 | 161 | }); |
| 252 | expect(Option.getOrThrow(result.error).message).not.toContain("secret-value"); | |
| 162 | expect(result.error).toEqual( | |
| 163 | Option.some({ | |
| 164 | failureTag: "ProcessDiagnosticsQueryFailedError", | |
| 165 | message: "collector stalled", | |
| 166 | }), | |
| 167 | ); | |
| 253 | 168 | }), |
| 254 | 169 | ); |
| 255 | 170 | }); |
apps/server/src/diagnostics/ProcessResourceMonitor.ts+62-304| ... | ... | @@ -1,55 +1,14 @@ |
| 1 | import { | |
| 2 | ServerProcessResourceHistoryFailureTag, | |
| 3 | type ServerProcessResourceHistoryBucket, | |
| 4 | type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType, | |
| 5 | type ServerProcessResourceHistoryInput, | |
| 6 | type ServerProcessResourceHistoryResult, | |
| 7 | type ServerProcessResourceHistorySummary, | |
| 1 | import type { | |
| 2 | ResourceTelemetryProcessCategory, | |
| 3 | ServerProcessResourceHistoryInput, | |
| 4 | ServerProcessResourceHistoryResult, | |
| 8 | 5 | } from "@t3tools/contracts"; |
| 9 | 6 | import * as Context from "effect/Context"; |
| 10 | import * as DateTime from "effect/DateTime"; | |
| 11 | 7 | import * as Effect from "effect/Effect"; |
| 12 | 8 | import * as Layer from "effect/Layer"; |
| 13 | 9 | import * as Option from "effect/Option"; |
| 14 | import * as Ref from "effect/Ref"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 17 | 10 | |
| 18 | import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; | |
| 19 | ||
| 20 | const SAMPLE_INTERVAL_MS = 5_000; | |
| 21 | const RETENTION_MS = 60 * 60_000; | |
| 22 | const MAX_RETAINED_SAMPLES = 20_000; | |
| 23 | ||
| 24 | export interface ProcessResourceSample { | |
| 25 | readonly sampledAt: DateTime.Utc; | |
| 26 | readonly sampledAtMs: number; | |
| 27 | readonly processKey: string; | |
| 28 | readonly pid: number; | |
| 29 | readonly ppid: number; | |
| 30 | readonly command: string; | |
| 31 | readonly cpuPercent: number; | |
| 32 | readonly rssBytes: number; | |
| 33 | readonly depth: number; | |
| 34 | readonly isServerRoot: boolean; | |
| 35 | } | |
| 36 | ||
| 37 | export class ProcessResourceSamplingError extends Schema.TaggedErrorClass<ProcessResourceSamplingError>()( | |
| 38 | "ProcessResourceSamplingError", | |
| 39 | { | |
| 40 | failureTag: ServerProcessResourceHistoryFailureTag, | |
| 41 | cause: Schema.Defect(), | |
| 42 | }, | |
| 43 | ) { | |
| 44 | override get message(): string { | |
| 45 | return `Failed to sample process resources (${this.failureTag}).`; | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | interface MonitorState { | |
| 50 | readonly samples: ReadonlyArray<ProcessResourceSample>; | |
| 51 | readonly lastFailure: ProcessResourceSamplingError | null; | |
| 52 | } | |
| 11 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; | |
| 53 | 12 | |
| 54 | 13 | export class ProcessResourceMonitor extends Context.Service< |
| 55 | 14 | ProcessResourceMonitor, |
| ... | ... | @@ -60,270 +19,69 @@ export class ProcessResourceMonitor extends Context.Service< |
| 60 | 19 | } |
| 61 | 20 | >()("t3/diagnostics/ProcessResourceMonitor") {} |
| 62 | 21 | |
| 63 | function dateTimeFromMillis(ms: number): DateTime.Utc { | |
| 64 | return DateTime.makeUnsafe(ms); | |
| 65 | } | |
| 66 | ||
| 67 | function sampleKey(row: Pick<ProcessDiagnostics.ProcessRow, "pid" | "command">): string { | |
| 68 | return `${row.pid}:${row.command}`; | |
| 69 | } | |
| 70 | ||
| 71 | function findServerRootRow( | |
| 72 | rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>, | |
| 73 | serverPid: number, | |
| 74 | ): ProcessDiagnostics.ProcessRow | null { | |
| 75 | return rows.find((row) => row.pid === serverPid) ?? null; | |
| 76 | } | |
| 77 | ||
| 78 | export function collectMonitoredSamples(input: { | |
| 79 | readonly rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>; | |
| 80 | readonly serverPid: number; | |
| 81 | readonly sampledAt: DateTime.Utc; | |
| 82 | readonly sampledAtMs: number; | |
| 83 | }): ReadonlyArray<ProcessResourceSample> { | |
| 84 | const rows = input.rows.filter( | |
| 85 | (row) => !ProcessDiagnostics.isDiagnosticsQueryProcess(row, input.serverPid), | |
| 22 | function isLegacyBackendCategory(category: ResourceTelemetryProcessCategory): boolean { | |
| 23 | return ( | |
| 24 | category === "server" || | |
| 25 | category === "server-child" || | |
| 26 | category === "provider-root" || | |
| 27 | category === "terminal-root" | |
| 86 | 28 | ); |
| 87 | const root = findServerRootRow(rows, input.serverPid); | |
| 88 | const descendants = ProcessDiagnostics.buildDescendantEntries(rows, input.serverPid); | |
| 89 | const samples: ProcessResourceSample[] = []; | |
| 90 | ||
| 91 | if (root) { | |
| 92 | samples.push({ | |
| 93 | sampledAt: input.sampledAt, | |
| 94 | sampledAtMs: input.sampledAtMs, | |
| 95 | processKey: sampleKey(root), | |
| 96 | pid: root.pid, | |
| 97 | ppid: root.ppid, | |
| 98 | command: root.command, | |
| 99 | cpuPercent: root.cpuPercent, | |
| 100 | rssBytes: root.rssBytes, | |
| 101 | depth: 0, | |
| 102 | isServerRoot: true, | |
| 103 | }); | |
| 104 | } | |
| 105 | ||
| 106 | for (const process of descendants) { | |
| 107 | samples.push({ | |
| 108 | sampledAt: input.sampledAt, | |
| 109 | sampledAtMs: input.sampledAtMs, | |
| 110 | processKey: sampleKey(process), | |
| 111 | pid: process.pid, | |
| 112 | ppid: process.ppid, | |
| 113 | command: process.command, | |
| 114 | cpuPercent: process.cpuPercent, | |
| 115 | rssBytes: process.rssBytes, | |
| 116 | depth: process.depth + 1, | |
| 117 | isServerRoot: false, | |
| 118 | }); | |
| 119 | } | |
| 120 | ||
| 121 | return samples; | |
| 122 | } | |
| 123 | ||
| 124 | function trimSamples( | |
| 125 | samples: ReadonlyArray<ProcessResourceSample>, | |
| 126 | nowMs: number, | |
| 127 | ): ReadonlyArray<ProcessResourceSample> { | |
| 128 | const minSampledAtMs = nowMs - RETENTION_MS; | |
| 129 | const retained = samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); | |
| 130 | return retained.length <= MAX_RETAINED_SAMPLES | |
| 131 | ? retained | |
| 132 | : retained.slice(retained.length - MAX_RETAINED_SAMPLES); | |
| 133 | 29 | } |
| 134 | 30 | |
| 135 | function summarizeProcesses( | |
| 136 | samples: ReadonlyArray<ProcessResourceSample>, | |
| 137 | ): ReadonlyArray<ServerProcessResourceHistorySummary> { | |
| 138 | const groups = new Map<string, ProcessResourceSample[]>(); | |
| 139 | for (const sample of samples) { | |
| 140 | const processSamples = groups.get(sample.processKey) ?? []; | |
| 141 | processSamples.push(sample); | |
| 142 | groups.set(sample.processKey, processSamples); | |
| 143 | } | |
| 144 | ||
| 145 | return [...groups.entries()] | |
| 146 | .map(([processKey, processSamples]) => { | |
| 147 | const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); | |
| 148 | const first = sorted[0]!; | |
| 149 | const latest = sorted[sorted.length - 1]!; | |
| 150 | const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0); | |
| 151 | const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent)); | |
| 152 | const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes)); | |
| 153 | const cpuSecondsApprox = sorted.reduce( | |
| 154 | (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), | |
| 155 | 0, | |
| 156 | ); | |
| 157 | ||
| 158 | return { | |
| 159 | processKey, | |
| 160 | pid: latest.pid, | |
| 161 | ppid: latest.ppid, | |
| 162 | command: latest.command, | |
| 163 | depth: latest.depth, | |
| 164 | isServerRoot: latest.isServerRoot, | |
| 165 | firstSeenAt: first.sampledAt, | |
| 166 | lastSeenAt: latest.sampledAt, | |
| 167 | currentCpuPercent: latest.cpuPercent, | |
| 168 | avgCpuPercent: cpuPercentTotal / sorted.length, | |
| 169 | maxCpuPercent, | |
| 170 | cpuSecondsApprox, | |
| 171 | currentRssBytes: latest.rssBytes, | |
| 172 | maxRssBytes, | |
| 173 | sampleCount: sorted.length, | |
| 174 | } satisfies ServerProcessResourceHistorySummary; | |
| 175 | }) | |
| 176 | .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox); | |
| 177 | } | |
| 178 | ||
| 179 | function buildBuckets(input: { | |
| 180 | readonly samples: ReadonlyArray<ProcessResourceSample>; | |
| 181 | readonly nowMs: number; | |
| 182 | readonly windowMs: number; | |
| 183 | readonly bucketMs: number; | |
| 184 | }): ReadonlyArray<ServerProcessResourceHistoryBucket> { | |
| 185 | const bucketMs = Math.max(1_000, input.bucketMs); | |
| 186 | const windowStartMs = input.nowMs - input.windowMs; | |
| 187 | const buckets: ServerProcessResourceHistoryBucket[] = []; | |
| 188 | ||
| 189 | for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += bucketMs) { | |
| 190 | const endedAtMs = Math.min(input.nowMs, startedAtMs + bucketMs); | |
| 191 | const bucketSamples = input.samples.filter( | |
| 192 | (sample) => | |
| 193 | sample.sampledAtMs >= startedAtMs && | |
| 194 | (endedAtMs === input.nowMs | |
| 195 | ? sample.sampledAtMs <= endedAtMs | |
| 196 | : sample.sampledAtMs < endedAtMs), | |
| 197 | ); | |
| 198 | const samplesByRead = new Map<number, ProcessResourceSample[]>(); | |
| 199 | for (const sample of bucketSamples) { | |
| 200 | const samplesAtTime = samplesByRead.get(sample.sampledAtMs) ?? []; | |
| 201 | samplesAtTime.push(sample); | |
| 202 | samplesByRead.set(sample.sampledAtMs, samplesAtTime); | |
| 203 | } | |
| 204 | ||
| 205 | const readTotals = [...samplesByRead.values()].map((samplesAtTime) => ({ | |
| 206 | cpuPercent: samplesAtTime.reduce((total, sample) => total + sample.cpuPercent, 0), | |
| 207 | rssBytes: samplesAtTime.reduce((total, sample) => total + sample.rssBytes, 0), | |
| 208 | processCount: samplesAtTime.length, | |
| 209 | })); | |
| 210 | const avgCpuPercent = | |
| 211 | readTotals.length === 0 | |
| 212 | ? 0 | |
| 213 | : readTotals.reduce((total, read) => total + read.cpuPercent, 0) / readTotals.length; | |
| 214 | ||
| 215 | buckets.push({ | |
| 216 | startedAt: dateTimeFromMillis(startedAtMs), | |
| 217 | endedAt: dateTimeFromMillis(endedAtMs), | |
| 218 | avgCpuPercent, | |
| 219 | maxCpuPercent: readTotals.length ? Math.max(...readTotals.map((read) => read.cpuPercent)) : 0, | |
| 220 | maxRssBytes: readTotals.length ? Math.max(...readTotals.map((read) => read.rssBytes)) : 0, | |
| 221 | maxProcessCount: readTotals.length | |
| 222 | ? Math.max(...readTotals.map((read) => read.processCount)) | |
| 223 | : 0, | |
| 224 | }); | |
| 225 | } | |
| 226 | ||
| 227 | return buckets; | |
| 228 | } | |
| 229 | ||
| 230 | export function aggregateProcessResourceHistory(input: { | |
| 231 | readonly samples: ReadonlyArray<ProcessResourceSample>; | |
| 232 | readonly readAt: DateTime.Utc; | |
| 233 | readonly readAtMs: number; | |
| 234 | readonly windowMs: number; | |
| 235 | readonly bucketMs: number; | |
| 236 | readonly lastFailure: ProcessResourceSamplingError | null; | |
| 237 | }): ServerProcessResourceHistoryResult { | |
| 238 | const windowMs = Math.max(1_000, input.windowMs); | |
| 239 | const bucketMs = Math.max(1_000, input.bucketMs); | |
| 240 | const minSampledAtMs = input.readAtMs - windowMs; | |
| 241 | const samples = input.samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); | |
| 242 | const topProcesses = summarizeProcesses(samples); | |
| 243 | const totalCpuSecondsApprox = samples.reduce( | |
| 244 | (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), | |
| 245 | 0, | |
| 246 | ); | |
| 247 | ||
| 248 | return { | |
| 249 | readAt: input.readAt, | |
| 250 | windowMs, | |
| 251 | bucketMs, | |
| 252 | sampleIntervalMs: SAMPLE_INTERVAL_MS, | |
| 253 | retainedSampleCount: input.samples.length, | |
| 254 | totalCpuSecondsApprox, | |
| 255 | buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }), | |
| 256 | topProcesses, | |
| 257 | error: input.lastFailure | |
| 258 | ? Option.some({ | |
| 259 | failureTag: input.lastFailure.failureTag, | |
| 260 | message: input.lastFailure.message, | |
| 261 | }) | |
| 262 | : Option.none(), | |
| 263 | }; | |
| 264 | } | |
| 265 | ||
| 266 | export const make = Effect.gen(function* () { | |
| 267 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 268 | const state = yield* Ref.make<MonitorState>({ samples: [], lastFailure: null }); | |
| 269 | ||
| 270 | const recordSamplingFailure = (cause: { | |
| 271 | readonly _tag: ServerProcessResourceHistoryFailureTagType; | |
| 272 | }) => | |
| 273 | Ref.update(state, (current) => ({ | |
| 274 | ...current, | |
| 275 | lastFailure: new ProcessResourceSamplingError({ | |
| 276 | failureTag: cause._tag, | |
| 277 | cause, | |
| 31 | export const make = Effect.fn("makeProcessResourceMonitor")(function* () { | |
| 32 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 33 | const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => | |
| 34 | telemetry.readHistory(input).pipe( | |
| 35 | Effect.map((history) => { | |
| 36 | const topProcesses = history.topProcesses | |
| 37 | .filter((entry) => isLegacyBackendCategory(entry.category)) | |
| 38 | .map((entry) => ({ | |
| 39 | processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`, | |
| 40 | pid: entry.identity.pid, | |
| 41 | ppid: entry.ppid, | |
| 42 | command: entry.command || entry.name || "unknown", | |
| 43 | depth: entry.depth, | |
| 44 | isServerRoot: entry.category === "server", | |
| 45 | firstSeenAt: entry.firstSeenAt, | |
| 46 | lastSeenAt: entry.lastSeenAt, | |
| 47 | currentCpuPercent: entry.currentCpuPercent, | |
| 48 | avgCpuPercent: entry.avgCpuPercent, | |
| 49 | maxCpuPercent: entry.maxCpuPercent, | |
| 50 | cpuSecondsApprox: entry.cpuTimeMs / 1_000, | |
| 51 | currentRssBytes: entry.currentRssBytes, | |
| 52 | maxRssBytes: entry.peakRssBytes, | |
| 53 | sampleCount: entry.sampleCount, | |
| 54 | })); | |
| 55 | return { | |
| 56 | readAt: history.readAt, | |
| 57 | windowMs: history.windowMs, | |
| 58 | bucketMs: history.bucketMs, | |
| 59 | sampleIntervalMs: history.sampleIntervalMs, | |
| 60 | retainedSampleCount: history.retainedSampleCount, | |
| 61 | totalCpuSecondsApprox: topProcesses.reduce( | |
| 62 | (total, entry) => total + entry.cpuSecondsApprox, | |
| 63 | 0, | |
| 64 | ), | |
| 65 | buckets: (history.legacyBackendBuckets ?? history.buckets).map((bucket) => ({ | |
| 66 | startedAt: bucket.startedAt, | |
| 67 | endedAt: bucket.endedAt, | |
| 68 | avgCpuPercent: bucket.avgCpuPercent, | |
| 69 | maxCpuPercent: bucket.maxCpuPercent, | |
| 70 | maxRssBytes: bucket.maxRssBytes, | |
| 71 | maxProcessCount: bucket.maxProcessCount, | |
| 72 | })), | |
| 73 | topProcesses, | |
| 74 | error: history.health.native.lastError.pipe( | |
| 75 | Option.map((message) => ({ | |
| 76 | failureTag: "ProcessDiagnosticsQueryFailedError" as const, | |
| 77 | message, | |
| 78 | })), | |
| 79 | ), | |
| 80 | }; | |
| 278 | 81 | }), |
| 279 | })); | |
| 280 | ||
| 281 | const sampleOnce = Effect.gen(function* () { | |
| 282 | const sampledAt = yield* DateTime.now; | |
| 283 | const sampledAtMs = DateTime.toEpochMillis(sampledAt); | |
| 284 | const rows = yield* ProcessDiagnostics.readProcessRows.pipe( | |
| 285 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 286 | 82 | ); |
| 287 | const samples = collectMonitoredSamples({ | |
| 288 | rows, | |
| 289 | serverPid: process.pid, | |
| 290 | sampledAt, | |
| 291 | sampledAtMs, | |
| 292 | }); | |
| 293 | yield* Ref.update(state, (current) => ({ | |
| 294 | samples: trimSamples([...current.samples, ...samples], sampledAtMs), | |
| 295 | lastFailure: null, | |
| 296 | })); | |
| 297 | }).pipe( | |
| 298 | Effect.catchTags({ | |
| 299 | ProcessDiagnosticsQueryTimeoutError: recordSamplingFailure, | |
| 300 | ProcessDiagnosticsQueryFailedError: recordSamplingFailure, | |
| 301 | ProcessDiagnosticsServerProcessSignalError: recordSamplingFailure, | |
| 302 | ProcessDiagnosticsNotDescendantError: recordSamplingFailure, | |
| 303 | ProcessDiagnosticsSignalFailedError: recordSamplingFailure, | |
| 304 | }), | |
| 305 | ); | |
| 306 | ||
| 307 | yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( | |
| 308 | Effect.forkScoped, | |
| 309 | ); | |
| 310 | ||
| 311 | const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => | |
| 312 | Effect.gen(function* () { | |
| 313 | const readAt = yield* DateTime.now; | |
| 314 | const readAtMs = DateTime.toEpochMillis(readAt); | |
| 315 | const current = yield* Ref.get(state); | |
| 316 | return aggregateProcessResourceHistory({ | |
| 317 | samples: current.samples, | |
| 318 | readAt, | |
| 319 | readAtMs, | |
| 320 | windowMs: input.windowMs, | |
| 321 | bucketMs: input.bucketMs, | |
| 322 | lastFailure: current.lastFailure, | |
| 323 | }); | |
| 324 | }); | |
| 325 | 83 | |
| 326 | 84 | return ProcessResourceMonitor.of({ readHistory }); |
| 327 | 85 | }); |
| 328 | 86 | |
| 329 | export const layer = Layer.effect(ProcessResourceMonitor, make); | |
| 87 | export const layer = Layer.effect(ProcessResourceMonitor, make()); |
apps/server/src/observability/Layers/Observability.ts+10| ... | ... | @@ -10,6 +10,7 @@ import * as OtlpSerialization from "effect/unstable/observability/OtlpSerializat |
| 10 | 10 | import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; |
| 11 | 11 | |
| 12 | 12 | import * as ServerConfig from "../../config.ts"; |
| 13 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | |
| 13 | 14 | import { ServerLoggerLive } from "../../serverLogger.ts"; |
| 14 | 15 | import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; |
| 15 | 16 | |
| ... | ... | @@ -18,6 +19,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson; |
| 18 | 19 | export const ObservabilityLive = Layer.unwrap( |
| 19 | 20 | Effect.gen(function* () { |
| 20 | 21 | const config = yield* ServerConfig.ServerConfig; |
| 22 | const attribution = yield* ResourceAttribution.ResourceAttribution; | |
| 21 | 23 | |
| 22 | 24 | const traceReferencesLayer = Layer.mergeAll( |
| 23 | 25 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), |
| ... | ... | @@ -32,6 +34,14 @@ export const ObservabilityLive = Layer.unwrap( |
| 32 | 34 | maxBytes: config.traceMaxBytes, |
| 33 | 35 | maxFiles: config.traceMaxFiles, |
| 34 | 36 | batchWindowMs: config.traceBatchWindowMs, |
| 37 | onFlush: (stats) => | |
| 38 | attribution.record({ | |
| 39 | component: "server-trace", | |
| 40 | operation: "append", | |
| 41 | logicalWriteBytes: stats.logicalWriteBytes, | |
| 42 | count: stats.count, | |
| 43 | durationMs: stats.durationMs, | |
| 44 | }), | |
| 35 | 45 | }); |
| 36 | 46 | const delegate = |
| 37 | 47 | config.otlpTracesUrl === undefined |
apps/server/src/provider/Drivers/ClaudeDriver.ts+2-2| ... | ... | @@ -24,6 +24,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 24 | 24 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 25 | 25 | |
| 26 | 26 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; |
| 27 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 27 | 28 | import { ServerConfig } from "../../config.ts"; |
| 28 | 29 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 29 | 30 | import { ProviderDriverError } from "../Errors.ts"; |
| ... | ... | @@ -57,7 +58,6 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from " |
| 57 | 58 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 58 | 59 | |
| 59 | 60 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 60 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | |
| 61 | 61 | const CAPABILITIES_PROBE_TTL = Duration.minutes(5); |
| 62 | 62 | |
| 63 | 63 | function isClaudeNativeCommandPath(commandPath: string): boolean { |
| ... | ... | @@ -82,6 +82,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 82 | 82 | }); |
| 83 | 83 | |
| 84 | 84 | export type ClaudeDriverEnv = |
| 85 | | BackgroundPolicy.BackgroundPolicy | |
| 85 | 86 | | ChildProcessSpawner.ChildProcessSpawner |
| 86 | 87 | | Crypto.Crypto |
| 87 | 88 | | FileSystem.FileSystem |
| ... | ... | @@ -190,7 +191,6 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 190 | 191 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 191 | 192 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), |
| 192 | 193 | ), |
| 193 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | |
| 194 | 194 | }).pipe( |
| 195 | 195 | Effect.mapError( |
| 196 | 196 | (cause) => |
apps/server/src/provider/Drivers/CodexDriver.ts+2-3| ... | ... | @@ -22,7 +22,6 @@ |
| 22 | 22 | * @module provider/Drivers/CodexDriver |
| 23 | 23 | */ |
| 24 | 24 | import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 25 | import * as Duration from "effect/Duration"; | |
| 26 | 25 | import * as Crypto from "effect/Crypto"; |
| 27 | 26 | import * as Effect from "effect/Effect"; |
| 28 | 27 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -32,6 +31,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 32 | 31 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 33 | 32 | |
| 34 | 33 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| 34 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 35 | 35 | import { ServerConfig } from "../../config.ts"; |
| 36 | 36 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 37 | 37 | import { ProviderDriverError } from "../Errors.ts"; |
| ... | ... | @@ -60,7 +60,6 @@ import { |
| 60 | 60 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| 61 | 61 | |
| 62 | 62 | const DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 63 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | |
| 64 | 63 | const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 65 | 64 | provider: DRIVER_KIND, |
| 66 | 65 | npmPackageName: "@openai/codex", |
| ... | ... | @@ -74,6 +73,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 74 | 73 | * registered driver and the runtime satisfies them once. |
| 75 | 74 | */ |
| 76 | 75 | export type CodexDriverEnv = |
| 76 | | BackgroundPolicy.BackgroundPolicy | |
| 77 | 77 | | ChildProcessSpawner.ChildProcessSpawner |
| 78 | 78 | | Crypto.Crypto |
| 79 | 79 | | FileSystem.FileSystem |
| ... | ... | @@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 186 | 186 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 187 | 187 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), |
| 188 | 188 | ), |
| 189 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | |
| 190 | 189 | }).pipe( |
| 191 | 190 | Effect.mapError( |
| 192 | 191 | (cause) => |
apps/server/src/provider/Drivers/CursorDriver.ts+3-4| ... | ... | @@ -12,7 +12,6 @@ |
| 12 | 12 | * @module provider/Drivers/CursorDriver |
| 13 | 13 | */ |
| 14 | 14 | import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 15 | import * as Duration from "effect/Duration"; | |
| 16 | 15 | import * as Crypto from "effect/Crypto"; |
| 17 | 16 | import * as Effect from "effect/Effect"; |
| 18 | 17 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -21,6 +20,7 @@ import * as Schema from "effect/Schema"; |
| 21 | 20 | import { HttpClient } from "effect/unstable/http"; |
| 22 | 21 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 23 | 22 | |
| 23 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 24 | 24 | import { ServerConfig } from "../../config.ts"; |
| 25 | 25 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 26 | 26 | import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; |
| ... | ... | @@ -53,7 +53,6 @@ import { |
| 53 | 53 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 54 | 54 | |
| 55 | 55 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 56 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | |
| 57 | 56 | const UPDATE: ProviderMaintenanceCapabilitiesResolver = { |
| 58 | 57 | resolve: (options) => |
| 59 | 58 | makeProviderMaintenanceCapabilities({ |
| ... | ... | @@ -66,6 +65,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { |
| 66 | 65 | }; |
| 67 | 66 | |
| 68 | 67 | export type CursorDriverEnv = |
| 68 | | BackgroundPolicy.BackgroundPolicy | |
| 69 | 69 | | ChildProcessSpawner.ChildProcessSpawner |
| 70 | 70 | | Crypto.Crypto |
| 71 | 71 | | FileSystem.FileSystem |
| ... | ... | @@ -160,8 +160,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 160 | 160 | publishSnapshot, |
| 161 | 161 | stampIdentity, |
| 162 | 162 | httpClient, |
| 163 | }), | |
| 164 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | |
| 163 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), | |
| 165 | 164 | }).pipe( |
| 166 | 165 | Effect.mapError( |
| 167 | 166 | (cause) => |
apps/server/src/provider/Drivers/GrokDriver.ts+2-3| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import * as Duration from "effect/Duration"; | |
| 3 | 2 | import * as Crypto from "effect/Crypto"; |
| 4 | 3 | import * as Effect from "effect/Effect"; |
| 5 | 4 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -8,6 +7,7 @@ import * as Schema from "effect/Schema"; |
| 8 | 7 | import { HttpClient } from "effect/unstable/http"; |
| 9 | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 9 | |
| 10 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 11 | 11 | import { ServerConfig } from "../../config.ts"; |
| 12 | 12 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 13 | 13 | import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; |
| ... | ... | @@ -40,7 +40,6 @@ import { |
| 40 | 40 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 41 | 41 | |
| 42 | 42 | const DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 43 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | |
| 44 | 43 | const UPDATE = makeStaticProviderMaintenanceResolver( |
| 45 | 44 | makeManualOnlyProviderMaintenanceCapabilities({ |
| 46 | 45 | provider: DRIVER_KIND, |
| ... | ... | @@ -49,6 +48,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( |
| 49 | 48 | ); |
| 50 | 49 | |
| 51 | 50 | export type GrokDriverEnv = |
| 51 | | BackgroundPolicy.BackgroundPolicy | |
| 52 | 52 | | ChildProcessSpawner.ChildProcessSpawner |
| 53 | 53 | | Crypto.Crypto |
| 54 | 54 | | FileSystem.FileSystem |
| ... | ... | @@ -136,7 +136,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 136 | 136 | publishSnapshot, |
| 137 | 137 | httpClient, |
| 138 | 138 | }), |
| 139 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | |
| 140 | 139 | }).pipe( |
| 141 | 140 | Effect.mapError( |
| 142 | 141 | (cause) => |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-3| ... | ... | @@ -13,7 +13,6 @@ |
| 13 | 13 | * @module provider/Drivers/OpenCodeDriver |
| 14 | 14 | */ |
| 15 | 15 | import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | import * as Duration from "effect/Duration"; | |
| 17 | 16 | import * as Crypto from "effect/Crypto"; |
| 18 | 17 | import * as Effect from "effect/Effect"; |
| 19 | 18 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -23,6 +22,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 23 | 22 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | 23 | |
| 25 | 24 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; |
| 25 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 26 | 26 | import { ServerConfig } from "../../config.ts"; |
| 27 | 27 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 28 | 28 | import { ProviderDriverError } from "../Errors.ts"; |
| ... | ... | @@ -55,7 +55,6 @@ import { |
| 55 | 55 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); |
| 56 | 56 | |
| 57 | 57 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 58 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | |
| 59 | 58 | |
| 60 | 59 | function isOpenCodeNativeCommandPath(commandPath: string): boolean { |
| 61 | 60 | const normalized = normalizeCommandPath(commandPath); |
| ... | ... | @@ -78,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 78 | 77 | }); |
| 79 | 78 | |
| 80 | 79 | export type OpenCodeDriverEnv = |
| 80 | | BackgroundPolicy.BackgroundPolicy | |
| 81 | 81 | | ChildProcessSpawner.ChildProcessSpawner |
| 82 | 82 | | Crypto.Crypto |
| 83 | 83 | | FileSystem.FileSystem |
| ... | ... | @@ -166,7 +166,6 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 166 | 166 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 167 | 167 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), |
| 168 | 168 | ), |
| 169 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | |
| 170 | 169 | }, |
| 171 | 170 | ).pipe( |
| 172 | 171 | Effect.mapError( |
apps/server/src/provider/Layers/ClaudeAdapter.ts+4-1| ... | ... | @@ -1354,6 +1354,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1354 | 1354 | stream: "native", |
| 1355 | 1355 | }) |
| 1356 | 1356 | : undefined); |
| 1357 | const managedNativeEventLogger = | |
| 1358 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; | |
| 1357 | 1359 | |
| 1358 | 1360 | const createQuery = |
| 1359 | 1361 | options?.createQuery ?? |
| ... | ... | @@ -1387,7 +1389,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1387 | 1389 | const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 1388 | 1390 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); |
| 1389 | 1391 | |
| 1390 | const logNativeSdkMessage = Effect.fn("logNativeSdkMessage")(function* ( | |
| 1392 | const logNativeSdkMessage = Effect.fnUntraced(function* ( | |
| 1391 | 1393 | context: ClaudeSessionContext, |
| 1392 | 1394 | message: SDKMessage, |
| 1393 | 1395 | ) { |
| ... | ... | @@ -3922,6 +3924,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3922 | 3924 | Effect.logError("Failed to emit Claude session shutdown event.", { cause }), |
| 3923 | 3925 | ), |
| 3924 | 3926 | Effect.tap(() => Queue.shutdown(runtimeEventQueue)), |
| 3927 | Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), | |
| 3925 | 3928 | ), |
| 3926 | 3929 | ); |
| 3927 | 3930 |
apps/server/src/provider/Layers/CodexAdapter.test.ts+1-1| ... | ... | @@ -1291,7 +1291,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => |
| 1291 | 1291 | yield* Scope.close(scope, Exit.void); |
| 1292 | 1292 | scopeClosed = true; |
| 1293 | 1293 | |
| 1294 | const threadLogPath = NodePath.join(tempDir, "thread-logger.log"); | |
| 1294 | const threadLogPath = NodePath.join(tempDir, "provider-native.thread-logger.log"); | |
| 1295 | 1295 | NodeAssert.equal(NodeFS.existsSync(threadLogPath), true); |
| 1296 | 1296 | const contents = NodeFS.readFileSync(threadLogPath, "utf8"); |
| 1297 | 1297 | NodeAssert.match(contents, /NTIVE: .*"message":"native flush test"/); |
apps/server/src/provider/Layers/CodexAdapter.ts+1-1| ... | ... | @@ -1646,7 +1646,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1646 | 1646 | ), |
| 1647 | 1647 | ); |
| 1648 | 1648 | |
| 1649 | const writeNativeEvent = Effect.fn("writeNativeEvent")(function* (event: ProviderEvent) { | |
| 1649 | const writeNativeEvent = Effect.fnUntraced(function* (event: ProviderEvent) { | |
| 1650 | 1650 | if (!nativeEventLogger) { |
| 1651 | 1651 | return; |
| 1652 | 1652 | } |
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+361-15| ... | ... | @@ -5,14 +5,30 @@ import * as NodePath from "node:path"; |
| 5 | 5 | |
| 6 | 6 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | 7 | import { assert, describe, it } from "@effect/vitest"; |
| 8 | import * as Clock from "effect/Clock"; | |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 9 | 11 | import * as Logger from "effect/Logger"; |
| 10 | 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 11 | 14 | |
| 12 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 15 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | |
| 16 | import { | |
| 17 | makeEventNdjsonLogger, | |
| 18 | makeEventNdjsonLogStore, | |
| 19 | type PendingRecord, | |
| 20 | writeBatchedMessages, | |
| 21 | } from "./EventNdjsonLogger.ts"; | |
| 13 | 22 | |
| 14 | 23 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); |
| 15 | 24 | |
| 25 | function ownedLogPath(basePath: string, segment: string): string { | |
| 26 | const basename = NodePath.basename(basePath); | |
| 27 | const extension = NodePath.extname(basename); | |
| 28 | const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename; | |
| 29 | return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`); | |
| 30 | } | |
| 31 | ||
| 16 | 32 | function parseLogLine(line: string) { |
| 17 | 33 | const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); |
| 18 | 34 | assert.notEqual(match, null); |
| ... | ... | @@ -87,8 +103,8 @@ describe("EventNdjsonLogger", () => { |
| 87 | 103 | ); |
| 88 | 104 | yield* logger.close(); |
| 89 | 105 | |
| 90 | const threadOnePath = NodePath.join(tempDir, "thread-1.log"); | |
| 91 | const threadTwoPath = NodePath.join(tempDir, "thread-2.log"); | |
| 106 | const threadOnePath = ownedLogPath(basePath, "thread-1"); | |
| 107 | const threadTwoPath = ownedLogPath(basePath, "thread-2"); | |
| 92 | 108 | assert.equal(NodeFS.existsSync(threadOnePath), true); |
| 93 | 109 | assert.equal(NodeFS.existsSync(threadTwoPath), true); |
| 94 | 110 | |
| ... | ... | @@ -129,7 +145,7 @@ describe("EventNdjsonLogger", () => { |
| 129 | 145 | yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); |
| 130 | 146 | yield* logger.close(); |
| 131 | 147 | |
| 132 | const globalPath = NodePath.join(tempDir, "_global.log"); | |
| 148 | const globalPath = ownedLogPath(basePath, "_global"); | |
| 133 | 149 | assert.equal(NodeFS.existsSync(globalPath), true); |
| 134 | 150 | const lines = NodeFS.readFileSync(globalPath, "utf8") |
| 135 | 151 | .trim() |
| ... | ... | @@ -138,9 +154,9 @@ describe("EventNdjsonLogger", () => { |
| 138 | 154 | assert.equal(lines.length, 2); |
| 139 | 155 | assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); |
| 140 | 156 | assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); |
| 141 | assert.equal(lines[0]?.stream, "CANON"); | |
| 157 | assert.equal(lines[0]?.stream, "ORCH"); | |
| 142 | 158 | assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); |
| 143 | assert.equal(lines[1]?.stream, "CANON"); | |
| 159 | assert.equal(lines[1]?.stream, "ORCH"); | |
| 144 | 160 | assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); |
| 145 | 161 | } finally { |
| 146 | 162 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); |
| ... | ... | @@ -148,6 +164,197 @@ describe("EventNdjsonLogger", () => { |
| 148 | 164 | }), |
| 149 | 165 | ); |
| 150 | 166 | |
| 167 | it.effect("shares one thread writer across native and canonical streams", () => | |
| 168 | Effect.gen(function* () { | |
| 169 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 170 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 171 | ||
| 172 | try { | |
| 173 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | |
| 174 | const native = store.logger("native"); | |
| 175 | const canonical = store.logger("canonical"); | |
| 176 | const threadId = ThreadId.make("thread-shared"); | |
| 177 | ||
| 178 | yield* native.write({ id: "native-event" }, threadId); | |
| 179 | yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); | |
| 180 | yield* store.close(); | |
| 181 | ||
| 182 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") | |
| 183 | .trim() | |
| 184 | .split("\n") | |
| 185 | .map(parseLogLine); | |
| 186 | ||
| 187 | assert.deepEqual( | |
| 188 | lines.map(({ stream, payload }) => ({ stream, payload })), | |
| 189 | [ | |
| 190 | { stream: "NTIVE", payload: '{"id":"native-event"}' }, | |
| 191 | { | |
| 192 | stream: "CANON", | |
| 193 | payload: '{"type":"item.completed","id":"canonical-event"}', | |
| 194 | }, | |
| 195 | ], | |
| 196 | ); | |
| 197 | } finally { | |
| 198 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 199 | } | |
| 200 | }), | |
| 201 | ); | |
| 202 | ||
| 203 | it.effect("keeps shared store views non-owning when one adapter closes", () => | |
| 204 | Effect.gen(function* () { | |
| 205 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 206 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 207 | ||
| 208 | try { | |
| 209 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | |
| 210 | const native = store.logger("native"); | |
| 211 | const canonical = store.logger("canonical"); | |
| 212 | const threadId = ThreadId.make("thread-shared-close"); | |
| 213 | ||
| 214 | yield* native.write({ id: "before-close" }, threadId); | |
| 215 | yield* native.close(); | |
| 216 | yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId); | |
| 217 | yield* store.close(); | |
| 218 | ||
| 219 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8") | |
| 220 | .trim() | |
| 221 | .split("\n") | |
| 222 | .map(parseLogLine); | |
| 223 | ||
| 224 | assert.deepEqual( | |
| 225 | lines.map(({ stream, payload }) => ({ stream, payload })), | |
| 226 | [ | |
| 227 | { stream: "NTIVE", payload: '{"id":"before-close"}' }, | |
| 228 | { | |
| 229 | stream: "CANON", | |
| 230 | payload: '{"type":"item.completed","id":"after-close"}', | |
| 231 | }, | |
| 232 | ], | |
| 233 | ); | |
| 234 | } finally { | |
| 235 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 236 | } | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | it.effect("flushes an active batch without a permanent polling loop", () => | |
| 241 | Effect.gen(function* () { | |
| 242 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 243 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 244 | const threadPath = ownedLogPath(basePath, "thread-batched"); | |
| 245 | ||
| 246 | try { | |
| 247 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); | |
| 248 | yield* store | |
| 249 | .logger("native") | |
| 250 | .write({ id: "batched-event" }, ThreadId.make("thread-batched")); | |
| 251 | ||
| 252 | assert.equal(NodeFS.existsSync(threadPath), false); | |
| 253 | yield* TestClock.adjust(1_000); | |
| 254 | assert.equal(NodeFS.existsSync(threadPath), true); | |
| 255 | yield* store.close(); | |
| 256 | } finally { | |
| 257 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 258 | } | |
| 259 | }), | |
| 260 | ); | |
| 261 | ||
| 262 | it.effect("does not strand a later batch after an interrupted write", () => | |
| 263 | Effect.gen(function* () { | |
| 264 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 265 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 266 | const threadPath = ownedLogPath(basePath, "thread-interrupted"); | |
| 267 | ||
| 268 | try { | |
| 269 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); | |
| 270 | const logger = store.logger("native"); | |
| 271 | const interruptedWrite = yield* logger | |
| 272 | .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted")) | |
| 273 | .pipe(Effect.forkChild); | |
| 274 | yield* Effect.yieldNow; | |
| 275 | yield* Fiber.interrupt(interruptedWrite); | |
| 276 | yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted")); | |
| 277 | ||
| 278 | yield* TestClock.adjust(1_000); | |
| 279 | ||
| 280 | assert.equal(NodeFS.existsSync(threadPath), true); | |
| 281 | assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}'); | |
| 282 | yield* store.close(); | |
| 283 | } finally { | |
| 284 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 285 | } | |
| 286 | }), | |
| 287 | ); | |
| 288 | ||
| 289 | it.effect("drops transient canonical events before serialization", () => | |
| 290 | Effect.gen(function* () { | |
| 291 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 292 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 293 | ||
| 294 | try { | |
| 295 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | |
| 296 | const canonical = store.logger("canonical"); | |
| 297 | const native = store.logger("native"); | |
| 298 | const threadId = ThreadId.make("thread-filtered"); | |
| 299 | const circularDelta: Record<string, unknown> = { type: "content.delta" }; | |
| 300 | circularDelta["self"] = circularDelta; | |
| 301 | ||
| 302 | yield* canonical.write(circularDelta, threadId); | |
| 303 | yield* canonical.write({ type: "item.completed", id: "final" }, threadId); | |
| 304 | yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); | |
| 305 | yield* store.close(); | |
| 306 | ||
| 307 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") | |
| 308 | .trim() | |
| 309 | .split("\n") | |
| 310 | .map(parseLogLine); | |
| 311 | ||
| 312 | assert.deepEqual( | |
| 313 | lines.map(({ stream, payload }) => ({ stream, payload })), | |
| 314 | [ | |
| 315 | { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, | |
| 316 | { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, | |
| 317 | ], | |
| 318 | ); | |
| 319 | } finally { | |
| 320 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 321 | } | |
| 322 | }), | |
| 323 | ); | |
| 324 | ||
| 325 | it.effect("contains hostile event accessors inside guarded serialization", () => | |
| 326 | Effect.gen(function* () { | |
| 327 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 328 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 329 | ||
| 330 | try { | |
| 331 | const logger = yield* makeEventNdjsonLogger(basePath, { | |
| 332 | stream: "canonical", | |
| 333 | batchWindowMs: 0, | |
| 334 | }); | |
| 335 | assert.exists(logger); | |
| 336 | if (!logger) return; | |
| 337 | const hostile = new Proxy( | |
| 338 | { id: "hostile" }, | |
| 339 | { | |
| 340 | get(_target, property) { | |
| 341 | if (property === "type") throw new Error("blocked"); | |
| 342 | return undefined; | |
| 343 | }, | |
| 344 | }, | |
| 345 | ); | |
| 346 | ||
| 347 | yield* logger.write(hostile, ThreadId.make("thread-hostile")); | |
| 348 | yield* logger.close(); | |
| 349 | ||
| 350 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); | |
| 351 | assert.notInclude(contents, "blocked"); | |
| 352 | } finally { | |
| 353 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 354 | } | |
| 355 | }), | |
| 356 | ); | |
| 357 | ||
| 151 | 358 | it.effect("serializes concurrent first writes for the same segment", () => |
| 152 | 359 | Effect.gen(function* () { |
| 153 | 360 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); |
| ... | ... | @@ -172,7 +379,7 @@ describe("EventNdjsonLogger", () => { |
| 172 | 379 | ); |
| 173 | 380 | yield* logger.close(); |
| 174 | 381 | |
| 175 | const globalPath = NodePath.join(tempDir, "_global.log"); | |
| 382 | const globalPath = ownedLogPath(basePath, "_global"); | |
| 176 | 383 | assert.equal(NodeFS.existsSync(globalPath), true); |
| 177 | 384 | const lines = NodeFS.readFileSync(globalPath, "utf8") |
| 178 | 385 | .trim() |
| ... | ... | @@ -196,19 +403,19 @@ describe("EventNdjsonLogger", () => { |
| 196 | 403 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); |
| 197 | 404 | |
| 198 | 405 | try { |
| 199 | const logger = yield* makeEventNdjsonLogger(basePath, { | |
| 200 | stream: "native", | |
| 406 | const store = yield* makeEventNdjsonLogStore(basePath, { | |
| 201 | 407 | maxBytes: 120, |
| 202 | 408 | maxFiles: 2, |
| 409 | batchWindowMs: 0, | |
| 203 | 410 | }); |
| 204 | assert.notEqual(logger, undefined); | |
| 205 | if (!logger) { | |
| 206 | return; | |
| 207 | } | |
| 411 | const native = store.logger("native"); | |
| 412 | const canonical = store.logger("canonical"); | |
| 208 | 413 | |
| 209 | 414 | for (let index = 0; index < 10; index += 1) { |
| 415 | const logger = index % 2 === 0 ? native : canonical; | |
| 210 | 416 | yield* logger.write( |
| 211 | 417 | { |
| 418 | type: "session.started", | |
| 212 | 419 | threadId: "provider-thread-rotate", |
| 213 | 420 | id: `evt-${index}`, |
| 214 | 421 | payload: "x".repeat(40), |
| ... | ... | @@ -216,9 +423,9 @@ describe("EventNdjsonLogger", () => { |
| 216 | 423 | ThreadId.make("thread-rotate"), |
| 217 | 424 | ); |
| 218 | 425 | } |
| 219 | yield* logger.close(); | |
| 426 | yield* store.close(); | |
| 220 | 427 | |
| 221 | const fileStem = "thread-rotate.log"; | |
| 428 | const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate")); | |
| 222 | 429 | const matchingFiles = NodeFS.readdirSync(tempDir) |
| 223 | 430 | .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) |
| 224 | 431 | .toSorted(); |
| ... | ... | @@ -240,4 +447,143 @@ describe("EventNdjsonLogger", () => { |
| 240 | 447 | } |
| 241 | 448 | }), |
| 242 | 449 | ); |
| 450 | ||
| 451 | it.effect("enforces aggregate age and byte retention on startup", () => | |
| 452 | Effect.gen(function* () { | |
| 453 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 454 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 455 | const expiredPath = ownedLogPath(basePath, "expired"); | |
| 456 | const oldPath = ownedLogPath(basePath, "old"); | |
| 457 | const newPath = ownedLogPath(basePath, "new"); | |
| 458 | const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); | |
| 459 | const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); | |
| 460 | const ignoredPath = NodePath.join(tempDir, "ignored.txt"); | |
| 461 | ||
| 462 | try { | |
| 463 | yield* TestClock.setTime(1_800_000_000_000); | |
| 464 | const now = yield* Clock.currentTimeMillis; | |
| 465 | for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { | |
| 466 | NodeFS.writeFileSync(filePath, "x".repeat(40)); | |
| 467 | } | |
| 468 | NodeFS.writeFileSync( | |
| 469 | legacyLogPath, | |
| 470 | "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", | |
| 471 | ); | |
| 472 | NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); | |
| 473 | NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); | |
| 474 | NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); | |
| 475 | NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); | |
| 476 | ||
| 477 | const store = yield* makeEventNdjsonLogStore(basePath, { | |
| 478 | maxAgeMs: 10_000, | |
| 479 | maxTotalBytes: 60, | |
| 480 | }); | |
| 481 | yield* store.close(); | |
| 482 | ||
| 483 | assert.equal(NodeFS.existsSync(expiredPath), false); | |
| 484 | assert.equal(NodeFS.existsSync(legacyLogPath), false); | |
| 485 | assert.equal(NodeFS.existsSync(oldPath), false); | |
| 486 | assert.equal(NodeFS.existsSync(newPath), true); | |
| 487 | assert.equal(NodeFS.existsSync(unrelatedLogPath), true); | |
| 488 | assert.equal(NodeFS.existsSync(ignoredPath), true); | |
| 489 | } finally { | |
| 490 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 491 | } | |
| 492 | }), | |
| 493 | ); | |
| 494 | ||
| 495 | it.effect("does not prune an active thread sink during an unrelated flush", () => | |
| 496 | Effect.gen(function* () { | |
| 497 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 498 | const basePath = NodePath.join(tempDir, "events.log"); | |
| 499 | const activePath = ownedLogPath(basePath, "active"); | |
| 500 | ||
| 501 | try { | |
| 502 | yield* TestClock.setTime(1_800_000_000_000); | |
| 503 | const store = yield* makeEventNdjsonLogStore(basePath, { | |
| 504 | batchWindowMs: 0, | |
| 505 | maxAgeMs: 1, | |
| 506 | retentionCheckIntervalMs: 1, | |
| 507 | }); | |
| 508 | const logger = store.logger("native"); | |
| 509 | ||
| 510 | yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); | |
| 511 | assert.equal(NodeFS.existsSync(activePath), true); | |
| 512 | ||
| 513 | yield* TestClock.adjust("2 millis"); | |
| 514 | yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); | |
| 515 | ||
| 516 | assert.equal(NodeFS.existsSync(activePath), true); | |
| 517 | yield* store.close(); | |
| 518 | } finally { | |
| 519 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 520 | } | |
| 521 | }), | |
| 522 | ); | |
| 523 | ||
| 524 | it("attributes batches that were written before a later chunk fails", () => { | |
| 525 | const records: ReadonlyArray<PendingRecord> = [ | |
| 526 | { | |
| 527 | stream: "native", | |
| 528 | threadSegment: "thread", | |
| 529 | line: "first", | |
| 530 | bytes: 5, | |
| 531 | }, | |
| 532 | { | |
| 533 | stream: "canonical", | |
| 534 | threadSegment: "thread", | |
| 535 | line: "second", | |
| 536 | bytes: 6, | |
| 537 | }, | |
| 538 | ]; | |
| 539 | const attributed: Array<PendingRecord> = []; | |
| 540 | let writes = 0; | |
| 541 | ||
| 542 | assert.throws(() => | |
| 543 | writeBatchedMessages( | |
| 544 | { | |
| 545 | write: () => { | |
| 546 | writes += 1; | |
| 547 | if (writes === 2) throw new Error("simulated disk exhaustion"); | |
| 548 | }, | |
| 549 | }, | |
| 550 | records, | |
| 551 | 5, | |
| 552 | (written) => attributed.push(...written), | |
| 553 | ), | |
| 554 | ); | |
| 555 | assert.deepEqual(attributed, [records[0]]); | |
| 556 | }); | |
| 557 | ||
| 558 | it.effect("reports logical provider log writes to resource attribution", () => | |
| 559 | Effect.gen(function* () { | |
| 560 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | |
| 561 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | |
| 562 | ||
| 563 | try { | |
| 564 | const attribution = yield* ResourceAttribution.make(); | |
| 565 | const logger = yield* makeEventNdjsonLogger(basePath, { | |
| 566 | stream: "native", | |
| 567 | batchWindowMs: 0, | |
| 568 | attribution, | |
| 569 | }); | |
| 570 | assert.notEqual(logger, undefined); | |
| 571 | if (!logger) { | |
| 572 | return; | |
| 573 | } | |
| 574 | ||
| 575 | yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); | |
| 576 | yield* logger.close(); | |
| 577 | ||
| 578 | const snapshot = yield* attribution.snapshot; | |
| 579 | assert.equal(snapshot.entries.length, 1); | |
| 580 | assert.equal(snapshot.entries[0]?.component, "provider-event-log"); | |
| 581 | assert.equal(snapshot.entries[0]?.operation, "native.append"); | |
| 582 | assert.equal(snapshot.entries[0]?.count, 1); | |
| 583 | assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); | |
| 584 | } finally { | |
| 585 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 586 | } | |
| 587 | }), | |
| 588 | ); | |
| 243 | 589 | }); |
apps/server/src/provider/Layers/EventNdjsonLogger.ts+527-197| ... | ... | @@ -1,10 +1,9 @@ |
| 1 | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | 2 | /** |
| 3 | * Provider event logger helper. | |
| 3 | * Best-effort provider event logging with one shared writer per thread. | |
| 4 | 4 | * |
| 5 | * Best-effort writer for observability logs. Each record is formatted as a | |
| 6 | * single effect-style text line in a thread-scoped file. Failures are | |
| 7 | * downgraded to warnings so provider runtime behavior is unaffected. | |
| 5 | * Native and canonical views share batching, rotation, and retention state so | |
| 6 | * they cannot race while appending to the same thread-scoped file. | |
| 8 | 7 | */ |
| 9 | 8 | import * as NodeFS from "node:fs"; |
| 10 | 9 | import * as NodePath from "node:path"; |
| ... | ... | @@ -12,45 +11,147 @@ import * as NodePath from "node:path"; |
| 12 | 11 | import type { ThreadId } from "@t3tools/contracts"; |
| 13 | 12 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 14 | 13 | import { errorTag } from "@t3tools/shared/observability"; |
| 14 | import * as Clock from "effect/Clock"; | |
| 15 | import * as DateTime from "effect/DateTime"; | |
| 15 | 16 | import * as Effect from "effect/Effect"; |
| 16 | 17 | import * as Exit from "effect/Exit"; |
| 17 | import * as Logger from "effect/Logger"; | |
| 18 | 18 | import * as Schema from "effect/Schema"; |
| 19 | 19 | import * as Scope from "effect/Scope"; |
| 20 | 20 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 21 | 21 | |
| 22 | 22 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; |
| 23 | import type { ResourceAttribution } from "../../resourceTelemetry/ResourceAttribution.ts"; | |
| 23 | 24 | |
| 24 | const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; | |
| 25 | const MEBIBYTE = 1024 * 1024; | |
| 26 | const DAY_MS = 24 * 60 * 60 * 1_000; | |
| 27 | const DEFAULT_MAX_BYTES = 10 * MEBIBYTE; | |
| 25 | 28 | const DEFAULT_MAX_FILES = 10; |
| 26 | const DEFAULT_BATCH_WINDOW_MS = 200; | |
| 29 | const DEFAULT_BATCH_WINDOW_MS = 1_000; | |
| 30 | const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE; | |
| 31 | const DEFAULT_MAX_AGE_MS = 14 * DAY_MS; | |
| 32 | const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000; | |
| 33 | const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE; | |
| 34 | const DEFAULT_MAX_BUFFERED_RECORDS = 512; | |
| 27 | 35 | const GLOBAL_THREAD_SEGMENT = "_global"; |
| 28 | 36 | const LOG_SCOPE = "provider-observability"; |
| 29 | 37 | const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); |
| 30 | 38 | |
| 39 | const transientCanonicalEventTypes = new Set([ | |
| 40 | "content.delta", | |
| 41 | "hook.progress", | |
| 42 | "item.updated", | |
| 43 | "task.progress", | |
| 44 | "thread.realtime.audio.delta", | |
| 45 | "tool.progress", | |
| 46 | "turn.proposed.delta", | |
| 47 | ]); | |
| 48 | ||
| 31 | 49 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; |
| 32 | 50 | |
| 33 | 51 | export interface EventNdjsonLogger { |
| 34 | 52 | readonly filePath: string; |
| 35 | write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void, never, never>; | |
| 36 | close: () => Effect.Effect<void, never, never>; | |
| 53 | readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>; | |
| 54 | readonly close: () => Effect.Effect<void>; | |
| 37 | 55 | } |
| 38 | 56 | |
| 39 | export interface EventNdjsonLoggerOptions { | |
| 40 | readonly stream: EventNdjsonStream; | |
| 57 | export interface EventNdjsonLogStore { | |
| 58 | readonly filePath: string; | |
| 59 | readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; | |
| 60 | readonly close: () => Effect.Effect<void>; | |
| 61 | } | |
| 62 | ||
| 63 | export interface EventNdjsonLogStoreOptions { | |
| 41 | 64 | readonly maxBytes?: number; |
| 42 | 65 | readonly maxFiles?: number; |
| 43 | 66 | readonly batchWindowMs?: number; |
| 67 | readonly maxTotalBytes?: number; | |
| 68 | readonly maxAgeMs?: number; | |
| 69 | readonly retentionCheckIntervalMs?: number; | |
| 70 | readonly maxBufferedBytes?: number; | |
| 71 | readonly maxBufferedRecords?: number; | |
| 72 | readonly attribution?: ResourceAttribution["Service"]; | |
| 73 | } | |
| 74 | ||
| 75 | export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { | |
| 76 | readonly stream: EventNdjsonStream; | |
| 77 | } | |
| 78 | ||
| 79 | export class EventNdjsonLogConfigurationError extends Schema.TaggedErrorClass<EventNdjsonLogConfigurationError>()( | |
| 80 | "EventNdjsonLogConfigurationError", | |
| 81 | { | |
| 82 | filePath: Schema.String, | |
| 83 | option: Schema.String, | |
| 84 | value: Schema.Number, | |
| 85 | minimum: Schema.Number, | |
| 86 | }, | |
| 87 | ) { | |
| 88 | override get message(): string { | |
| 89 | return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`; | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | export class EventNdjsonLogDirectoryError extends Schema.TaggedErrorClass<EventNdjsonLogDirectoryError>()( | |
| 94 | "EventNdjsonLogDirectoryError", | |
| 95 | { | |
| 96 | directory: Schema.String, | |
| 97 | cause: Schema.Defect(), | |
| 98 | }, | |
| 99 | ) { | |
| 100 | override get message(): string { | |
| 101 | return `Failed to create provider event log directory '${this.directory}'`; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | export type EventNdjsonLogStoreError = | |
| 106 | | EventNdjsonLogConfigurationError | |
| 107 | | EventNdjsonLogDirectoryError; | |
| 108 | ||
| 109 | interface ResolvedOptions { | |
| 110 | readonly maxBytes: number; | |
| 111 | readonly maxFiles: number; | |
| 112 | readonly batchWindowMs: number; | |
| 113 | readonly maxTotalBytes: number; | |
| 114 | readonly maxAgeMs: number; | |
| 115 | readonly retentionCheckIntervalMs: number; | |
| 116 | readonly maxBufferedBytes: number; | |
| 117 | readonly maxBufferedRecords: number; | |
| 118 | readonly attribution: ResourceAttribution["Service"] | undefined; | |
| 44 | 119 | } |
| 45 | 120 | |
| 46 | interface ThreadWriter { | |
| 47 | writeMessage: (message: string) => Effect.Effect<void>; | |
| 48 | close: () => Effect.Effect<void>; | |
| 121 | export interface PendingRecord { | |
| 122 | readonly stream: EventNdjsonStream; | |
| 123 | readonly threadSegment: string; | |
| 124 | readonly line: string; | |
| 125 | readonly bytes: number; | |
| 49 | 126 | } |
| 50 | 127 | |
| 51 | interface LoggerState { | |
| 52 | readonly threadWriters: Map<string, ThreadWriter>; | |
| 53 | readonly failedSegments: Set<string>; | |
| 128 | interface StoreState { | |
| 129 | readonly pending: ReadonlyArray<PendingRecord>; | |
| 130 | readonly pendingBytes: number; | |
| 131 | readonly sinks: ReadonlyMap<string, RotatingFileSink>; | |
| 132 | readonly flushScheduled: boolean; | |
| 133 | readonly closed: boolean; | |
| 134 | readonly lastRetentionAt: number; | |
| 135 | } | |
| 136 | ||
| 137 | interface AttributionSummary { | |
| 138 | readonly stream: EventNdjsonStream; | |
| 139 | readonly count: number; | |
| 140 | readonly logicalWriteBytes: number; | |
| 141 | } | |
| 142 | ||
| 143 | interface FileOperationFailure { | |
| 144 | readonly filePath: string; | |
| 145 | readonly cause: unknown; | |
| 146 | } | |
| 147 | ||
| 148 | interface RetentionResult { | |
| 149 | readonly failures: ReadonlyArray<FileOperationFailure>; | |
| 150 | } | |
| 151 | ||
| 152 | interface DrainResult { | |
| 153 | readonly attributions: ReadonlyArray<AttributionSummary>; | |
| 154 | readonly failures: ReadonlyArray<FileOperationFailure>; | |
| 54 | 155 | } |
| 55 | 156 | |
| 56 | 157 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { |
| ... | ... | @@ -62,225 +163,454 @@ function resolveThreadSegment(raw: string | null | undefined): string { |
| 62 | 163 | return normalized ?? GLOBAL_THREAD_SEGMENT; |
| 63 | 164 | } |
| 64 | 165 | |
| 65 | function formatLoggerMessage(message: unknown): string { | |
| 66 | if (Array.isArray(message)) { | |
| 67 | return message.map((part) => (typeof part === "string" ? part : String(part))).join(" "); | |
| 166 | function resolveStreamLabel(stream: EventNdjsonStream): string { | |
| 167 | return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON"; | |
| 168 | } | |
| 169 | ||
| 170 | function providerLogPrefix(filePath: string): string { | |
| 171 | const basename = NodePath.basename(filePath); | |
| 172 | const extension = NodePath.extname(basename); | |
| 173 | return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`; | |
| 174 | } | |
| 175 | ||
| 176 | function providerLogPath(directory: string, prefix: string, threadSegment: string): string { | |
| 177 | return NodePath.join(directory, `${prefix}${threadSegment}.log`); | |
| 178 | } | |
| 179 | ||
| 180 | function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { | |
| 181 | if (stream !== "canonical" || typeof event !== "object" || event === null) { | |
| 182 | return true; | |
| 183 | } | |
| 184 | try { | |
| 185 | const type = Reflect.get(event, "type"); | |
| 186 | return typeof type !== "string" || !transientCanonicalEventTypes.has(type); | |
| 187 | } catch { | |
| 188 | return true; | |
| 68 | 189 | } |
| 69 | return typeof message === "string" ? message : String(message); | |
| 70 | 190 | } |
| 71 | 191 | |
| 72 | function makeLineLogger(streamLabel: string): Logger.Logger<unknown, string> { | |
| 73 | return Logger.make( | |
| 74 | ({ date, message }) => | |
| 75 | `[${date.toISOString()}] ${streamLabel}: ${formatLoggerMessage(message)}\n`, | |
| 76 | ); | |
| 192 | export function writeBatchedMessages( | |
| 193 | sink: Pick<RotatingFileSink, "write">, | |
| 194 | records: ReadonlyArray<PendingRecord>, | |
| 195 | maxBytes: number, | |
| 196 | onWritten: (records: ReadonlyArray<PendingRecord>) => void, | |
| 197 | ): void { | |
| 198 | let pendingRecords: Array<PendingRecord> = []; | |
| 199 | let pendingBytes = 0; | |
| 200 | ||
| 201 | const flush = () => { | |
| 202 | if (pendingRecords.length === 0) return; | |
| 203 | const writtenRecords = pendingRecords; | |
| 204 | sink.write(writtenRecords.map((record) => record.line).join("")); | |
| 205 | onWritten(writtenRecords); | |
| 206 | pendingRecords = []; | |
| 207 | pendingBytes = 0; | |
| 208 | }; | |
| 209 | ||
| 210 | for (const record of records) { | |
| 211 | if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) { | |
| 212 | flush(); | |
| 213 | } | |
| 214 | pendingRecords.push(record); | |
| 215 | pendingBytes += record.bytes; | |
| 216 | if (pendingBytes >= maxBytes) { | |
| 217 | flush(); | |
| 218 | } | |
| 219 | } | |
| 220 | flush(); | |
| 77 | 221 | } |
| 78 | 222 | |
| 79 | function resolveStreamLabel(stream: EventNdjsonStream): string { | |
| 80 | switch (stream) { | |
| 81 | case "native": | |
| 82 | return "NTIVE"; | |
| 83 | case "canonical": | |
| 84 | case "orchestration": | |
| 85 | default: | |
| 86 | return "CANON"; | |
| 223 | function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean { | |
| 224 | if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false; | |
| 225 | if (fileName.startsWith(filePrefix)) return true; | |
| 226 | ||
| 227 | const descriptor = NodeFS.openSync(filePath, "r"); | |
| 228 | try { | |
| 229 | const header = Buffer.alloc(256); | |
| 230 | const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0); | |
| 231 | return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead)); | |
| 232 | } finally { | |
| 233 | NodeFS.closeSync(descriptor); | |
| 87 | 234 | } |
| 88 | 235 | } |
| 89 | 236 | |
| 90 | const toLogMessage = Effect.fn("toLogMessage")(function* ( | |
| 91 | event: unknown, | |
| 92 | ): Effect.fn.Return<string | undefined> { | |
| 93 | return yield* encodeUnknownJsonString(event).pipe( | |
| 94 | Effect.catch((error) => | |
| 95 | logWarning("failed to serialize provider event log record", { | |
| 96 | errorTag: errorTag(error), | |
| 97 | }).pipe(Effect.as(undefined)), | |
| 98 | ), | |
| 99 | ); | |
| 100 | }); | |
| 237 | function enforceRetention(input: { | |
| 238 | readonly directory: string; | |
| 239 | readonly maxTotalBytes: number; | |
| 240 | readonly maxAgeMs: number; | |
| 241 | readonly activeFilePaths: ReadonlySet<string>; | |
| 242 | readonly filePrefix: string; | |
| 243 | readonly now: number; | |
| 244 | }): RetentionResult { | |
| 245 | const failures: Array<FileOperationFailure> = []; | |
| 246 | const files: Array<{ filePath: string; mtimeMs: number; size: number }> = []; | |
| 247 | ||
| 248 | let entries: ReadonlyArray<NodeFS.Dirent>; | |
| 249 | try { | |
| 250 | entries = NodeFS.readdirSync(input.directory, { withFileTypes: true }); | |
| 251 | } catch (cause) { | |
| 252 | return { failures: [{ filePath: input.directory, cause }] }; | |
| 253 | } | |
| 101 | 254 | |
| 102 | const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { | |
| 103 | readonly filePath: string; | |
| 104 | readonly maxBytes: number; | |
| 105 | readonly maxFiles: number; | |
| 106 | readonly batchWindowMs: number; | |
| 107 | readonly streamLabel: string; | |
| 108 | }): Effect.fn.Return<ThreadWriter | undefined> { | |
| 109 | const sinkResult = yield* Effect.sync(() => { | |
| 255 | for (const entry of entries) { | |
| 256 | if (!entry.isFile()) continue; | |
| 257 | const filePath = NodePath.join(input.directory, entry.name); | |
| 110 | 258 | try { |
| 111 | return { | |
| 112 | ok: true as const, | |
| 113 | sink: new RotatingFileSink({ | |
| 114 | filePath: input.filePath, | |
| 115 | maxBytes: input.maxBytes, | |
| 116 | maxFiles: input.maxFiles, | |
| 117 | throwOnError: true, | |
| 118 | }), | |
| 119 | }; | |
| 120 | } catch (error) { | |
| 121 | return { ok: false as const, error }; | |
| 259 | if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue; | |
| 260 | const stat = NodeFS.statSync(filePath); | |
| 261 | files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size }); | |
| 262 | } catch (cause) { | |
| 263 | failures.push({ filePath, cause }); | |
| 122 | 264 | } |
| 265 | } | |
| 266 | ||
| 267 | let totalBytes = files.reduce((total, file) => total + file.size, 0); | |
| 268 | const remove = (file: (typeof files)[number]) => { | |
| 269 | if (input.activeFilePaths.has(file.filePath)) return false; | |
| 270 | try { | |
| 271 | NodeFS.rmSync(file.filePath, { force: true }); | |
| 272 | totalBytes -= file.size; | |
| 273 | return true; | |
| 274 | } catch (cause) { | |
| 275 | failures.push({ filePath: file.filePath, cause }); | |
| 276 | return false; | |
| 277 | } | |
| 278 | }; | |
| 279 | ||
| 280 | const retained = files.filter((file) => { | |
| 281 | if (input.now - file.mtimeMs <= input.maxAgeMs) return true; | |
| 282 | return !remove(file); | |
| 123 | 283 | }); |
| 124 | 284 | |
| 125 | if (!sinkResult.ok) { | |
| 126 | yield* logWarning("failed to initialize provider thread log file", { | |
| 127 | filePath: input.filePath, | |
| 128 | errorTag: errorTag(sinkResult.error), | |
| 129 | }); | |
| 130 | return undefined; | |
| 285 | for (const file of retained.toSorted( | |
| 286 | (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath), | |
| 287 | )) { | |
| 288 | if (totalBytes <= input.maxTotalBytes) break; | |
| 289 | remove(file); | |
| 131 | 290 | } |
| 132 | 291 | |
| 133 | const sink = sinkResult.sink; | |
| 134 | const scope = yield* Scope.make(); | |
| 135 | const lineLogger = makeLineLogger(input.streamLabel); | |
| 136 | const batchedLogger = yield* Logger.batched(lineLogger, { | |
| 137 | window: input.batchWindowMs, | |
| 138 | flush: Effect.fn("makeThreadWriter.flush")(function* (messages) { | |
| 139 | const flushResult = yield* Effect.sync(() => { | |
| 140 | try { | |
| 141 | for (const message of messages) { | |
| 142 | sink.write(message); | |
| 143 | } | |
| 144 | return { ok: true as const }; | |
| 145 | } catch (error) { | |
| 146 | return { ok: false as const, error }; | |
| 147 | } | |
| 148 | }); | |
| 292 | return { failures }; | |
| 293 | } | |
| 294 | ||
| 295 | function validateOption(input: { | |
| 296 | readonly filePath: string; | |
| 297 | readonly option: string; | |
| 298 | readonly value: number; | |
| 299 | readonly minimum: number; | |
| 300 | }): EventNdjsonLogConfigurationError | undefined { | |
| 301 | if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined; | |
| 302 | return new EventNdjsonLogConfigurationError(input); | |
| 303 | } | |
| 304 | ||
| 305 | function resolveOptions( | |
| 306 | filePath: string, | |
| 307 | options: EventNdjsonLogStoreOptions, | |
| 308 | ): Effect.Effect<ResolvedOptions, EventNdjsonLogConfigurationError> { | |
| 309 | const resolved = { | |
| 310 | maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, | |
| 311 | maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES, | |
| 312 | batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS, | |
| 313 | maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, | |
| 314 | maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS, | |
| 315 | retentionCheckIntervalMs: | |
| 316 | options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS, | |
| 317 | maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES, | |
| 318 | maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS, | |
| 319 | attribution: options.attribution, | |
| 320 | } satisfies ResolvedOptions; | |
| 321 | ||
| 322 | const validations = [ | |
| 323 | ["maxBytes", resolved.maxBytes, 1], | |
| 324 | ["maxFiles", resolved.maxFiles, 1], | |
| 325 | ["batchWindowMs", resolved.batchWindowMs, 0], | |
| 326 | ["maxTotalBytes", resolved.maxTotalBytes, 1], | |
| 327 | ["maxAgeMs", resolved.maxAgeMs, 1], | |
| 328 | ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1], | |
| 329 | ["maxBufferedBytes", resolved.maxBufferedBytes, 1], | |
| 330 | ["maxBufferedRecords", resolved.maxBufferedRecords, 1], | |
| 331 | ] as const; | |
| 332 | ||
| 333 | for (const [option, value, minimum] of validations) { | |
| 334 | const error = validateOption({ filePath, option, value, minimum }); | |
| 335 | if (error) return Effect.fail(error); | |
| 336 | } | |
| 337 | return Effect.succeed(resolved); | |
| 338 | } | |
| 149 | 339 | |
| 150 | if (!flushResult.ok) { | |
| 151 | yield* logWarning("provider event log batch flush failed", { | |
| 152 | filePath: input.filePath, | |
| 153 | errorTag: errorTag(flushResult.error), | |
| 340 | function drainPending(input: { | |
| 341 | readonly directory: string; | |
| 342 | readonly options: ResolvedOptions; | |
| 343 | readonly state: StoreState; | |
| 344 | readonly filePrefix: string; | |
| 345 | readonly now: number; | |
| 346 | readonly timerFired: boolean; | |
| 347 | readonly close: boolean; | |
| 348 | }): readonly [DrainResult, StoreState] { | |
| 349 | if (input.state.closed) { | |
| 350 | return [{ attributions: [], failures: [] }, input.state]; | |
| 351 | } | |
| 352 | ||
| 353 | const sinks = new Map(input.state.sinks); | |
| 354 | const failures: Array<FileOperationFailure> = []; | |
| 355 | const attributionByStream = new Map< | |
| 356 | EventNdjsonStream, | |
| 357 | { count: number; logicalWriteBytes: number } | |
| 358 | >(); | |
| 359 | const recordsBySegment = new Map<string, Array<PendingRecord>>(); | |
| 360 | ||
| 361 | for (const record of input.state.pending) { | |
| 362 | const records = recordsBySegment.get(record.threadSegment) ?? []; | |
| 363 | records.push(record); | |
| 364 | recordsBySegment.set(record.threadSegment, records); | |
| 365 | } | |
| 366 | ||
| 367 | for (const [threadSegment, records] of recordsBySegment) { | |
| 368 | const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment); | |
| 369 | let sink = sinks.get(threadSegment); | |
| 370 | if (!sink) { | |
| 371 | try { | |
| 372 | sink = new RotatingFileSink({ | |
| 373 | filePath, | |
| 374 | maxBytes: input.options.maxBytes, | |
| 375 | maxFiles: input.options.maxFiles, | |
| 376 | throwOnError: true, | |
| 154 | 377 | }); |
| 378 | sinks.set(threadSegment, sink); | |
| 379 | } catch (cause) { | |
| 380 | failures.push({ filePath, cause }); | |
| 381 | continue; | |
| 155 | 382 | } |
| 156 | }), | |
| 157 | }).pipe(Effect.provideService(Scope.Scope, scope)); | |
| 383 | } | |
| 158 | 384 | |
| 159 | const loggerLayer = Logger.layer([batchedLogger], { mergeWithExisting: false }); | |
| 385 | try { | |
| 386 | writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => { | |
| 387 | for (const record of writtenRecords) { | |
| 388 | const current = attributionByStream.get(record.stream) ?? { | |
| 389 | count: 0, | |
| 390 | logicalWriteBytes: 0, | |
| 391 | }; | |
| 392 | attributionByStream.set(record.stream, { | |
| 393 | count: current.count + 1, | |
| 394 | logicalWriteBytes: current.logicalWriteBytes + record.bytes, | |
| 395 | }); | |
| 396 | } | |
| 397 | }); | |
| 398 | } catch (cause) { | |
| 399 | sinks.delete(threadSegment); | |
| 400 | failures.push({ filePath, cause }); | |
| 401 | } | |
| 402 | } | |
| 160 | 403 | |
| 161 | return { | |
| 162 | writeMessage(message: string) { | |
| 163 | return Effect.log(message).pipe(Effect.provide(loggerLayer)); | |
| 404 | const retentionDue = | |
| 405 | input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; | |
| 406 | const retention = retentionDue | |
| 407 | ? enforceRetention({ | |
| 408 | directory: input.directory, | |
| 409 | maxTotalBytes: input.options.maxTotalBytes, | |
| 410 | maxAgeMs: input.options.maxAgeMs, | |
| 411 | activeFilePaths: new Set( | |
| 412 | Array.from(sinks.keys(), (threadSegment) => | |
| 413 | providerLogPath(input.directory, input.filePrefix, threadSegment), | |
| 414 | ), | |
| 415 | ), | |
| 416 | filePrefix: input.filePrefix, | |
| 417 | now: input.now, | |
| 418 | }) | |
| 419 | : { failures: [] }; | |
| 420 | ||
| 421 | return [ | |
| 422 | { | |
| 423 | attributions: Array.from(attributionByStream, ([stream, value]) => ({ | |
| 424 | stream, | |
| 425 | ...value, | |
| 426 | })), | |
| 427 | failures: [...failures, ...retention.failures], | |
| 164 | 428 | }, |
| 165 | close() { | |
| 166 | return Scope.close(scope, Exit.void); | |
| 429 | { | |
| 430 | pending: [], | |
| 431 | pendingBytes: 0, | |
| 432 | sinks, | |
| 433 | flushScheduled: input.timerFired ? false : input.state.flushScheduled, | |
| 434 | closed: input.close, | |
| 435 | lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt, | |
| 167 | 436 | }, |
| 168 | } satisfies ThreadWriter; | |
| 437 | ]; | |
| 438 | } | |
| 439 | ||
| 440 | const serializeEvent = Effect.fnUntraced(function* (event: unknown) { | |
| 441 | return yield* encodeUnknownJsonString(event).pipe( | |
| 442 | Effect.catch((error) => | |
| 443 | logWarning("failed to serialize provider event log record", { | |
| 444 | errorTag: errorTag(error), | |
| 445 | }).pipe(Effect.as(undefined)), | |
| 446 | ), | |
| 447 | ); | |
| 169 | 448 | }); |
| 170 | 449 | |
| 171 | export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function* ( | |
| 450 | export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( | |
| 172 | 451 | filePath: string, |
| 173 | options: EventNdjsonLoggerOptions, | |
| 174 | ): Effect.fn.Return<EventNdjsonLogger | undefined> { | |
| 175 | const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; | |
| 176 | const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; | |
| 177 | const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS; | |
| 178 | const streamLabel = resolveStreamLabel(options.stream); | |
| 179 | ||
| 180 | const directoryReady = yield* Effect.sync(() => { | |
| 181 | try { | |
| 182 | NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true }); | |
| 183 | return true; | |
| 184 | } catch (error) { | |
| 185 | return { ok: false as const, error }; | |
| 186 | } | |
| 452 | options: EventNdjsonLogStoreOptions = {}, | |
| 453 | ): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> { | |
| 454 | const resolved = yield* resolveOptions(filePath, options); | |
| 455 | const directory = NodePath.dirname(filePath); | |
| 456 | const filePrefix = providerLogPrefix(filePath); | |
| 457 | ||
| 458 | yield* Effect.try({ | |
| 459 | try: () => NodeFS.mkdirSync(directory, { recursive: true }), | |
| 460 | catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }), | |
| 187 | 461 | }); |
| 188 | if (directoryReady !== true) { | |
| 189 | yield* logWarning("failed to create provider event log directory", { | |
| 190 | filePath, | |
| 191 | errorTag: errorTag(directoryReady.error), | |
| 462 | ||
| 463 | const initializedAt = yield* Clock.currentTimeMillis; | |
| 464 | const initialRetention = yield* Effect.sync(() => | |
| 465 | enforceRetention({ | |
| 466 | directory, | |
| 467 | maxTotalBytes: resolved.maxTotalBytes, | |
| 468 | maxAgeMs: resolved.maxAgeMs, | |
| 469 | activeFilePaths: new Set(), | |
| 470 | filePrefix, | |
| 471 | now: initializedAt, | |
| 472 | }), | |
| 473 | ); | |
| 474 | for (const failure of initialRetention.failures) { | |
| 475 | yield* logWarning("provider event log retention failed", { | |
| 476 | filePath: failure.filePath, | |
| 477 | errorTag: errorTag(failure.cause), | |
| 192 | 478 | }); |
| 193 | return undefined; | |
| 194 | 479 | } |
| 195 | 480 | |
| 196 | const stateRef = yield* SynchronizedRef.make<LoggerState>({ | |
| 197 | threadWriters: new Map(), | |
| 198 | failedSegments: new Set(), | |
| 481 | const stateRef = yield* SynchronizedRef.make<StoreState>({ | |
| 482 | pending: [], | |
| 483 | pendingBytes: 0, | |
| 484 | sinks: new Map(), | |
| 485 | flushScheduled: false, | |
| 486 | closed: false, | |
| 487 | lastRetentionAt: initializedAt, | |
| 199 | 488 | }); |
| 200 | ||
| 201 | const resolveThreadWriter = Effect.fn("resolveThreadWriter")(function* ( | |
| 202 | threadSegment: string, | |
| 203 | ): Effect.fn.Return<ThreadWriter | undefined> { | |
| 204 | return yield* SynchronizedRef.modifyEffect(stateRef, (state) => { | |
| 205 | if (state.failedSegments.has(threadSegment)) { | |
| 206 | return Effect.succeed([undefined, state] as const); | |
| 207 | } | |
| 208 | ||
| 209 | const existing = state.threadWriters.get(threadSegment); | |
| 210 | if (existing) { | |
| 211 | return Effect.succeed([existing, state] as const); | |
| 212 | } | |
| 213 | ||
| 214 | return makeThreadWriter({ | |
| 215 | filePath: NodePath.join(NodePath.dirname(filePath), `${threadSegment}.log`), | |
| 216 | maxBytes, | |
| 217 | maxFiles, | |
| 218 | batchWindowMs, | |
| 219 | streamLabel, | |
| 220 | }).pipe( | |
| 221 | Effect.map((writer) => { | |
| 222 | if (!writer) { | |
| 223 | const nextFailedSegments = new Set(state.failedSegments); | |
| 224 | nextFailedSegments.add(threadSegment); | |
| 225 | return [ | |
| 226 | undefined, | |
| 227 | { | |
| 228 | ...state, | |
| 229 | failedSegments: nextFailedSegments, | |
| 230 | }, | |
| 231 | ] as const; | |
| 232 | } | |
| 233 | ||
| 234 | const nextThreadWriters = new Map(state.threadWriters); | |
| 235 | nextThreadWriters.set(threadSegment, writer); | |
| 236 | return [ | |
| 237 | writer, | |
| 238 | { | |
| 239 | ...state, | |
| 240 | threadWriters: nextThreadWriters, | |
| 241 | }, | |
| 242 | ] as const; | |
| 489 | const timerScope = yield* Scope.make(); | |
| 490 | ||
| 491 | const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) { | |
| 492 | const startedAt = yield* Clock.currentTimeMillis; | |
| 493 | const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) => | |
| 494 | Effect.sync(() => | |
| 495 | drainPending({ | |
| 496 | directory, | |
| 497 | options: resolved, | |
| 498 | state, | |
| 499 | filePrefix, | |
| 500 | now: startedAt, | |
| 501 | timerFired, | |
| 502 | close, | |
| 243 | 503 | }), |
| 244 | ); | |
| 245 | }); | |
| 246 | }); | |
| 504 | ), | |
| 505 | ); | |
| 247 | 506 | |
| 248 | const write = Effect.fn("write")(function* (event: unknown, threadId: ThreadId | null) { | |
| 249 | const threadSegment = resolveThreadSegment(threadId); | |
| 250 | const message = yield* toLogMessage(event); | |
| 251 | if (!message) { | |
| 252 | return; | |
| 507 | for (const failure of result.failures) { | |
| 508 | yield* logWarning("provider event log write or retention failed", { | |
| 509 | filePath: failure.filePath, | |
| 510 | errorTag: errorTag(failure.cause), | |
| 511 | }); | |
| 253 | 512 | } |
| 254 | 513 | |
| 255 | const writer = yield* resolveThreadWriter(threadSegment); | |
| 256 | if (!writer) { | |
| 257 | return; | |
| 514 | if (resolved.attribution && result.attributions.length > 0) { | |
| 515 | const completedAt = yield* Clock.currentTimeMillis; | |
| 516 | const durationMs = Math.max(0, completedAt - startedAt); | |
| 517 | const totalBytes = result.attributions.reduce( | |
| 518 | (total, entry) => total + entry.logicalWriteBytes, | |
| 519 | 0, | |
| 520 | ); | |
| 521 | yield* Effect.forEach( | |
| 522 | result.attributions, | |
| 523 | (entry) => | |
| 524 | resolved.attribution?.record({ | |
| 525 | component: "provider-event-log", | |
| 526 | operation: `${entry.stream}.append`, | |
| 527 | logicalWriteBytes: entry.logicalWriteBytes, | |
| 528 | count: entry.count, | |
| 529 | durationMs: | |
| 530 | totalBytes === 0 | |
| 531 | ? 0 | |
| 532 | : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)), | |
| 533 | }) ?? Effect.void, | |
| 534 | { discard: true }, | |
| 535 | ); | |
| 258 | 536 | } |
| 537 | }); | |
| 538 | ||
| 539 | const scheduleFlush = Effect.fnUntraced(function* () { | |
| 540 | yield* Effect.forkIn( | |
| 541 | Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))), | |
| 542 | timerScope, | |
| 543 | { startImmediately: true }, | |
| 544 | ); | |
| 545 | }); | |
| 259 | 546 | |
| 260 | yield* writer.writeMessage(message); | |
| 547 | const close = Effect.fnUntraced(function* () { | |
| 548 | yield* flush(false, true); | |
| 549 | yield* Scope.close(timerScope, Exit.void); | |
| 261 | 550 | }); |
| 262 | 551 | |
| 263 | const close = Effect.fn("close")(function* () { | |
| 264 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => | |
| 265 | Effect.gen(function* () { | |
| 266 | for (const writer of state.threadWriters.values()) { | |
| 267 | yield* writer.close(); | |
| 552 | const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>(); | |
| 553 | const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { | |
| 554 | const existing = loggerViews.get(stream); | |
| 555 | if (existing) return existing; | |
| 556 | ||
| 557 | const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) { | |
| 558 | if (!shouldPersist(stream, event)) return; | |
| 559 | const payload = yield* serializeEvent(event); | |
| 560 | if (payload === undefined) return; | |
| 561 | ||
| 562 | const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); | |
| 563 | const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`; | |
| 564 | const bytes = Buffer.byteLength(line); | |
| 565 | const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => { | |
| 566 | if (state.closed) { | |
| 567 | return Effect.succeed([{ flush: false }, state] as const); | |
| 268 | 568 | } |
| 569 | const pending = [ | |
| 570 | ...state.pending, | |
| 571 | { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, | |
| 572 | ]; | |
| 573 | const pendingBytes = state.pendingBytes + bytes; | |
| 574 | const flush = | |
| 575 | resolved.batchWindowMs === 0 || | |
| 576 | pending.length >= resolved.maxBufferedRecords || | |
| 577 | pendingBytes >= resolved.maxBufferedBytes; | |
| 578 | const schedule = !flush && !state.flushScheduled; | |
| 579 | const nextState = { | |
| 580 | ...state, | |
| 581 | pending, | |
| 582 | pendingBytes, | |
| 583 | flushScheduled: state.flushScheduled || schedule, | |
| 584 | }; | |
| 585 | return (schedule ? scheduleFlush() : Effect.void).pipe( | |
| 586 | Effect.as([{ flush }, nextState] as const), | |
| 587 | ); | |
| 588 | }).pipe(Effect.uninterruptible); | |
| 589 | ||
| 590 | if (action.flush) { | |
| 591 | yield* flush(false, false); | |
| 592 | } | |
| 593 | }); | |
| 269 | 594 | |
| 270 | return [ | |
| 271 | undefined, | |
| 272 | { | |
| 273 | threadWriters: new Map<string, ThreadWriter>(), | |
| 274 | failedSegments: new Set<string>(), | |
| 275 | }, | |
| 276 | ] as const; | |
| 277 | }), | |
| 278 | ); | |
| 279 | }); | |
| 595 | const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; | |
| 596 | loggerViews.set(stream, view); | |
| 597 | return view; | |
| 598 | }; | |
| 280 | 599 | |
| 281 | return { | |
| 282 | filePath, | |
| 283 | write, | |
| 284 | close, | |
| 285 | } satisfies EventNdjsonLogger; | |
| 600 | return { filePath, logger, close } satisfies EventNdjsonLogStore; | |
| 601 | }); | |
| 602 | ||
| 603 | export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( | |
| 604 | filePath: string, | |
| 605 | options: EventNdjsonLoggerOptions, | |
| 606 | ): Effect.fn.Return<EventNdjsonLogger | undefined> { | |
| 607 | const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( | |
| 608 | Effect.catch((error) => | |
| 609 | logWarning(error.message, { error }).pipe( | |
| 610 | Effect.as<EventNdjsonLogStore | undefined>(undefined), | |
| 611 | ), | |
| 612 | ), | |
| 613 | ); | |
| 614 | if (!store) return undefined; | |
| 615 | return { ...store.logger(options.stream), close: store.close }; | |
| 286 | 616 | }); |
apps/server/src/provider/Layers/ProviderEventLoggers.ts+43-38| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | /** |
| 2 | * ProviderEventLoggers — single observability service that owns the two | |
| 3 | * shared NDJSON streams the provider runtime writes: | |
| 2 | * ProviderEventLoggers — single observability service that owns the shared | |
| 3 | * provider event log store and exposes its two runtime views: | |
| 4 | 4 | * |
| 5 | 5 | * - `native` — provider-protocol events as the SDK emits them, written |
| 6 | 6 | * from inside each `<X>Adapter` factory. |
| ... | ... | @@ -13,17 +13,14 @@ |
| 13 | 13 | * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)` |
| 14 | 14 | * call site where we can hand an `EventNdjsonLogger` in by hand. |
| 15 | 15 | * - Multiple driver instances per kind (`codex_personal`, `codex_work`) |
| 16 | * should share one underlying log writer per stream — opening N writers | |
| 17 | * against the same rotating file would race the rotation logic. Owning | |
| 18 | * the loggers on a single tag keeps that invariant intact. | |
| 16 | * must share one underlying log store — opening N writers against the | |
| 17 | * same rotating file would race the rotation logic. Owning the loggers on | |
| 18 | * a single tag keeps that invariant intact. | |
| 19 | 19 | * - Tests can swap one (or both) loggers with in-memory recorders by |
| 20 | 20 | * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of |
| 21 | 21 | * juggling per-Layer option threading. |
| 22 | 22 | * |
| 23 | * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when | |
| 24 | * the target directory cannot be created; we forward that as `undefined` | |
| 25 | * rather than failing the boot Layer, matching the previous best-effort | |
| 26 | * behavior of `server.ts`. | |
| 23 | * Both fields are optional because observability must not prevent startup. | |
| 27 | 24 | * |
| 28 | 25 | * @module provider/Layers/ProviderEventLoggers |
| 29 | 26 | */ |
| ... | ... | @@ -32,24 +29,23 @@ import * as Effect from "effect/Effect"; |
| 32 | 29 | import * as Layer from "effect/Layer"; |
| 33 | 30 | |
| 34 | 31 | import { ServerConfig } from "../../config.ts"; |
| 35 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 36 | ||
| 37 | export interface ProviderEventLoggersShape { | |
| 38 | readonly native: EventNdjsonLogger | undefined; | |
| 39 | readonly canonical: EventNdjsonLogger | undefined; | |
| 40 | } | |
| 32 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | |
| 33 | import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; | |
| 41 | 34 | |
| 42 | 35 | /** |
| 43 | 36 | * Shared logger pair for native + canonical provider event streams. |
| 44 | 37 | * |
| 45 | 38 | * Service value is intentionally a struct of two optional loggers rather |
| 46 | 39 | * than two parallel tags. Construction site is one place |
| 47 | * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read | |
| 48 | * one tag and pluck the field they need. | |
| 40 | * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the | |
| 41 | * field they need. | |
| 49 | 42 | */ |
| 50 | 43 | export class ProviderEventLoggers extends Context.Service< |
| 51 | 44 | ProviderEventLoggers, |
| 52 | ProviderEventLoggersShape | |
| 45 | { | |
| 46 | readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; | |
| 47 | readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; | |
| 48 | } | |
| 53 | 49 | >()("t3/provider/Layers/ProviderEventLoggers") {} |
| 54 | 50 | |
| 55 | 51 | /** |
| ... | ... | @@ -57,29 +53,38 @@ export class ProviderEventLoggers extends Context.Service< |
| 57 | 53 | * + canonical logging entirely. Keeps the tag non-optional in the type |
| 58 | 54 | * system while letting the runtime treat absence as a no-op. |
| 59 | 55 | */ |
| 60 | export const NoOpProviderEventLoggers: ProviderEventLoggersShape = { | |
| 56 | export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { | |
| 61 | 57 | native: undefined, |
| 62 | 58 | canonical: undefined, |
| 63 | 59 | }; |
| 64 | 60 | |
| 65 | 61 | /** |
| 66 | * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`. | |
| 67 | * If the directory create fails for either stream, the corresponding field | |
| 68 | * is `undefined` and writes from that stream become no-ops downstream. | |
| 62 | * Builds both stream views over one shared store. Setup failures are logged | |
| 63 | * and downgraded to the no-op service so diagnostics never block startup. | |
| 69 | 64 | */ |
| 70 | export const ProviderEventLoggersLive = Layer.effect( | |
| 71 | ProviderEventLoggers, | |
| 72 | Effect.gen(function* () { | |
| 73 | const { providerEventLogPath } = yield* ServerConfig; | |
| 74 | const native = yield* makeEventNdjsonLogger(providerEventLogPath, { | |
| 75 | stream: "native", | |
| 76 | }); | |
| 77 | const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { | |
| 78 | stream: "canonical", | |
| 79 | }); | |
| 80 | return { | |
| 81 | native, | |
| 82 | canonical, | |
| 83 | } satisfies ProviderEventLoggersShape; | |
| 84 | }), | |
| 85 | ); | |
| 65 | export const make = Effect.gen(function* () { | |
| 66 | const { providerEventLogPath } = yield* ServerConfig; | |
| 67 | const attribution = yield* ResourceAttribution.ResourceAttribution; | |
| 68 | const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { | |
| 69 | attribution, | |
| 70 | }).pipe( | |
| 71 | Effect.catch((error) => | |
| 72 | Effect.logWarning(error.message, { error }).pipe( | |
| 73 | Effect.annotateLogs({ scope: "provider-observability" }), | |
| 74 | Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined), | |
| 75 | ), | |
| 76 | ), | |
| 77 | ); | |
| 78 | ||
| 79 | if (!store) { | |
| 80 | return ProviderEventLoggers.of(NoOpProviderEventLoggers); | |
| 81 | } | |
| 82 | ||
| 83 | yield* Effect.addFinalizer(() => store.close()); | |
| 84 | return ProviderEventLoggers.of({ | |
| 85 | native: store.logger("native"), | |
| 86 | canonical: store.logger("canonical"), | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | export const layer = Layer.effect(ProviderEventLoggers, make); |
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+36| ... | ... | @@ -34,10 +34,13 @@ import { |
| 34 | 34 | type ProviderInstanceConfigMap, |
| 35 | 35 | ProviderInstanceId, |
| 36 | 36 | } from "@t3tools/contracts"; |
| 37 | import * as DateTime from "effect/DateTime"; | |
| 37 | 38 | import * as Effect from "effect/Effect"; |
| 38 | 39 | import * as Layer from "effect/Layer"; |
| 40 | import * as Stream from "effect/Stream"; | |
| 39 | 41 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 40 | 42 | |
| 43 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 41 | 44 | import { ServerConfig } from "../../config.ts"; |
| 42 | 45 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 43 | 46 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; |
| ... | ... | @@ -56,6 +59,37 @@ const TestHttpClientLive = Layer.succeed( |
| 56 | 59 | ), |
| 57 | 60 | ); |
| 58 | 61 | |
| 62 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | |
| 63 | ||
| 64 | const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 65 | reportClientActivity: () => Effect.void, | |
| 66 | removeRpcClient: () => Effect.void, | |
| 67 | reportHostPowerState: () => Effect.void, | |
| 68 | snapshot: Effect.succeed({ | |
| 69 | hostPower: { | |
| 70 | source: "unknown", | |
| 71 | idle: "unknown", | |
| 72 | idleSeconds: null, | |
| 73 | locked: "unknown", | |
| 74 | suspended: false, | |
| 75 | onBattery: "unknown", | |
| 76 | lowPowerMode: "unknown", | |
| 77 | thermalState: "unknown", | |
| 78 | stale: true, | |
| 79 | updatedAt: TEST_EPOCH, | |
| 80 | }, | |
| 81 | leases: [], | |
| 82 | activeForegroundLeaseCount: 0, | |
| 83 | activeScopeKeys: [], | |
| 84 | shouldRunOpportunisticWork: true, | |
| 85 | updatedAt: TEST_EPOCH, | |
| 86 | }), | |
| 87 | streamChanges: Stream.empty, | |
| 88 | hasDemand: () => Effect.succeed(true), | |
| 89 | shouldRunScopeWork: () => Effect.succeed(true), | |
| 90 | shouldRunOpportunisticWork: Effect.succeed(true), | |
| 91 | }); | |
| 92 | ||
| 59 | 93 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ |
| 60 | 94 | enabled: false, |
| 61 | 95 | binaryPath: "codex", |
| ... | ... | @@ -109,6 +143,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { |
| 109 | 143 | prefix: "provider-instance-registry-test", |
| 110 | 144 | }).pipe( |
| 111 | 145 | Layer.provideMerge(NodeServices.layer), |
| 146 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 112 | 147 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 113 | 148 | Layer.provideMerge(TestHttpClientLive), |
| 114 | 149 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| ... | ... | @@ -247,6 +282,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 247 | 282 | prefix: "provider-instance-registry-all-drivers-test", |
| 248 | 283 | }).pipe( |
| 249 | 284 | Layer.provideMerge(infraLayer), |
| 285 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 250 | 286 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 251 | 287 | Layer.provideMerge(TestHttpClientLive), |
| 252 | 288 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+44| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { describe, it, assert } from "@effect/vitest"; |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Exit from "effect/Exit"; |
| 5 | 6 | import * as Fiber from "effect/Fiber"; |
| ... | ... | @@ -32,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 32 | 33 | |
| 33 | 34 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 34 | 35 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; |
| 36 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 35 | 37 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 36 | 38 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; |
| 37 | 39 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; |
| ... | ... | @@ -65,6 +67,7 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; |
| 65 | 67 | // ── Test helpers ──────────────────────────────────────────────────── |
| 66 | 68 | |
| 67 | 69 | const encoder = new TextEncoder(); |
| 70 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | |
| 68 | 71 | |
| 69 | 72 | const TestHttpClientLive = Layer.succeed( |
| 70 | 73 | HttpClient.HttpClient, |
| ... | ... | @@ -73,6 +76,35 @@ const TestHttpClientLive = Layer.succeed( |
| 73 | 76 | ), |
| 74 | 77 | ); |
| 75 | 78 | |
| 79 | const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 80 | reportClientActivity: () => Effect.void, | |
| 81 | removeRpcClient: () => Effect.void, | |
| 82 | reportHostPowerState: () => Effect.void, | |
| 83 | snapshot: Effect.succeed({ | |
| 84 | hostPower: { | |
| 85 | source: "unknown", | |
| 86 | idle: "unknown", | |
| 87 | idleSeconds: null, | |
| 88 | locked: "unknown", | |
| 89 | suspended: false, | |
| 90 | onBattery: "unknown", | |
| 91 | lowPowerMode: "unknown", | |
| 92 | thermalState: "unknown", | |
| 93 | stale: true, | |
| 94 | updatedAt: TEST_EPOCH, | |
| 95 | }, | |
| 96 | leases: [], | |
| 97 | activeForegroundLeaseCount: 0, | |
| 98 | activeScopeKeys: [], | |
| 99 | shouldRunOpportunisticWork: true, | |
| 100 | updatedAt: TEST_EPOCH, | |
| 101 | }), | |
| 102 | streamChanges: Stream.empty, | |
| 103 | hasDemand: () => Effect.succeed(true), | |
| 104 | shouldRunScopeWork: () => Effect.succeed(true), | |
| 105 | shouldRunOpportunisticWork: Effect.succeed(true), | |
| 106 | }); | |
| 107 | ||
| 76 | 108 | function selectDescriptor( |
| 77 | 109 | id: string, |
| 78 | 110 | label: string, |
| ... | ... | @@ -297,6 +329,11 @@ function makeMutableServerSettingsService( |
| 297 | 329 | get streamChanges() { |
| 298 | 330 | return Stream.fromPubSub(changes); |
| 299 | 331 | }, |
| 332 | get subscribeChanges() { | |
| 333 | return PubSub.subscribe(changes).pipe( | |
| 334 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 335 | ); | |
| 336 | }, | |
| 300 | 337 | } satisfies ServerSettingsModule.ServerSettingsService["Service"]; |
| 301 | 338 | }); |
| 302 | 339 | } |
| ... | ... | @@ -1000,6 +1037,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1000 | 1037 | prefix: "t3-provider-registry-merged-persist-", |
| 1001 | 1038 | }), |
| 1002 | 1039 | ), |
| 1040 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 1003 | 1041 | Layer.provideMerge(NodeServices.layer), |
| 1004 | 1042 | ), |
| 1005 | 1043 | ).pipe(Scope.provide(scope)); |
| ... | ... | @@ -1235,6 +1273,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1235 | 1273 | prefix: "t3-provider-registry-refresh-failure-", |
| 1236 | 1274 | }), |
| 1237 | 1275 | ), |
| 1276 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 1238 | 1277 | Layer.provideMerge(NodeServices.layer), |
| 1239 | 1278 | ), |
| 1240 | 1279 | ).pipe(Scope.provide(scope)); |
| ... | ... | @@ -1342,6 +1381,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1342 | 1381 | prefix: "t3-provider-registry-sync-failure-", |
| 1343 | 1382 | }), |
| 1344 | 1383 | ), |
| 1384 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 1345 | 1385 | Layer.provideMerge(NodeServices.layer), |
| 1346 | 1386 | ), |
| 1347 | 1387 | ).pipe(Scope.provide(scope)); |
| ... | ... | @@ -1446,6 +1486,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1446 | 1486 | ), |
| 1447 | 1487 | ), |
| 1448 | 1488 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1489 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 1449 | 1490 | // NO spawner mock — `ChildProcessSpawner` is supplied by the |
| 1450 | 1491 | // outer `NodeServices.layer` on `it.layer(...)` and will |
| 1451 | 1492 | // genuinely spawn a subprocess. The missing-binary ENOENT is |
| ... | ... | @@ -1545,6 +1586,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1545 | 1586 | }), |
| 1546 | 1587 | ), |
| 1547 | 1588 | Layer.provideMerge(NodeServices.layer), |
| 1589 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 1548 | 1590 | ); |
| 1549 | 1591 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| 1550 | 1592 | Scope.provide(scope), |
| ... | ... | @@ -1660,6 +1702,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1660 | 1702 | ), |
| 1661 | 1703 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1662 | 1704 | Layer.provideMerge(NodeServices.layer), |
| 1705 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 1663 | 1706 | ); |
| 1664 | 1707 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| 1665 | 1708 | Scope.provide(scope), |
| ... | ... | @@ -1720,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1720 | 1763 | ), |
| 1721 | 1764 | ), |
| 1722 | 1765 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1766 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | |
| 1723 | 1767 | Layer.provideMerge( |
| 1724 | 1768 | mockCommandSpawnerLayer((command, args) => { |
| 1725 | 1769 | if (command === "cursor-agent") { |
apps/server/src/provider/makeManagedServerProvider.test.ts+172-5| ... | ... | @@ -1,16 +1,28 @@ |
| 1 | 1 | import { describe, it, assert } from "@effect/vitest"; |
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | DEFAULT_SERVER_SETTINGS, | |
| 4 | ProviderDriverKind, | |
| 5 | ProviderInstanceId, | |
| 6 | type ServerProvider, | |
| 7 | } from "@t3tools/contracts"; | |
| 3 | 8 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 9 | import * as DateTime from "effect/DateTime"; | |
| 4 | 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Duration from "effect/Duration"; | |
| 5 | 12 | import * as Effect from "effect/Effect"; |
| 6 | 13 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as Layer from "effect/Layer"; | |
| 7 | 15 | import * as PubSub from "effect/PubSub"; |
| 8 | 16 | import * as Ref from "effect/Ref"; |
| 9 | 17 | import * as Stream from "effect/Stream"; |
| 18 | import { TestClock } from "effect/testing"; | |
| 10 | 19 | |
| 20 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | |
| 21 | import { ServerSettingsService } from "../serverSettings.ts"; | |
| 11 | 22 | import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; |
| 12 | 23 | |
| 13 | 24 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); |
| 25 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | |
| 14 | 26 | const fastModeCapabilities = createModelCapabilities({ |
| 15 | 27 | optionDescriptors: [ |
| 16 | 28 | { |
| ... | ... | @@ -87,6 +99,43 @@ const refreshedSnapshotSecond: ServerProvider = { |
| 87 | 99 | message: "Refreshed provider availability again.", |
| 88 | 100 | }; |
| 89 | 101 | |
| 102 | function makeBackgroundPolicyLayer(shouldRunScopeWork: boolean) { | |
| 103 | return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 104 | reportClientActivity: () => Effect.void, | |
| 105 | removeRpcClient: () => Effect.void, | |
| 106 | reportHostPowerState: () => Effect.void, | |
| 107 | snapshot: Effect.succeed({ | |
| 108 | hostPower: { | |
| 109 | source: "unknown", | |
| 110 | idle: "unknown", | |
| 111 | idleSeconds: null, | |
| 112 | locked: "unknown", | |
| 113 | suspended: false, | |
| 114 | onBattery: "unknown", | |
| 115 | lowPowerMode: "unknown", | |
| 116 | thermalState: "unknown", | |
| 117 | stale: true, | |
| 118 | updatedAt: TEST_EPOCH, | |
| 119 | }, | |
| 120 | leases: [], | |
| 121 | activeForegroundLeaseCount: 0, | |
| 122 | activeScopeKeys: [], | |
| 123 | shouldRunOpportunisticWork: true, | |
| 124 | updatedAt: TEST_EPOCH, | |
| 125 | }), | |
| 126 | streamChanges: Stream.empty, | |
| 127 | hasDemand: () => Effect.succeed(shouldRunScopeWork), | |
| 128 | shouldRunScopeWork: () => Effect.succeed(shouldRunScopeWork), | |
| 129 | shouldRunOpportunisticWork: Effect.succeed(shouldRunScopeWork), | |
| 130 | }); | |
| 131 | } | |
| 132 | ||
| 133 | const BackgroundPolicyAlwaysRunLayer = makeBackgroundPolicyLayer(true); | |
| 134 | const BackgroundPolicyNeverRunLayer = makeBackgroundPolicyLayer(false); | |
| 135 | const ServerSettingsTestLayer = ServerSettingsService.layerTest(); | |
| 136 | const AlwaysRunTestLayer = Layer.merge(BackgroundPolicyAlwaysRunLayer, ServerSettingsTestLayer); | |
| 137 | const NeverRunTestLayer = Layer.merge(BackgroundPolicyNeverRunLayer, ServerSettingsTestLayer); | |
| 138 | ||
| 90 | 139 | const enrichedSnapshotSecond: ServerProvider = { |
| 91 | 140 | ...refreshedSnapshotSecond, |
| 92 | 141 | checkedAt: "2026-04-10T00:00:04.000Z", |
| ... | ... | @@ -140,7 +189,125 @@ describe("makeManagedServerProvider", () => { |
| 140 | 189 | assert.deepStrictEqual(latest, refreshedSnapshot); |
| 141 | 190 | assert.strictEqual(yield* Ref.get(checkCalls), 1); |
| 142 | 191 | }), |
| 143 | ), | |
| 192 | ).pipe(Effect.provide(AlwaysRunTestLayer)), | |
| 193 | ); | |
| 194 | ||
| 195 | it.effect("skips periodic provider refreshes without foreground provider-status demand", () => | |
| 196 | Effect.scoped( | |
| 197 | Effect.gen(function* () { | |
| 198 | const checkCalls = yield* Ref.make(0); | |
| 199 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 200 | yield* makeManagedServerProvider<TestSettings>({ | |
| 201 | maintenanceCapabilities, | |
| 202 | getSettings: Effect.succeed({ enabled: true }), | |
| 203 | streamSettings: Stream.empty, | |
| 204 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 205 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 206 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 207 | Effect.tap((count) => | |
| 208 | count === 1 | |
| 209 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 210 | : Effect.void, | |
| 211 | ), | |
| 212 | Effect.as(refreshedSnapshot), | |
| 213 | ), | |
| 214 | refreshInterval: "1 second", | |
| 215 | }); | |
| 216 | ||
| 217 | yield* Deferred.await(initialCheckDone); | |
| 218 | yield* TestClock.adjust("1 second"); | |
| 219 | yield* Effect.yieldNow; | |
| 220 | ||
| 221 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 222 | }), | |
| 223 | ).pipe(Effect.provide(Layer.mergeAll(NeverRunTestLayer, TestClock.layer()))), | |
| 224 | ); | |
| 225 | ||
| 226 | it.effect("disables periodic provider refreshes when the explicit interval is zero", () => | |
| 227 | Effect.scoped( | |
| 228 | Effect.gen(function* () { | |
| 229 | const checkCalls = yield* Ref.make(0); | |
| 230 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 231 | yield* makeManagedServerProvider<TestSettings>({ | |
| 232 | maintenanceCapabilities, | |
| 233 | getSettings: Effect.succeed({ enabled: true }), | |
| 234 | streamSettings: Stream.empty, | |
| 235 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 236 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 237 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 238 | Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), | |
| 239 | Effect.as(refreshedSnapshot), | |
| 240 | ), | |
| 241 | refreshInterval: 0, | |
| 242 | }); | |
| 243 | ||
| 244 | yield* Deferred.await(initialCheckDone); | |
| 245 | yield* TestClock.adjust("5 minutes"); | |
| 246 | yield* Effect.yieldNow; | |
| 247 | ||
| 248 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 249 | }), | |
| 250 | ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), | |
| 251 | ); | |
| 252 | ||
| 253 | it.effect("wakes a sleeping provider refresh loop when its interval changes", () => | |
| 254 | Effect.scoped( | |
| 255 | Effect.gen(function* () { | |
| 256 | const initialServerSettings = { | |
| 257 | ...DEFAULT_SERVER_SETTINGS, | |
| 258 | providerHealthRefreshInterval: Duration.hours(1), | |
| 259 | }; | |
| 260 | const serverSettingsRef = yield* Ref.make(initialServerSettings); | |
| 261 | const serverSettingsChanges = yield* PubSub.unbounded<typeof initialServerSettings>(); | |
| 262 | const serverSettingsLayer = Layer.succeed( | |
| 263 | ServerSettingsService, | |
| 264 | ServerSettingsService.of({ | |
| 265 | start: Effect.void, | |
| 266 | ready: Effect.void, | |
| 267 | getSettings: Ref.get(serverSettingsRef), | |
| 268 | updateSettings: () => Effect.die(new Error("unused in this test")), | |
| 269 | streamChanges: Stream.empty, | |
| 270 | subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe( | |
| 271 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 272 | ), | |
| 273 | }), | |
| 274 | ); | |
| 275 | const checkCalls = yield* Ref.make(0); | |
| 276 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 277 | const periodicCheckDone = yield* Deferred.make<void>(); | |
| 278 | ||
| 279 | yield* makeManagedServerProvider<TestSettings>({ | |
| 280 | maintenanceCapabilities, | |
| 281 | getSettings: Effect.succeed({ enabled: true }), | |
| 282 | streamSettings: Stream.empty, | |
| 283 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 284 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 285 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 286 | Effect.tap((count) => | |
| 287 | count === 1 | |
| 288 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 289 | : Deferred.succeed(periodicCheckDone, undefined).pipe(Effect.ignore), | |
| 290 | ), | |
| 291 | Effect.as(refreshedSnapshot), | |
| 292 | ), | |
| 293 | }).pipe(Effect.provide(Layer.merge(BackgroundPolicyAlwaysRunLayer, serverSettingsLayer))); | |
| 294 | ||
| 295 | yield* Deferred.await(initialCheckDone); | |
| 296 | const nextServerSettings = { | |
| 297 | ...initialServerSettings, | |
| 298 | providerHealthRefreshInterval: Duration.seconds(1), | |
| 299 | }; | |
| 300 | yield* Ref.set(serverSettingsRef, nextServerSettings); | |
| 301 | yield* PubSub.publish(serverSettingsChanges, nextServerSettings); | |
| 302 | yield* Effect.yieldNow; | |
| 303 | ||
| 304 | yield* TestClock.adjust("999 millis"); | |
| 305 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 306 | yield* TestClock.adjust("1 millis"); | |
| 307 | yield* Deferred.await(periodicCheckDone); | |
| 308 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | |
| 309 | }), | |
| 310 | ).pipe(Effect.provide(TestClock.layer())), | |
| 144 | 311 | ); |
| 145 | 312 | |
| 146 | 313 | it.effect("reruns the provider check when streamed settings change", () => |
| ... | ... | @@ -185,7 +352,7 @@ describe("makeManagedServerProvider", () => { |
| 185 | 352 | assert.deepStrictEqual(latest, refreshedSnapshotSecond); |
| 186 | 353 | assert.strictEqual(yield* Ref.get(checkCalls), 2); |
| 187 | 354 | }), |
| 188 | ), | |
| 355 | ).pipe(Effect.provide(AlwaysRunTestLayer)), | |
| 189 | 356 | ); |
| 190 | 357 | |
| 191 | 358 | it.effect("streams supplemental snapshot updates after the base provider check completes", () => |
| ... | ... | @@ -223,7 +390,7 @@ describe("makeManagedServerProvider", () => { |
| 223 | 390 | assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); |
| 224 | 391 | assert.deepStrictEqual(latest, enrichedSnapshot); |
| 225 | 392 | }), |
| 226 | ), | |
| 393 | ).pipe(Effect.provide(AlwaysRunTestLayer)), | |
| 227 | 394 | ); |
| 228 | 395 | |
| 229 | 396 | it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => |
| ... | ... | @@ -284,6 +451,6 @@ describe("makeManagedServerProvider", () => { |
| 284 | 451 | ]); |
| 285 | 452 | assert.deepStrictEqual(latest, enrichedSnapshotSecond); |
| 286 | 453 | }), |
| 287 | ), | |
| 454 | ).pipe(Effect.provide(AlwaysRunTestLayer)), | |
| 288 | 455 | ); |
| 289 | 456 | }); |
apps/server/src/provider/makeManagedServerProvider.ts+73-5| ... | ... | @@ -1,16 +1,23 @@ |
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, | |
| 3 | type ServerProvider, | |
| 4 | ServerSettingsError, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 2 | 7 | import * as Duration from "effect/Duration"; |
| 3 | 8 | import * as Effect from "effect/Effect"; |
| 4 | 9 | import * as Equal from "effect/Equal"; |
| 5 | 10 | import * as Fiber from "effect/Fiber"; |
| 6 | 11 | import * as PubSub from "effect/PubSub"; |
| 12 | import * as Queue from "effect/Queue"; | |
| 7 | 13 | import * as Ref from "effect/Ref"; |
| 8 | 14 | import * as Scope from "effect/Scope"; |
| 9 | 15 | import * as Stream from "effect/Stream"; |
| 10 | 16 | import * as Semaphore from "effect/Semaphore"; |
| 11 | 17 | |
| 18 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | |
| 19 | import { ServerSettingsService } from "../serverSettings.ts"; | |
| 12 | 20 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; |
| 13 | import { ServerSettingsError } from "@t3tools/contracts"; | |
| 14 | 21 | |
| 15 | 22 | interface ProviderSnapshotState { |
| 16 | 23 | readonly snapshot: ServerProvider; |
| ... | ... | @@ -33,7 +40,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 33 | 40 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; |
| 34 | 41 | }) => Effect.Effect<void>; |
| 35 | 42 | readonly refreshInterval?: Duration.Input; |
| 36 | }): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope> { | |
| 43 | }): Effect.fn.Return< | |
| 44 | ServerProviderShape, | |
| 45 | ServerSettingsError, | |
| 46 | Scope.Scope | BackgroundPolicy.BackgroundPolicy | ServerSettingsService | |
| 47 | > { | |
| 48 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 49 | const serverSettings = yield* ServerSettingsService; | |
| 37 | 50 | const refreshSemaphore = yield* Semaphore.make(1); |
| 38 | 51 | const changesPubSub = yield* Effect.acquireRelease( |
| 39 | 52 | PubSub.unbounded<ServerProvider>(), |
| ... | ... | @@ -134,13 +147,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 134 | 147 | return yield* applySnapshot(nextSettings, { forceRefresh: true }); |
| 135 | 148 | }); |
| 136 | 149 | |
| 150 | const hasProviderStatusDemand = Effect.gen(function* () { | |
| 151 | const state = yield* Ref.get(snapshotStateRef); | |
| 152 | const instanceId = state.snapshot.instanceId; | |
| 153 | const [genericDemand, instanceDemand] = yield* Effect.all([ | |
| 154 | backgroundPolicy.shouldRunScopeWork({ type: "provider-status" }), | |
| 155 | backgroundPolicy.shouldRunScopeWork({ type: "provider-status", instanceId }), | |
| 156 | ]); | |
| 157 | return genericDemand || instanceDemand; | |
| 158 | }); | |
| 159 | ||
| 160 | const getRefreshInterval = | |
| 161 | input.refreshInterval !== undefined | |
| 162 | ? Effect.succeed(input.refreshInterval) | |
| 163 | : serverSettings.getSettings.pipe( | |
| 164 | Effect.map( | |
| 165 | (settings) => | |
| 166 | resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, | |
| 167 | ), | |
| 168 | Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL), | |
| 169 | ); | |
| 170 | ||
| 171 | const refreshIntervalChanges = yield* Queue.sliding<void>(1); | |
| 172 | if (input.refreshInterval === undefined) { | |
| 173 | const serverSettingsChanges = yield* serverSettings.subscribeChanges; | |
| 174 | yield* serverSettingsChanges.pipe( | |
| 175 | Stream.map((settings) => | |
| 176 | Duration.toMillis( | |
| 177 | resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, | |
| 178 | ), | |
| 179 | ), | |
| 180 | Stream.changes, | |
| 181 | Stream.runForEach(() => Queue.offer(refreshIntervalChanges, undefined).pipe(Effect.asVoid)), | |
| 182 | Effect.forkScoped, | |
| 183 | ); | |
| 184 | } | |
| 185 | ||
| 137 | 186 | yield* Stream.runForEach(input.streamSettings, (nextSettings) => |
| 138 | 187 | Effect.asVoid(applySnapshot(nextSettings)), |
| 139 | 188 | ).pipe(Effect.forkScoped); |
| 140 | 189 | |
| 141 | 190 | yield* Effect.forever( |
| 142 | Effect.sleep(input.refreshInterval ?? "60 seconds").pipe( | |
| 143 | Effect.flatMap(() => refreshSnapshot()), | |
| 191 | getRefreshInterval.pipe( | |
| 192 | Effect.flatMap((refreshInterval) => | |
| 193 | Effect.raceFirst( | |
| 194 | Effect.sleep( | |
| 195 | Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) <= 0 | |
| 196 | ? "60 seconds" | |
| 197 | : refreshInterval, | |
| 198 | ).pipe(Effect.as(true)), | |
| 199 | Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), | |
| 200 | ).pipe( | |
| 201 | Effect.flatMap((intervalElapsed) => | |
| 202 | intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 | |
| 203 | ? hasProviderStatusDemand.pipe( | |
| 204 | Effect.flatMap((shouldRefresh) => | |
| 205 | shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, | |
| 206 | ), | |
| 207 | ) | |
| 208 | : Effect.void, | |
| 209 | ), | |
| 210 | ), | |
| 211 | ), | |
| 144 | 212 | Effect.ignoreCause({ log: true }), |
| 145 | 213 | ), |
| 146 | 214 | ).pipe(Effect.forkScoped); |
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | ||
| 6 | import { it } from "@effect/vitest"; | |
| 7 | import * as DateTime from "effect/DateTime"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as PubSub from "effect/PubSub"; | |
| 11 | import * as Ref from "effect/Ref"; | |
| 12 | import { assert, describe, expect } from "vite-plus/test"; | |
| 13 | ||
| 14 | import { | |
| 15 | type DesktopTelemetryReceiverHealth, | |
| 16 | initialDesktopTelemetryContactAt, | |
| 17 | isDesktopTelemetryContactStale, | |
| 18 | recordDesktopTelemetrySampleHealth, | |
| 19 | requireDesktopTelemetryWriteProgress, | |
| 20 | resolveDesktopTelemetrySnapshotStaleAfterMs, | |
| 21 | writeAllToFileDescriptor, | |
| 22 | } from "./DesktopTelemetryReceiver.ts"; | |
| 23 | ||
| 24 | describe("DesktopTelemetryReceiver", () => { | |
| 25 | it("degrades a hello-only stream after the first-sample deadline", () => { | |
| 26 | expect(isDesktopTelemetryContactStale(Option.some(1_000), 90_999)).toBe(false); | |
| 27 | expect(isDesktopTelemetryContactStale(Option.some(1_000), 91_000)).toBe(true); | |
| 28 | expect(isDesktopTelemetryContactStale(Option.none(), 1_000_000)).toBe(false); | |
| 29 | }); | |
| 30 | ||
| 31 | it("starts the stale deadline as soon as a telemetry descriptor is opened", () => { | |
| 32 | expect(initialDesktopTelemetryContactAt(7, 1_000)).toEqual(Option.some(1_000)); | |
| 33 | expect(initialDesktopTelemetryContactAt(undefined, 1_000)).toEqual(Option.none()); | |
| 34 | }); | |
| 35 | ||
| 36 | it("keeps the snapshot deadline beyond the configured idle polling interval", () => { | |
| 37 | expect(resolveDesktopTelemetrySnapshotStaleAfterMs(30_000, 120_000)).toBe(150_000); | |
| 38 | expect(resolveDesktopTelemetrySnapshotStaleAfterMs(60_000, 600_000)).toBe(630_000); | |
| 39 | expect(resolveDesktopTelemetrySnapshotStaleAfterMs(1_000, 1_000)).toBe(90_000); | |
| 40 | }); | |
| 41 | ||
| 42 | it.effect("publishes the latest sample timestamp while health remains healthy", () => | |
| 43 | Effect.scoped( | |
| 44 | Effect.gen(function* () { | |
| 45 | const initialSample = DateTime.makeUnsafe(1_000); | |
| 46 | const nextSample = DateTime.makeUnsafe(2_000); | |
| 47 | const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({ | |
| 48 | status: "healthy", | |
| 49 | lastSampleAt: Option.some(initialSample), | |
| 50 | lastError: Option.none<string>(), | |
| 51 | }); | |
| 52 | const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4); | |
| 53 | const subscription = yield* PubSub.subscribe(healthChanges); | |
| 54 | ||
| 55 | yield* recordDesktopTelemetrySampleHealth(health, healthChanges, nextSample); | |
| 56 | const published = yield* PubSub.take(subscription).pipe(Effect.timeout("1 second")); | |
| 57 | ||
| 58 | expect(DateTime.toEpochMillis(Option.getOrThrow(published.lastSampleAt))).toBe(2_000); | |
| 59 | }), | |
| 60 | ), | |
| 61 | ); | |
| 62 | ||
| 63 | it.effect("writes control messages through the asynchronous descriptor path", () => | |
| 64 | Effect.acquireUseRelease( | |
| 65 | Effect.sync(() => { | |
| 66 | const directory = NodeFS.mkdtempSync( | |
| 67 | NodePath.join(NodeOS.tmpdir(), "t3-desktop-telemetry-control-test-"), | |
| 68 | ); | |
| 69 | const path = NodePath.join(directory, "control.ndjson"); | |
| 70 | return { | |
| 71 | directory, | |
| 72 | path, | |
| 73 | fd: NodeFS.openSync(path, "w"), | |
| 74 | }; | |
| 75 | }), | |
| 76 | ({ fd, path }) => | |
| 77 | Effect.gen(function* () { | |
| 78 | const payload = Buffer.from('{"type":"setDiagnosticsDemand","enabled":true}\n'); | |
| 79 | yield* writeAllToFileDescriptor(fd, payload); | |
| 80 | NodeFS.fsyncSync(fd); | |
| 81 | ||
| 82 | assert.equal(NodeFS.readFileSync(path, "utf8"), payload.toString("utf8")); | |
| 83 | }), | |
| 84 | ({ directory, fd }) => | |
| 85 | Effect.sync(() => { | |
| 86 | NodeFS.closeSync(fd); | |
| 87 | NodeFS.rmSync(directory, { recursive: true, force: true }); | |
| 88 | }), | |
| 89 | ), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("models a zero-byte control write as a stalled descriptor", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | const error = yield* requireDesktopTelemetryWriteProgress(7, 42, 0).pipe(Effect.flip); | |
| 95 | ||
| 96 | expect(error._tag).toBe("DesktopTelemetryControlStalled"); | |
| 97 | expect(error.fd).toBe(7); | |
| 98 | expect(error.remainingBytes).toBe(42); | |
| 99 | expect(error.message).toBe( | |
| 100 | "Desktop telemetry control stalled on fd 7 with 42 bytes remaining.", | |
| 101 | ); | |
| 102 | yield* requireDesktopTelemetryWriteProgress(7, 42, 1); | |
| 103 | }), | |
| 104 | ); | |
| 105 | }); |
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts created+662| ... | ... | @@ -0,0 +1,662 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | ||
| 4 | import * as NodeStream from "@effect/platform-node/NodeStream"; | |
| 5 | import { | |
| 6 | DesktopHostTelemetryMessage, | |
| 7 | type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, | |
| 8 | type DesktopHostTelemetrySnapshot, | |
| 9 | DesktopTelemetryControlMessage, | |
| 10 | type ResourceTelemetrySourceStatus, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 13 | import * as Context from "effect/Context"; | |
| 14 | import * as DateTime from "effect/DateTime"; | |
| 15 | import * as Duration from "effect/Duration"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as PubSub from "effect/PubSub"; | |
| 20 | import * as Ref from "effect/Ref"; | |
| 21 | import * as Schema from "effect/Schema"; | |
| 22 | import * as Semaphore from "effect/Semaphore"; | |
| 23 | import * as Scope from "effect/Scope"; | |
| 24 | import * as Stream from "effect/Stream"; | |
| 25 | import * as Ndjson from "effect/unstable/encoding/Ndjson"; | |
| 26 | ||
| 27 | import { ServerConfig } from "../config.ts"; | |
| 28 | import { ServerSettingsService } from "../serverSettings.ts"; | |
| 29 | import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts"; | |
| 30 | ||
| 31 | const INITIAL_SAMPLE_DEADLINE_MS = 90_000; | |
| 32 | const MIN_SNAPSHOT_STALE_AFTER_MS = 90_000; | |
| 33 | const STALE_GRACE_MS = 30_000; | |
| 34 | const DEFAULT_HOST_POWER_ACTIVE_INTERVAL_MS = 30_000; | |
| 35 | const DEFAULT_HOST_POWER_IDLE_INTERVAL_MS = 120_000; | |
| 36 | const STALE_CHECK_INTERVAL = Duration.seconds(30); | |
| 37 | ||
| 38 | export class DesktopTelemetryDescriptorUnavailable extends Schema.TaggedErrorClass<DesktopTelemetryDescriptorUnavailable>()( | |
| 39 | "DesktopTelemetryDescriptorUnavailable", | |
| 40 | { | |
| 41 | mode: Schema.String, | |
| 42 | }, | |
| 43 | ) { | |
| 44 | override get message(): string { | |
| 45 | return `Desktop telemetry descriptor is unavailable in '${this.mode}' mode.`; | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | export class DesktopTelemetryProtocolMismatch extends Schema.TaggedErrorClass<DesktopTelemetryProtocolMismatch>()( | |
| 50 | "DesktopTelemetryProtocolMismatch", | |
| 51 | { | |
| 52 | expectedVersion: Schema.Number, | |
| 53 | receivedVersion: Schema.Number, | |
| 54 | }, | |
| 55 | ) { | |
| 56 | override get message(): string { | |
| 57 | return `Desktop telemetry protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`; | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | export class DesktopTelemetryDecodeFailed extends Schema.TaggedErrorClass<DesktopTelemetryDecodeFailed>()( | |
| 62 | "DesktopTelemetryDecodeFailed", | |
| 63 | { | |
| 64 | cause: Schema.Defect(), | |
| 65 | }, | |
| 66 | ) { | |
| 67 | override get message(): string { | |
| 68 | return "Failed to decode desktop telemetry."; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | export class DesktopTelemetryStreamFailed extends Schema.TaggedErrorClass<DesktopTelemetryStreamFailed>()( | |
| 73 | "DesktopTelemetryStreamFailed", | |
| 74 | { | |
| 75 | fd: Schema.Number, | |
| 76 | cause: Schema.Defect(), | |
| 77 | }, | |
| 78 | ) { | |
| 79 | override get message(): string { | |
| 80 | return `Desktop telemetry stream on fd ${this.fd} failed.`; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | export class DesktopTelemetryStreamClosed extends Schema.TaggedErrorClass<DesktopTelemetryStreamClosed>()( | |
| 85 | "DesktopTelemetryStreamClosed", | |
| 86 | { | |
| 87 | fd: Schema.Number, | |
| 88 | }, | |
| 89 | ) { | |
| 90 | override get message(): string { | |
| 91 | return `Desktop telemetry stream on fd ${this.fd} closed.`; | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | export class DesktopTelemetryStale extends Schema.TaggedErrorClass<DesktopTelemetryStale>()( | |
| 96 | "DesktopTelemetryStale", | |
| 97 | { | |
| 98 | fd: Schema.Number, | |
| 99 | staleAfterMs: Schema.Number, | |
| 100 | }, | |
| 101 | ) { | |
| 102 | override get message(): string { | |
| 103 | return `Desktop telemetry on fd ${this.fd} has not updated for ${this.staleAfterMs}ms.`; | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | export type DesktopTelemetryReceiverError = | |
| 108 | | DesktopTelemetryDescriptorUnavailable | |
| 109 | | DesktopTelemetryProtocolMismatch | |
| 110 | | DesktopTelemetryDecodeFailed | |
| 111 | | DesktopTelemetryStreamFailed | |
| 112 | | DesktopTelemetryStreamClosed; | |
| 113 | ||
| 114 | export class DesktopTelemetryControlFailed extends Schema.TaggedErrorClass<DesktopTelemetryControlFailed>()( | |
| 115 | "DesktopTelemetryControlFailed", | |
| 116 | { | |
| 117 | fd: Schema.Number, | |
| 118 | operation: Schema.String, | |
| 119 | cause: Schema.Defect(), | |
| 120 | }, | |
| 121 | ) { | |
| 122 | override get message(): string { | |
| 123 | return `Desktop telemetry control '${this.operation}' failed on fd ${this.fd}.`; | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | export class DesktopTelemetryControlStalled extends Schema.TaggedErrorClass<DesktopTelemetryControlStalled>()( | |
| 128 | "DesktopTelemetryControlStalled", | |
| 129 | { | |
| 130 | fd: Schema.Number, | |
| 131 | remainingBytes: Schema.Number, | |
| 132 | }, | |
| 133 | ) { | |
| 134 | override get message(): string { | |
| 135 | return `Desktop telemetry control stalled on fd ${this.fd} with ${this.remainingBytes} bytes remaining.`; | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | export type DesktopTelemetryControlError = | |
| 140 | | DesktopTelemetryControlFailed | |
| 141 | | DesktopTelemetryControlStalled; | |
| 142 | ||
| 143 | export interface DesktopTelemetryReceiverHealth { | |
| 144 | readonly status: ResourceTelemetrySourceStatus; | |
| 145 | readonly lastSampleAt: Option.Option<DateTime.Utc>; | |
| 146 | readonly lastError: Option.Option<string>; | |
| 147 | } | |
| 148 | ||
| 149 | export class DesktopTelemetryReceiver extends Context.Service< | |
| 150 | DesktopTelemetryReceiver, | |
| 151 | { | |
| 152 | readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>; | |
| 153 | readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>; | |
| 154 | readonly subscribe: Effect.Effect< | |
| 155 | { | |
| 156 | readonly latest: Option.Option<DesktopHostTelemetrySnapshot>; | |
| 157 | readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>; | |
| 158 | }, | |
| 159 | never, | |
| 160 | Scope.Scope | |
| 161 | >; | |
| 162 | readonly health: Effect.Effect<DesktopTelemetryReceiverHealth>; | |
| 163 | readonly subscribeHealth: Effect.Effect< | |
| 164 | { | |
| 165 | readonly latest: DesktopTelemetryReceiverHealth; | |
| 166 | readonly changes: Stream.Stream<DesktopTelemetryReceiverHealth>; | |
| 167 | }, | |
| 168 | never, | |
| 169 | Scope.Scope | |
| 170 | >; | |
| 171 | readonly setDiagnosticsDemand: ( | |
| 172 | enabled: boolean, | |
| 173 | ) => Effect.Effect<void, DesktopTelemetryControlError>; | |
| 174 | } | |
| 175 | >()("t3/resourceTelemetry/DesktopTelemetryReceiver") {} | |
| 176 | ||
| 177 | const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage); | |
| 178 | const encodeControlMessage = Schema.encodeEffect( | |
| 179 | Schema.fromJsonString(DesktopTelemetryControlMessage), | |
| 180 | ); | |
| 181 | const isDescriptorUnavailable = Schema.is(DesktopTelemetryDescriptorUnavailable); | |
| 182 | const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch); | |
| 183 | const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed); | |
| 184 | const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed); | |
| 185 | ||
| 186 | export function isDesktopTelemetryContactStale( | |
| 187 | lastContactAtMs: Option.Option<number>, | |
| 188 | nowMs: number, | |
| 189 | ): boolean { | |
| 190 | return Option.exists( | |
| 191 | lastContactAtMs, | |
| 192 | (lastContact) => nowMs - lastContact >= INITIAL_SAMPLE_DEADLINE_MS, | |
| 193 | ); | |
| 194 | } | |
| 195 | ||
| 196 | export function resolveDesktopTelemetrySnapshotStaleAfterMs( | |
| 197 | activeIntervalMs: number, | |
| 198 | idleIntervalMs: number, | |
| 199 | ): number { | |
| 200 | return Math.max( | |
| 201 | MIN_SNAPSHOT_STALE_AFTER_MS, | |
| 202 | Math.max(activeIntervalMs, idleIntervalMs) + STALE_GRACE_MS, | |
| 203 | ); | |
| 204 | } | |
| 205 | ||
| 206 | export function initialDesktopTelemetryContactAt( | |
| 207 | desktopTelemetryFd: number | undefined, | |
| 208 | nowMs: number, | |
| 209 | ): Option.Option<number> { | |
| 210 | return desktopTelemetryFd === undefined ? Option.none() : Option.some(nowMs); | |
| 211 | } | |
| 212 | ||
| 213 | export const recordDesktopTelemetrySampleHealth = Effect.fn( | |
| 214 | "resourceTelemetry.desktopTelemetryReceiver.recordSampleHealth", | |
| 215 | )(function* ( | |
| 216 | health: Ref.Ref<DesktopTelemetryReceiverHealth>, | |
| 217 | healthChanges: PubSub.PubSub<DesktopTelemetryReceiverHealth>, | |
| 218 | sampledAt: DateTime.Utc, | |
| 219 | ) { | |
| 220 | const next: DesktopTelemetryReceiverHealth = { | |
| 221 | status: "healthy", | |
| 222 | lastSampleAt: Option.some(sampledAt), | |
| 223 | lastError: Option.none(), | |
| 224 | }; | |
| 225 | yield* Ref.set(health, next); | |
| 226 | yield* PubSub.publish(healthChanges, next); | |
| 227 | }); | |
| 228 | ||
| 229 | function normalizeReceiverError(error: unknown): DesktopTelemetryReceiverError { | |
| 230 | if ( | |
| 231 | isDescriptorUnavailable(error) || | |
| 232 | isProtocolMismatch(error) || | |
| 233 | isDecodeFailed(error) || | |
| 234 | isStreamFailed(error) | |
| 235 | ) { | |
| 236 | return error; | |
| 237 | } | |
| 238 | return new DesktopTelemetryDecodeFailed({ cause: error }); | |
| 239 | } | |
| 240 | ||
| 241 | function messageVersion(value: unknown): number | undefined { | |
| 242 | if (typeof value !== "object" || value === null) return undefined; | |
| 243 | const version = Reflect.get(value, "version"); | |
| 244 | return typeof version === "number" ? version : undefined; | |
| 245 | } | |
| 246 | ||
| 247 | export const writeAllToFileDescriptor = Effect.fn( | |
| 248 | "resourceTelemetry.desktopTelemetryReceiver.writeAllToFileDescriptor", | |
| 249 | )(function* (fd: number, payload: Buffer) { | |
| 250 | let offset = 0; | |
| 251 | while (offset < payload.byteLength) { | |
| 252 | const written = yield* Effect.callback<number, DesktopTelemetryControlFailed>( | |
| 253 | (resume, signal) => { | |
| 254 | if (signal.aborted) return; | |
| 255 | try { | |
| 256 | NodeFS.write( | |
| 257 | fd, | |
| 258 | payload, | |
| 259 | offset, | |
| 260 | payload.byteLength - offset, | |
| 261 | null, | |
| 262 | (error, bytesWritten) => { | |
| 263 | if (error) { | |
| 264 | resume( | |
| 265 | Effect.fail( | |
| 266 | new DesktopTelemetryControlFailed({ | |
| 267 | fd, | |
| 268 | operation: "write", | |
| 269 | cause: error, | |
| 270 | }), | |
| 271 | ), | |
| 272 | ); | |
| 273 | return; | |
| 274 | } | |
| 275 | resume(Effect.succeed(bytesWritten)); | |
| 276 | }, | |
| 277 | ); | |
| 278 | } catch (cause) { | |
| 279 | resume( | |
| 280 | Effect.fail( | |
| 281 | new DesktopTelemetryControlFailed({ | |
| 282 | fd, | |
| 283 | operation: "write", | |
| 284 | cause, | |
| 285 | }), | |
| 286 | ), | |
| 287 | ); | |
| 288 | } | |
| 289 | }, | |
| 290 | ); | |
| 291 | yield* requireDesktopTelemetryWriteProgress(fd, payload.byteLength - offset, written); | |
| 292 | offset += written; | |
| 293 | } | |
| 294 | }); | |
| 295 | ||
| 296 | export function requireDesktopTelemetryWriteProgress( | |
| 297 | fd: number, | |
| 298 | remainingBytes: number, | |
| 299 | written: number, | |
| 300 | ): Effect.Effect<void, DesktopTelemetryControlStalled> { | |
| 301 | return written > 0 | |
| 302 | ? Effect.void | |
| 303 | : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes })); | |
| 304 | } | |
| 305 | ||
| 306 | export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { | |
| 307 | const config = yield* ServerConfig; | |
| 308 | const serverSettings = yield* ServerSettingsService; | |
| 309 | const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>()); | |
| 310 | const receiverStartedAt = yield* DateTime.now; | |
| 311 | const lastContactAtMs = yield* Ref.make( | |
| 312 | initialDesktopTelemetryContactAt( | |
| 313 | config.desktopTelemetryFd, | |
| 314 | DateTime.toEpochMillis(receiverStartedAt), | |
| 315 | ), | |
| 316 | ); | |
| 317 | const snapshotStaleAfterMs = yield* Ref.make( | |
| 318 | resolveDesktopTelemetrySnapshotStaleAfterMs( | |
| 319 | DEFAULT_HOST_POWER_ACTIVE_INTERVAL_MS, | |
| 320 | DEFAULT_HOST_POWER_IDLE_INTERVAL_MS, | |
| 321 | ), | |
| 322 | ); | |
| 323 | const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8); | |
| 324 | const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4); | |
| 325 | const controlMutex = yield* Semaphore.make(1); | |
| 326 | const snapshotMutex = yield* Semaphore.make(1); | |
| 327 | const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({ | |
| 328 | status: config.desktopTelemetryFd === undefined ? "unavailable" : "starting", | |
| 329 | lastSampleAt: Option.none(), | |
| 330 | lastError: | |
| 331 | config.desktopTelemetryFd === undefined | |
| 332 | ? Option.some( | |
| 333 | new DesktopTelemetryDescriptorUnavailable({ | |
| 334 | mode: config.mode, | |
| 335 | }).message, | |
| 336 | ) | |
| 337 | : Option.none(), | |
| 338 | }); | |
| 339 | const updateHealth = ( | |
| 340 | update: (current: DesktopTelemetryReceiverHealth) => DesktopTelemetryReceiverHealth, | |
| 341 | ) => | |
| 342 | Ref.modify(health, (current) => { | |
| 343 | const next = update(current); | |
| 344 | return [next, next]; | |
| 345 | }).pipe( | |
| 346 | Effect.flatMap((next) => PubSub.publish(healthChanges, next)), | |
| 347 | Effect.asVoid, | |
| 348 | ); | |
| 349 | const updateSampleHealth = (sampledAt: DateTime.Utc) => | |
| 350 | recordDesktopTelemetrySampleHealth(health, healthChanges, sampledAt); | |
| 351 | ||
| 352 | const sendControlMessage = (message: DesktopTelemetryControlMessage) => | |
| 353 | controlMutex.withPermits(1)( | |
| 354 | Effect.gen(function* () { | |
| 355 | const fd = config.desktopTelemetryControlFd; | |
| 356 | if (fd === undefined) return; | |
| 357 | const encoded = yield* encodeControlMessage(message).pipe( | |
| 358 | Effect.mapError( | |
| 359 | (cause) => | |
| 360 | new DesktopTelemetryControlFailed({ | |
| 361 | fd, | |
| 362 | operation: "encode", | |
| 363 | cause, | |
| 364 | }), | |
| 365 | ), | |
| 366 | ); | |
| 367 | yield* writeAllToFileDescriptor(fd, Buffer.from(`${encoded}\n`)).pipe( | |
| 368 | Effect.tapError((error) => | |
| 369 | updateHealth((current) => ({ | |
| 370 | ...current, | |
| 371 | status: "degraded", | |
| 372 | lastError: Option.some(error.message), | |
| 373 | })), | |
| 374 | ), | |
| 375 | ); | |
| 376 | }), | |
| 377 | ); | |
| 378 | const setDiagnosticsDemand: DesktopTelemetryReceiver["Service"]["setDiagnosticsDemand"] = ( | |
| 379 | enabled, | |
| 380 | ) => | |
| 381 | sendControlMessage({ | |
| 382 | version: 1, | |
| 383 | type: "setDiagnosticsDemand", | |
| 384 | enabled, | |
| 385 | }); | |
| 386 | ||
| 387 | const sendHostPowerIntervals = ( | |
| 388 | settings: Parameters<typeof resolveServerBackgroundActivitySettings>[0], | |
| 389 | ) => { | |
| 390 | const resolved = resolveServerBackgroundActivitySettings(settings); | |
| 391 | const activeIntervalMs = Math.max( | |
| 392 | 1, | |
| 393 | Math.round(Duration.toMillis(resolved.hostPowerMonitorActiveInterval)), | |
| 394 | ); | |
| 395 | const idleIntervalMs = Math.max( | |
| 396 | 1, | |
| 397 | Math.round(Duration.toMillis(resolved.hostPowerMonitorIdleInterval)), | |
| 398 | ); | |
| 399 | return sendControlMessage({ | |
| 400 | version: 1, | |
| 401 | type: "setHostPowerIntervals", | |
| 402 | activeIntervalMs, | |
| 403 | idleIntervalMs, | |
| 404 | }).pipe( | |
| 405 | Effect.andThen( | |
| 406 | Ref.set( | |
| 407 | snapshotStaleAfterMs, | |
| 408 | resolveDesktopTelemetrySnapshotStaleAfterMs(activeIntervalMs, idleIntervalMs), | |
| 409 | ), | |
| 410 | ), | |
| 411 | ); | |
| 412 | }; | |
| 413 | if (config.desktopTelemetryControlFd !== undefined) { | |
| 414 | const settingsChanges = yield* serverSettings.subscribeChanges; | |
| 415 | const settings = yield* serverSettings.getSettings; | |
| 416 | yield* sendHostPowerIntervals(settings).pipe( | |
| 417 | Effect.catchCause((cause) => | |
| 418 | Effect.logWarning("Failed to configure desktop host-power intervals", { | |
| 419 | cause: String(cause), | |
| 420 | }), | |
| 421 | ), | |
| 422 | ); | |
| 423 | yield* settingsChanges.pipe( | |
| 424 | Stream.runForEach((settings) => | |
| 425 | sendHostPowerIntervals(settings).pipe( | |
| 426 | Effect.catchCause((cause) => | |
| 427 | Effect.logWarning("Failed to update desktop host-power intervals", { | |
| 428 | cause: String(cause), | |
| 429 | }), | |
| 430 | ), | |
| 431 | ), | |
| 432 | ), | |
| 433 | Effect.forkScoped, | |
| 434 | ); | |
| 435 | } | |
| 436 | ||
| 437 | if (config.desktopTelemetryFd !== undefined) { | |
| 438 | const fd = config.desktopTelemetryFd; | |
| 439 | const readable = yield* Effect.acquireRelease( | |
| 440 | Effect.try({ | |
| 441 | try: () => | |
| 442 | NodeFS.createReadStream("", { | |
| 443 | fd, | |
| 444 | autoClose: true, | |
| 445 | }), | |
| 446 | catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), | |
| 447 | }), | |
| 448 | (stream) => | |
| 449 | Effect.sync(() => { | |
| 450 | stream.destroy(); | |
| 451 | }), | |
| 452 | ); | |
| 453 | ||
| 454 | const messages: Stream.Stream<DesktopHostTelemetryMessageValue, DesktopTelemetryReceiverError> = | |
| 455 | NodeStream.fromReadable<Uint8Array, DesktopTelemetryStreamFailed>({ | |
| 456 | evaluate: () => readable, | |
| 457 | closeOnDone: true, | |
| 458 | onError: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), | |
| 459 | }).pipe( | |
| 460 | Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), | |
| 461 | Stream.mapEffect( | |
| 462 | ( | |
| 463 | value, | |
| 464 | ): Effect.Effect< | |
| 465 | DesktopHostTelemetryMessageValue, | |
| 466 | DesktopTelemetryProtocolMismatch | DesktopTelemetryDecodeFailed | |
| 467 | > => { | |
| 468 | const version = messageVersion(value); | |
| 469 | if (version !== undefined && version !== 1) { | |
| 470 | return Effect.fail( | |
| 471 | new DesktopTelemetryProtocolMismatch({ | |
| 472 | expectedVersion: 1, | |
| 473 | receivedVersion: version, | |
| 474 | }), | |
| 475 | ); | |
| 476 | } | |
| 477 | return decodeMessage(value).pipe( | |
| 478 | Effect.mapError((cause) => new DesktopTelemetryDecodeFailed({ cause })), | |
| 479 | ); | |
| 480 | }, | |
| 481 | ), | |
| 482 | Stream.mapError(normalizeReceiverError), | |
| 483 | ); | |
| 484 | ||
| 485 | yield* messages.pipe( | |
| 486 | Stream.runForEach((message) => { | |
| 487 | const recordContact = DateTime.now.pipe( | |
| 488 | Effect.flatMap((now) => | |
| 489 | Ref.set(lastContactAtMs, Option.some(DateTime.toEpochMillis(now))), | |
| 490 | ), | |
| 491 | ); | |
| 492 | if (message.type === "desktopTelemetryHello") { | |
| 493 | return recordContact.pipe( | |
| 494 | Effect.andThen( | |
| 495 | updateHealth( | |
| 496 | (current): DesktopTelemetryReceiverHealth => ({ | |
| 497 | ...current, | |
| 498 | status: "healthy", | |
| 499 | lastError: Option.none(), | |
| 500 | }), | |
| 501 | ), | |
| 502 | ), | |
| 503 | ); | |
| 504 | } | |
| 505 | ||
| 506 | const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs); | |
| 507 | return snapshotMutex.withPermits(1)( | |
| 508 | recordContact.pipe( | |
| 509 | Effect.andThen(Ref.set(latest, Option.some(message))), | |
| 510 | Effect.andThen(updateSampleHealth(sampledAt)), | |
| 511 | Effect.andThen(PubSub.publish(changes, message)), | |
| 512 | Effect.asVoid, | |
| 513 | ), | |
| 514 | ); | |
| 515 | }), | |
| 516 | Effect.andThen( | |
| 517 | updateHealth( | |
| 518 | (current): DesktopTelemetryReceiverHealth => ({ | |
| 519 | ...current, | |
| 520 | status: "stopped", | |
| 521 | lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message), | |
| 522 | }), | |
| 523 | ), | |
| 524 | ), | |
| 525 | Effect.catch((error) => | |
| 526 | updateHealth( | |
| 527 | (current): DesktopTelemetryReceiverHealth => ({ | |
| 528 | ...current, | |
| 529 | status: "degraded", | |
| 530 | lastError: Option.some(error.message), | |
| 531 | }), | |
| 532 | ), | |
| 533 | ), | |
| 534 | Effect.forkScoped, | |
| 535 | ); | |
| 536 | ||
| 537 | yield* Effect.forever( | |
| 538 | Effect.sleep(STALE_CHECK_INTERVAL).pipe( | |
| 539 | Effect.andThen( | |
| 540 | snapshotMutex.withPermits(1)( | |
| 541 | Effect.gen(function* () { | |
| 542 | const now = yield* DateTime.now; | |
| 543 | const nowMs = DateTime.toEpochMillis(now); | |
| 544 | const staleAfterMs = yield* Ref.get(snapshotStaleAfterMs); | |
| 545 | const staleSnapshot = yield* Ref.modify(latest, (current) => { | |
| 546 | if ( | |
| 547 | Option.isNone(current) || | |
| 548 | current.value.power.stale || | |
| 549 | nowMs - current.value.sampledAtUnixMs < staleAfterMs | |
| 550 | ) { | |
| 551 | return [Option.none<DesktopHostTelemetrySnapshot>(), current] as const; | |
| 552 | } | |
| 553 | const stale: DesktopHostTelemetrySnapshot = { | |
| 554 | ...current.value, | |
| 555 | power: { ...current.value.power, stale: true }, | |
| 556 | }; | |
| 557 | return [Option.some(stale), Option.some(stale)] as const; | |
| 558 | }); | |
| 559 | if (Option.isNone(staleSnapshot)) { | |
| 560 | const lastContact = yield* Ref.get(lastContactAtMs); | |
| 561 | if (!isDesktopTelemetryContactStale(lastContact, nowMs)) return; | |
| 562 | const staleMessage = new DesktopTelemetryStale({ | |
| 563 | fd, | |
| 564 | staleAfterMs: INITIAL_SAMPLE_DEADLINE_MS, | |
| 565 | }).message; | |
| 566 | const changed = yield* Ref.modify(health, (current) => { | |
| 567 | if ( | |
| 568 | current.status === "stopped" || | |
| 569 | Option.isSome(current.lastSampleAt) || | |
| 570 | (current.status === "degraded" && | |
| 571 | Option.contains(current.lastError, staleMessage)) | |
| 572 | ) { | |
| 573 | return [Option.none<DesktopTelemetryReceiverHealth>(), current] as const; | |
| 574 | } | |
| 575 | const next: DesktopTelemetryReceiverHealth = { | |
| 576 | ...current, | |
| 577 | status: "degraded", | |
| 578 | lastError: Option.some(staleMessage), | |
| 579 | }; | |
| 580 | return [Option.some(next), next] as const; | |
| 581 | }); | |
| 582 | if (Option.isSome(changed)) { | |
| 583 | yield* PubSub.publish(healthChanges, changed.value); | |
| 584 | } | |
| 585 | return; | |
| 586 | } | |
| 587 | yield* updateHealth((currentHealth) => ({ | |
| 588 | ...currentHealth, | |
| 589 | status: currentHealth.status === "stopped" ? "stopped" : "degraded", | |
| 590 | lastError: | |
| 591 | currentHealth.status === "stopped" | |
| 592 | ? currentHealth.lastError | |
| 593 | : Option.some(new DesktopTelemetryStale({ fd, staleAfterMs }).message), | |
| 594 | })); | |
| 595 | yield* PubSub.publish(changes, staleSnapshot.value); | |
| 596 | }), | |
| 597 | ), | |
| 598 | ), | |
| 599 | ), | |
| 600 | ).pipe(Effect.forkScoped); | |
| 601 | } | |
| 602 | ||
| 603 | return DesktopTelemetryReceiver.of({ | |
| 604 | latest: Ref.get(latest), | |
| 605 | changes: Stream.fromPubSub(changes), | |
| 606 | subscribe: snapshotMutex.withPermits(1)( | |
| 607 | Effect.gen(function* () { | |
| 608 | const initial = yield* Ref.get(latest); | |
| 609 | const subscription = yield* PubSub.subscribe(changes); | |
| 610 | return { | |
| 611 | latest: initial, | |
| 612 | changes: Stream.fromSubscription(subscription), | |
| 613 | }; | |
| 614 | }), | |
| 615 | ), | |
| 616 | health: Ref.get(health), | |
| 617 | subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), | |
| 618 | setDiagnosticsDemand, | |
| 619 | }); | |
| 620 | }); | |
| 621 | ||
| 622 | export const layer = Layer.effect(DesktopTelemetryReceiver, make()); | |
| 623 | ||
| 624 | export const layerTest = ( | |
| 625 | overrides: Partial<DesktopTelemetryReceiver["Service"]> = {}, | |
| 626 | ): Layer.Layer<DesktopTelemetryReceiver> => { | |
| 627 | const latest = overrides.latest ?? Effect.succeedNone; | |
| 628 | const changes = overrides.changes ?? Stream.empty; | |
| 629 | const health = | |
| 630 | overrides.health ?? | |
| 631 | Effect.succeed({ | |
| 632 | status: "unavailable" as const, | |
| 633 | lastSampleAt: Option.none<DateTime.Utc>(), | |
| 634 | lastError: Option.some("Desktop telemetry test implementation is unavailable."), | |
| 635 | }); | |
| 636 | return Layer.succeed( | |
| 637 | DesktopTelemetryReceiver, | |
| 638 | DesktopTelemetryReceiver.of({ | |
| 639 | latest, | |
| 640 | changes, | |
| 641 | subscribe: | |
| 642 | overrides.subscribe ?? | |
| 643 | latest.pipe( | |
| 644 | Effect.map((initial) => ({ | |
| 645 | latest: initial, | |
| 646 | changes, | |
| 647 | })), | |
| 648 | ), | |
| 649 | health, | |
| 650 | subscribeHealth: | |
| 651 | overrides.subscribeHealth ?? | |
| 652 | health.pipe( | |
| 653 | Effect.map((initial) => ({ | |
| 654 | latest: initial, | |
| 655 | changes: Stream.empty, | |
| 656 | })), | |
| 657 | ), | |
| 658 | setDiagnosticsDemand: () => Effect.void, | |
| 659 | ...overrides, | |
| 660 | }), | |
| 661 | ); | |
| 662 | }; |
apps/server/src/resourceTelemetry/Model.test.ts created+581| ... | ... | @@ -0,0 +1,581 @@ |
| 1 | import { | |
| 2 | type DesktopElectronProcessMetric, | |
| 3 | type DesktopHostTelemetrySnapshot, | |
| 4 | type ResourceMonitorProcessSample, | |
| 5 | type ResourceMonitorSnapshotEvent, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { describe, expect, it } from "@effect/vitest"; | |
| 8 | import * as DateTime from "effect/DateTime"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | ||
| 11 | import { emptyTelemetryCounters, mergeProcesses, type MergeProcessesResult } from "./Model.ts"; | |
| 12 | ||
| 13 | const SERVER_PID = 100; | |
| 14 | const BASE_TIME_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z")); | |
| 15 | ||
| 16 | function processSample( | |
| 17 | input: Partial<ResourceMonitorProcessSample> & | |
| 18 | Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">, | |
| 19 | ): ResourceMonitorProcessSample { | |
| 20 | return { | |
| 21 | runTimeMs: 1_000, | |
| 22 | name: `process-${input.pid}`, | |
| 23 | command: `process-${input.pid}`, | |
| 24 | status: "Running", | |
| 25 | cpuPercent: 0, | |
| 26 | cpuTimeMs: 0, | |
| 27 | residentBytes: 1_024, | |
| 28 | virtualBytes: 2_048, | |
| 29 | ioReadBytes: 0, | |
| 30 | ioWriteBytes: 0, | |
| 31 | ioSemantics: "storage", | |
| 32 | ...input, | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | function nativeSnapshot( | |
| 37 | sampledAtUnixMs: number, | |
| 38 | processes: ReadonlyArray<ResourceMonitorProcessSample>, | |
| 39 | sequence = 1, | |
| 40 | ): ResourceMonitorSnapshotEvent { | |
| 41 | return { | |
| 42 | version: 2, | |
| 43 | type: "snapshot", | |
| 44 | sequence, | |
| 45 | sampledAtUnixMs, | |
| 46 | collectionDurationMicros: 250, | |
| 47 | scannedProcessCount: processes.length, | |
| 48 | retainedProcessCount: processes.length, | |
| 49 | inaccessibleProcessCount: 0, | |
| 50 | processes: [...processes], | |
| 51 | }; | |
| 52 | } | |
| 53 | ||
| 54 | function electronMetric( | |
| 55 | input: Partial<DesktopElectronProcessMetric> & | |
| 56 | Pick<DesktopElectronProcessMetric, "pid" | "creationTimeMs" | "type">, | |
| 57 | ): DesktopElectronProcessMetric { | |
| 58 | return { | |
| 59 | cpuPercent: 0, | |
| 60 | idleWakeupsPerSecond: 0, | |
| 61 | workingSetBytes: 1_024, | |
| 62 | peakWorkingSetBytes: 2_048, | |
| 63 | ...input, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | function desktopSnapshot( | |
| 68 | sampledAtUnixMs: number, | |
| 69 | electronProcesses: ReadonlyArray<DesktopElectronProcessMetric>, | |
| 70 | ): DesktopHostTelemetrySnapshot { | |
| 71 | const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); | |
| 72 | return { | |
| 73 | version: 1, | |
| 74 | type: "desktopTelemetry", | |
| 75 | sequence: 1, | |
| 76 | sampledAtUnixMs, | |
| 77 | electronPid: electronProcesses[0]?.pid ?? 10_000, | |
| 78 | power: { | |
| 79 | source: "electron-main", | |
| 80 | idle: "false", | |
| 81 | idleSeconds: 0, | |
| 82 | locked: "false", | |
| 83 | suspended: false, | |
| 84 | onBattery: "false", | |
| 85 | lowPowerMode: "unknown", | |
| 86 | thermalState: "nominal", | |
| 87 | stale: false, | |
| 88 | updatedAt: sampledAt, | |
| 89 | }, | |
| 90 | speedLimitPercent: Option.none(), | |
| 91 | electronProcesses: [...electronProcesses], | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | function merge(input: { | |
| 96 | readonly native: ResourceMonitorSnapshotEvent; | |
| 97 | readonly desktop?: DesktopHostTelemetrySnapshot; | |
| 98 | readonly previous?: MergeProcessesResult; | |
| 99 | readonly sidecarPid?: number; | |
| 100 | }): MergeProcessesResult { | |
| 101 | return mergeProcesses({ | |
| 102 | serverPid: SERVER_PID, | |
| 103 | sidecarPid: Option.fromUndefinedOr(input.sidecarPid), | |
| 104 | fallbackSampledAtMs: input.native.sampledAtUnixMs, | |
| 105 | nativeSnapshot: Option.some(input.native), | |
| 106 | desktopSnapshot: Option.fromUndefinedOr(input.desktop), | |
| 107 | previous: input.previous?.previous ?? new Map(), | |
| 108 | counters: input.previous?.counters ?? emptyTelemetryCounters(), | |
| 109 | updatePrevious: true, | |
| 110 | }); | |
| 111 | } | |
| 112 | ||
| 113 | describe("resource telemetry process model", () => { | |
| 114 | it("builds complete descendant depths and isolates monitor overhead", () => { | |
| 115 | const result = merge({ | |
| 116 | sidecarPid: 900, | |
| 117 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 118 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 119 | processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }), | |
| 120 | processSample({ pid: 201, ppid: 200, startTimeMs: 3_000 }), | |
| 121 | processSample({ pid: 202, ppid: 201, startTimeMs: 4_000 }), | |
| 122 | processSample({ pid: 900, ppid: SERVER_PID, startTimeMs: 5_000 }), | |
| 123 | ]), | |
| 124 | }); | |
| 125 | ||
| 126 | expect(result.processes.map((process) => [process.identity.pid, process.depth])).toEqual([ | |
| 127 | [100, 0], | |
| 128 | [200, 1], | |
| 129 | [201, 2], | |
| 130 | [202, 3], | |
| 131 | [900, 1], | |
| 132 | ]); | |
| 133 | expect(result.processes.find((process) => process.identity.pid === 900)?.category).toBe( | |
| 134 | "resource-monitor", | |
| 135 | ); | |
| 136 | expect(result.groups.backend.processCount).toBe(4); | |
| 137 | expect(result.groups.monitor.processCount).toBe(1); | |
| 138 | expect(result.groups.monitor.processStarts).toBe(1); | |
| 139 | expect(result.groups.allT3.processStarts).toBe(5); | |
| 140 | }); | |
| 141 | ||
| 142 | it("deduplicates Electron metrics and classifies Electron descendants", () => { | |
| 143 | const electronStart = 10_000; | |
| 144 | const result = merge({ | |
| 145 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 146 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 147 | processSample({ pid: 300, ppid: 1, startTimeMs: electronStart }), | |
| 148 | processSample({ pid: 301, ppid: 300, startTimeMs: electronStart + 1 }), | |
| 149 | ]), | |
| 150 | desktop: desktopSnapshot(BASE_TIME_MS, [ | |
| 151 | electronMetric({ | |
| 152 | pid: 300, | |
| 153 | creationTimeMs: electronStart + 500, | |
| 154 | type: "Browser", | |
| 155 | name: "electron", | |
| 156 | }), | |
| 157 | electronMetric({ | |
| 158 | pid: 301, | |
| 159 | creationTimeMs: electronStart + 500, | |
| 160 | type: "Utility", | |
| 161 | name: "network-service", | |
| 162 | }), | |
| 163 | ]), | |
| 164 | }); | |
| 165 | ||
| 166 | expect(result.processes.filter((process) => process.identity.pid === 300)).toHaveLength(1); | |
| 167 | expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe( | |
| 168 | "electron-main", | |
| 169 | ); | |
| 170 | expect(result.processes.find((process) => process.identity.pid === 301)?.category).toBe( | |
| 171 | "electron-utility", | |
| 172 | ); | |
| 173 | expect(result.processes.find((process) => process.identity.pid === 301)?.depth).toBe(1); | |
| 174 | expect(result.groups.electron.processCount).toBe(2); | |
| 175 | }); | |
| 176 | ||
| 177 | it("ignores stale Electron metrics after PID reuse", () => { | |
| 178 | const result = merge({ | |
| 179 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 180 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 181 | processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 50_000 }), | |
| 182 | ]), | |
| 183 | desktop: desktopSnapshot(BASE_TIME_MS, [ | |
| 184 | electronMetric({ | |
| 185 | pid: 300, | |
| 186 | creationTimeMs: 10_000, | |
| 187 | type: "Browser", | |
| 188 | }), | |
| 189 | ]), | |
| 190 | }); | |
| 191 | ||
| 192 | expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe( | |
| 193 | "server-child", | |
| 194 | ); | |
| 195 | expect(result.groups.electron.processCount).toBe(0); | |
| 196 | }); | |
| 197 | ||
| 198 | it("derives cumulative CPU time for synthetic Electron-only processes", () => { | |
| 199 | const first = merge({ | |
| 200 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 201 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 202 | ]), | |
| 203 | desktop: desktopSnapshot(BASE_TIME_MS, [ | |
| 204 | electronMetric({ | |
| 205 | pid: 300, | |
| 206 | creationTimeMs: 10_000, | |
| 207 | type: "Browser", | |
| 208 | cpuPercent: 50, | |
| 209 | }), | |
| 210 | ]), | |
| 211 | }); | |
| 212 | const second = merge({ | |
| 213 | previous: first, | |
| 214 | native: nativeSnapshot( | |
| 215 | BASE_TIME_MS + 1_000, | |
| 216 | [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })], | |
| 217 | 2, | |
| 218 | ), | |
| 219 | desktop: desktopSnapshot(BASE_TIME_MS + 1_000, [ | |
| 220 | electronMetric({ | |
| 221 | pid: 300, | |
| 222 | creationTimeMs: 10_000, | |
| 223 | type: "Browser", | |
| 224 | cpuPercent: 50, | |
| 225 | }), | |
| 226 | ]), | |
| 227 | }); | |
| 228 | ||
| 229 | expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(500); | |
| 230 | expect(second.groups.electron.cpuTimeMs).toBe(500); | |
| 231 | }); | |
| 232 | ||
| 233 | it("uses the native timestamp for native cumulative-counter deltas", () => { | |
| 234 | const first = merge({ | |
| 235 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 236 | processSample({ | |
| 237 | pid: SERVER_PID, | |
| 238 | ppid: 1, | |
| 239 | startTimeMs: 1_000, | |
| 240 | cpuTimeMs: 1_000, | |
| 241 | }), | |
| 242 | ]), | |
| 243 | desktop: desktopSnapshot(BASE_TIME_MS + 10_000, []), | |
| 244 | }); | |
| 245 | const second = merge({ | |
| 246 | previous: first, | |
| 247 | native: nativeSnapshot( | |
| 248 | BASE_TIME_MS + 1_000, | |
| 249 | [ | |
| 250 | processSample({ | |
| 251 | pid: SERVER_PID, | |
| 252 | ppid: 1, | |
| 253 | startTimeMs: 1_000, | |
| 254 | cpuTimeMs: 1_500, | |
| 255 | }), | |
| 256 | ], | |
| 257 | 2, | |
| 258 | ), | |
| 259 | desktop: desktopSnapshot(BASE_TIME_MS + 11_000, []), | |
| 260 | }); | |
| 261 | ||
| 262 | expect(second.processes[0]?.cpuPercent).toBe(50); | |
| 263 | expect(second.groups.backend.cpuTimeMs).toBe(500); | |
| 264 | }); | |
| 265 | ||
| 266 | it("does not advance synthetic CPU time when reusing the same desktop sample", () => { | |
| 267 | const metric = electronMetric({ | |
| 268 | pid: 300, | |
| 269 | creationTimeMs: 10_000, | |
| 270 | type: "Browser", | |
| 271 | cpuPercent: 50, | |
| 272 | }); | |
| 273 | const first = merge({ | |
| 274 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 275 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 276 | ]), | |
| 277 | desktop: desktopSnapshot(BASE_TIME_MS, [metric]), | |
| 278 | }); | |
| 279 | const second = merge({ | |
| 280 | previous: first, | |
| 281 | native: nativeSnapshot( | |
| 282 | BASE_TIME_MS + 1_000, | |
| 283 | [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })], | |
| 284 | 2, | |
| 285 | ), | |
| 286 | desktop: desktopSnapshot(BASE_TIME_MS, [metric]), | |
| 287 | }); | |
| 288 | ||
| 289 | expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(0); | |
| 290 | expect(second.groups.electron.cpuTimeMs).toBe(0); | |
| 291 | }); | |
| 292 | ||
| 293 | it("does not apply an explicit Electron root to a reused PID", () => { | |
| 294 | const first = mergeProcesses({ | |
| 295 | serverPid: SERVER_PID, | |
| 296 | sidecarPid: Option.none(), | |
| 297 | electronRootPids: new Set([300]), | |
| 298 | fallbackSampledAtMs: BASE_TIME_MS, | |
| 299 | nativeSnapshot: Option.some( | |
| 300 | nativeSnapshot(BASE_TIME_MS, [ | |
| 301 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 302 | processSample({ pid: 300, ppid: 1, startTimeMs: 10_000 }), | |
| 303 | ]), | |
| 304 | ), | |
| 305 | desktopSnapshot: Option.some( | |
| 306 | desktopSnapshot(BASE_TIME_MS, [ | |
| 307 | electronMetric({ | |
| 308 | pid: 300, | |
| 309 | creationTimeMs: 10_000, | |
| 310 | type: "Browser", | |
| 311 | }), | |
| 312 | ]), | |
| 313 | ), | |
| 314 | previous: new Map(), | |
| 315 | counters: emptyTelemetryCounters(), | |
| 316 | updatePrevious: true, | |
| 317 | }); | |
| 318 | const reused = mergeProcesses({ | |
| 319 | serverPid: SERVER_PID, | |
| 320 | sidecarPid: Option.none(), | |
| 321 | electronRootPids: new Set([300]), | |
| 322 | fallbackSampledAtMs: BASE_TIME_MS + 1_000, | |
| 323 | nativeSnapshot: Option.some( | |
| 324 | nativeSnapshot( | |
| 325 | BASE_TIME_MS + 1_000, | |
| 326 | [ | |
| 327 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 328 | processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 20_000 }), | |
| 329 | ], | |
| 330 | 2, | |
| 331 | ), | |
| 332 | ), | |
| 333 | desktopSnapshot: Option.none(), | |
| 334 | previous: first.previous, | |
| 335 | counters: first.counters, | |
| 336 | updatePrevious: true, | |
| 337 | }); | |
| 338 | ||
| 339 | expect(reused.processes.find((process) => process.identity.pid === 300)?.category).toBe( | |
| 340 | "server-child", | |
| 341 | ); | |
| 342 | expect(reused.groups.electron.processCount).toBe(0); | |
| 343 | }); | |
| 344 | ||
| 345 | it("derives rates from cumulative counters and preserves I/O semantics", () => { | |
| 346 | const first = merge({ | |
| 347 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 348 | processSample({ | |
| 349 | pid: SERVER_PID, | |
| 350 | ppid: 1, | |
| 351 | startTimeMs: 1_000, | |
| 352 | cpuTimeMs: 1_000, | |
| 353 | ioReadBytes: 10_000, | |
| 354 | ioWriteBytes: 20_000, | |
| 355 | ioSemantics: "all-io", | |
| 356 | }), | |
| 357 | ]), | |
| 358 | }); | |
| 359 | const second = merge({ | |
| 360 | previous: first, | |
| 361 | native: nativeSnapshot( | |
| 362 | BASE_TIME_MS + 1_000, | |
| 363 | [ | |
| 364 | processSample({ | |
| 365 | pid: SERVER_PID, | |
| 366 | ppid: 1, | |
| 367 | startTimeMs: 1_000, | |
| 368 | cpuTimeMs: 1_250, | |
| 369 | ioReadBytes: 12_000, | |
| 370 | ioWriteBytes: 23_000, | |
| 371 | ioSemantics: "all-io", | |
| 372 | }), | |
| 373 | ], | |
| 374 | 2, | |
| 375 | ), | |
| 376 | }); | |
| 377 | const server = second.processes[0]!; | |
| 378 | ||
| 379 | expect(server.cpuPercent).toBe(25); | |
| 380 | expect(server.ioReadBytesPerSecond).toBe(2_000); | |
| 381 | expect(server.ioWriteBytesPerSecond).toBe(3_000); | |
| 382 | expect(server.ioSemantics).toBe("all-io"); | |
| 383 | expect(second.groups.backend.cpuTimeMs).toBe(250); | |
| 384 | expect(second.groups.backend.ioReadBytes).toBe(2_000); | |
| 385 | expect(second.groups.backend.ioWriteBytes).toBe(3_000); | |
| 386 | }); | |
| 387 | ||
| 388 | it("derives deltas at the constrained 15-second sampling cadence", () => { | |
| 389 | const first = merge({ | |
| 390 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 391 | processSample({ | |
| 392 | pid: SERVER_PID, | |
| 393 | ppid: 1, | |
| 394 | startTimeMs: 1_000, | |
| 395 | cpuTimeMs: 1_000, | |
| 396 | ioReadBytes: 10_000, | |
| 397 | ioWriteBytes: 20_000, | |
| 398 | }), | |
| 399 | ]), | |
| 400 | }); | |
| 401 | const second = merge({ | |
| 402 | previous: first, | |
| 403 | native: nativeSnapshot( | |
| 404 | BASE_TIME_MS + 15_000, | |
| 405 | [ | |
| 406 | processSample({ | |
| 407 | pid: SERVER_PID, | |
| 408 | ppid: 1, | |
| 409 | startTimeMs: 1_000, | |
| 410 | cpuTimeMs: 2_500, | |
| 411 | ioReadBytes: 25_000, | |
| 412 | ioWriteBytes: 50_000, | |
| 413 | }), | |
| 414 | ], | |
| 415 | 2, | |
| 416 | ), | |
| 417 | }); | |
| 418 | ||
| 419 | expect(second.processes[0]?.cpuPercent).toBe(10); | |
| 420 | expect(second.processes[0]?.ioReadBytesPerSecond).toBe(1_000); | |
| 421 | expect(second.processes[0]?.ioWriteBytesPerSecond).toBe(2_000); | |
| 422 | expect(second.groups.backend.cpuTimeMs).toBe(1_500); | |
| 423 | expect(second.groups.backend.ioReadBytes).toBe(15_000); | |
| 424 | expect(second.groups.backend.ioWriteBytes).toBe(30_000); | |
| 425 | }); | |
| 426 | ||
| 427 | it("preserves native rates while applying a desktop-only update", () => { | |
| 428 | const first = merge({ | |
| 429 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 430 | processSample({ | |
| 431 | pid: SERVER_PID, | |
| 432 | ppid: 1, | |
| 433 | startTimeMs: 1_000, | |
| 434 | cpuTimeMs: 1_000, | |
| 435 | ioReadBytes: 10_000, | |
| 436 | ioWriteBytes: 20_000, | |
| 437 | }), | |
| 438 | ]), | |
| 439 | }); | |
| 440 | const second = merge({ | |
| 441 | previous: first, | |
| 442 | native: nativeSnapshot( | |
| 443 | BASE_TIME_MS + 1_000, | |
| 444 | [ | |
| 445 | processSample({ | |
| 446 | pid: SERVER_PID, | |
| 447 | ppid: 1, | |
| 448 | startTimeMs: 1_000, | |
| 449 | cpuTimeMs: 1_250, | |
| 450 | ioReadBytes: 12_000, | |
| 451 | ioWriteBytes: 23_000, | |
| 452 | }), | |
| 453 | ], | |
| 454 | 2, | |
| 455 | ), | |
| 456 | }); | |
| 457 | const desktopOnly = mergeProcesses({ | |
| 458 | serverPid: SERVER_PID, | |
| 459 | sidecarPid: Option.none(), | |
| 460 | fallbackSampledAtMs: BASE_TIME_MS + 1_000, | |
| 461 | nativeSnapshot: Option.some( | |
| 462 | nativeSnapshot( | |
| 463 | BASE_TIME_MS + 1_000, | |
| 464 | [ | |
| 465 | processSample({ | |
| 466 | pid: SERVER_PID, | |
| 467 | ppid: 1, | |
| 468 | startTimeMs: 1_000, | |
| 469 | cpuTimeMs: 1_250, | |
| 470 | ioReadBytes: 12_000, | |
| 471 | ioWriteBytes: 23_000, | |
| 472 | }), | |
| 473 | ], | |
| 474 | 2, | |
| 475 | ), | |
| 476 | ), | |
| 477 | desktopSnapshot: Option.some(desktopSnapshot(BASE_TIME_MS + 1_500, [])), | |
| 478 | previous: second.previous, | |
| 479 | counters: second.counters, | |
| 480 | updatePrevious: false, | |
| 481 | }); | |
| 482 | ||
| 483 | expect(desktopOnly.processes[0]?.cpuPercent).toBe(25); | |
| 484 | expect(desktopOnly.processes[0]?.ioReadBytesPerSecond).toBe(2_000); | |
| 485 | expect(desktopOnly.processes[0]?.ioWriteBytesPerSecond).toBe(3_000); | |
| 486 | expect(desktopOnly.sampledAtMs).toBe(BASE_TIME_MS + 1_500); | |
| 487 | }); | |
| 488 | ||
| 489 | it("resets deltas when counters decrease or the sampling gap is unsafe", () => { | |
| 490 | const first = merge({ | |
| 491 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 492 | processSample({ | |
| 493 | pid: SERVER_PID, | |
| 494 | ppid: 1, | |
| 495 | startTimeMs: 1_000, | |
| 496 | cpuTimeMs: 1_000, | |
| 497 | ioReadBytes: 10_000, | |
| 498 | ioWriteBytes: 20_000, | |
| 499 | }), | |
| 500 | ]), | |
| 501 | }); | |
| 502 | const decreased = merge({ | |
| 503 | previous: first, | |
| 504 | native: nativeSnapshot( | |
| 505 | BASE_TIME_MS + 1_000, | |
| 506 | [ | |
| 507 | processSample({ | |
| 508 | pid: SERVER_PID, | |
| 509 | ppid: 1, | |
| 510 | startTimeMs: 1_000, | |
| 511 | cpuTimeMs: 100, | |
| 512 | ioReadBytes: 100, | |
| 513 | ioWriteBytes: 200, | |
| 514 | }), | |
| 515 | ], | |
| 516 | 2, | |
| 517 | ), | |
| 518 | }); | |
| 519 | const delayed = merge({ | |
| 520 | previous: decreased, | |
| 521 | native: nativeSnapshot( | |
| 522 | BASE_TIME_MS + 90_000, | |
| 523 | [ | |
| 524 | processSample({ | |
| 525 | pid: SERVER_PID, | |
| 526 | ppid: 1, | |
| 527 | startTimeMs: 1_000, | |
| 528 | cpuTimeMs: 10_000, | |
| 529 | ioReadBytes: 100_000, | |
| 530 | ioWriteBytes: 200_000, | |
| 531 | }), | |
| 532 | ], | |
| 533 | 3, | |
| 534 | ), | |
| 535 | }); | |
| 536 | ||
| 537 | expect(decreased.processes[0]?.cpuPercent).toBe(0); | |
| 538 | expect(decreased.processes[0]?.ioReadBytesPerSecond).toBe(0); | |
| 539 | expect(decreased.processes[0]?.ioWriteBytesPerSecond).toBe(0); | |
| 540 | expect(delayed.processes[0]?.cpuPercent).toBe(0); | |
| 541 | expect(delayed.processes[0]?.ioReadBytesPerSecond).toBe(0); | |
| 542 | expect(delayed.processes[0]?.ioWriteBytesPerSecond).toBe(0); | |
| 543 | expect(delayed.groups.backend.cpuTimeMs).toBe(0); | |
| 544 | expect(delayed.groups.backend.ioReadBytes).toBe(0); | |
| 545 | expect(delayed.groups.backend.ioWriteBytes).toBe(0); | |
| 546 | }); | |
| 547 | ||
| 548 | it("treats reused PIDs as an exit plus a new process", () => { | |
| 549 | const first = merge({ | |
| 550 | native: nativeSnapshot(BASE_TIME_MS, [ | |
| 551 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 552 | processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }), | |
| 553 | ]), | |
| 554 | }); | |
| 555 | const second = merge({ | |
| 556 | previous: first, | |
| 557 | native: nativeSnapshot( | |
| 558 | BASE_TIME_MS + 1_000, | |
| 559 | [ | |
| 560 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | |
| 561 | processSample({ | |
| 562 | pid: 200, | |
| 563 | ppid: SERVER_PID, | |
| 564 | startTimeMs: 9_000, | |
| 565 | cpuTimeMs: 999, | |
| 566 | ioReadBytes: 999, | |
| 567 | ioWriteBytes: 999, | |
| 568 | }), | |
| 569 | ], | |
| 570 | 2, | |
| 571 | ), | |
| 572 | }); | |
| 573 | const reused = second.processes.find((process) => process.identity.pid === 200)!; | |
| 574 | ||
| 575 | expect(reused.identity.startTimeMs).toBe(9_000); | |
| 576 | expect(reused.cpuPercent).toBe(0); | |
| 577 | expect(reused.ioReadBytesPerSecond).toBe(0); | |
| 578 | expect(second.groups.backend.processStarts).toBe(3); | |
| 579 | expect(second.groups.backend.processExits).toBe(1); | |
| 580 | }); | |
| 581 | }); |
apps/server/src/resourceTelemetry/Model.ts created+608| ... | ... | @@ -0,0 +1,608 @@ |
| 1 | import type { | |
| 2 | DesktopElectronProcessMetric, | |
| 3 | DesktopHostTelemetrySnapshot, | |
| 4 | ResourceMonitorProcessSample, | |
| 5 | ResourceMonitorSnapshotEvent, | |
| 6 | ResourceTelemetryAggregate, | |
| 7 | ResourceTelemetryProcess, | |
| 8 | ResourceTelemetryProcessCategory, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as DateTime from "effect/DateTime"; | |
| 11 | import * as Option from "effect/Option"; | |
| 12 | ||
| 13 | const MAX_DELTA_INTERVAL_MS = 30_000; | |
| 14 | const ELECTRON_IDENTITY_TOLERANCE_MS = 2_000; | |
| 15 | ||
| 16 | export interface ProcessState { | |
| 17 | readonly process: ResourceTelemetryProcess; | |
| 18 | readonly sampledAtMs: number; | |
| 19 | } | |
| 20 | ||
| 21 | export interface GroupCounters { | |
| 22 | readonly cpuTimeMs: number; | |
| 23 | readonly ioReadBytes: number; | |
| 24 | readonly ioWriteBytes: number; | |
| 25 | readonly processStarts: number; | |
| 26 | readonly processExits: number; | |
| 27 | } | |
| 28 | ||
| 29 | export interface TelemetryCounters { | |
| 30 | readonly backend: GroupCounters; | |
| 31 | readonly electron: GroupCounters; | |
| 32 | readonly monitor: GroupCounters; | |
| 33 | readonly allT3: GroupCounters; | |
| 34 | } | |
| 35 | ||
| 36 | export interface ProcessDelta { | |
| 37 | readonly identityKey: string; | |
| 38 | readonly category: ResourceTelemetryProcessCategory; | |
| 39 | readonly cpuTimeMs: number; | |
| 40 | readonly ioReadBytes: number; | |
| 41 | readonly ioWriteBytes: number; | |
| 42 | } | |
| 43 | ||
| 44 | export interface MergeProcessesInput { | |
| 45 | readonly serverPid: number; | |
| 46 | readonly sidecarPid: Option.Option<number>; | |
| 47 | readonly fallbackSampledAtMs: number; | |
| 48 | readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>; | |
| 49 | readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>; | |
| 50 | readonly electronRootPids?: ReadonlySet<number>; | |
| 51 | readonly electronRootStartTimes?: ReadonlyMap<number, number>; | |
| 52 | readonly previous: ReadonlyMap<string, ProcessState>; | |
| 53 | readonly counters: TelemetryCounters; | |
| 54 | readonly updatePrevious: boolean; | |
| 55 | } | |
| 56 | ||
| 57 | export interface MergeProcessesResult { | |
| 58 | readonly sampledAtMs: number; | |
| 59 | readonly processes: ReadonlyArray<ResourceTelemetryProcess>; | |
| 60 | readonly previous: ReadonlyMap<string, ProcessState>; | |
| 61 | readonly counters: TelemetryCounters; | |
| 62 | readonly groups: { | |
| 63 | readonly backend: ResourceTelemetryAggregate; | |
| 64 | readonly electron: ResourceTelemetryAggregate; | |
| 65 | readonly monitor: ResourceTelemetryAggregate; | |
| 66 | readonly allT3: ResourceTelemetryAggregate; | |
| 67 | }; | |
| 68 | readonly deltas: ReadonlyArray<ProcessDelta>; | |
| 69 | } | |
| 70 | ||
| 71 | export const emptyGroupCounters = (): GroupCounters => ({ | |
| 72 | cpuTimeMs: 0, | |
| 73 | ioReadBytes: 0, | |
| 74 | ioWriteBytes: 0, | |
| 75 | processStarts: 0, | |
| 76 | processExits: 0, | |
| 77 | }); | |
| 78 | ||
| 79 | export const emptyTelemetryCounters = (): TelemetryCounters => ({ | |
| 80 | backend: emptyGroupCounters(), | |
| 81 | electron: emptyGroupCounters(), | |
| 82 | monitor: emptyGroupCounters(), | |
| 83 | allT3: emptyGroupCounters(), | |
| 84 | }); | |
| 85 | ||
| 86 | export function processIdentityKey(pid: number, startTimeMs: number): string { | |
| 87 | return `${pid}:${startTimeMs}`; | |
| 88 | } | |
| 89 | ||
| 90 | function finiteNonNegative(value: number): number { | |
| 91 | return Number.isFinite(value) ? Math.max(0, value) : 0; | |
| 92 | } | |
| 93 | ||
| 94 | function categoryGroup( | |
| 95 | category: ResourceTelemetryProcessCategory, | |
| 96 | ): "backend" | "electron" | "monitor" { | |
| 97 | if (category === "resource-monitor") return "monitor"; | |
| 98 | if (category.startsWith("electron-")) return "electron"; | |
| 99 | return "backend"; | |
| 100 | } | |
| 101 | ||
| 102 | function electronCategory(metric: DesktopElectronProcessMetric): ResourceTelemetryProcessCategory { | |
| 103 | switch (metric.type) { | |
| 104 | case "Browser": | |
| 105 | return "electron-main"; | |
| 106 | case "Tab": | |
| 107 | return "electron-renderer"; | |
| 108 | case "GPU": | |
| 109 | return "electron-gpu"; | |
| 110 | default: | |
| 111 | return "electron-utility"; | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | function inferredElectronCategory( | |
| 116 | process: ResourceMonitorProcessSample, | |
| 117 | ): ResourceTelemetryProcessCategory { | |
| 118 | const command = process.command.toLowerCase(); | |
| 119 | if (command.includes("--type=renderer")) return "electron-renderer"; | |
| 120 | if (command.includes("--type=gpu-process")) return "electron-gpu"; | |
| 121 | return "electron-utility"; | |
| 122 | } | |
| 123 | ||
| 124 | function matchElectronMetric( | |
| 125 | process: ResourceMonitorProcessSample, | |
| 126 | metricsByPid: ReadonlyMap<number, DesktopElectronProcessMetric>, | |
| 127 | ): DesktopElectronProcessMetric | undefined { | |
| 128 | const metric = metricsByPid.get(process.pid); | |
| 129 | if (!metric) return undefined; | |
| 130 | return Math.abs(metric.creationTimeMs - process.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS | |
| 131 | ? metric | |
| 132 | : undefined; | |
| 133 | } | |
| 134 | ||
| 135 | function syntheticNativeSample( | |
| 136 | metric: DesktopElectronProcessMetric, | |
| 137 | sampledAtMs: number, | |
| 138 | previous: ProcessState | undefined, | |
| 139 | ): ResourceMonitorProcessSample { | |
| 140 | const cpuTimeMs = | |
| 141 | metric.cumulativeCpuSeconds !== undefined | |
| 142 | ? Math.max(0, Math.round(metric.cumulativeCpuSeconds * 1_000)) | |
| 143 | : previous | |
| 144 | ? previous.process.cpuTimeMs + | |
| 145 | Math.max(0, ((sampledAtMs - previous.sampledAtMs) * metric.cpuPercent) / 100) | |
| 146 | : 0; | |
| 147 | return { | |
| 148 | pid: metric.pid, | |
| 149 | ppid: 0, | |
| 150 | startTimeMs: metric.creationTimeMs, | |
| 151 | runTimeMs: Math.max(0, sampledAtMs - metric.creationTimeMs), | |
| 152 | name: metric.name ?? metric.serviceName ?? metric.type, | |
| 153 | command: metric.name ?? metric.serviceName ?? metric.type, | |
| 154 | status: "Running", | |
| 155 | cpuPercent: metric.cpuPercent, | |
| 156 | cpuTimeMs, | |
| 157 | residentBytes: metric.workingSetBytes, | |
| 158 | virtualBytes: 0, | |
| 159 | ioReadBytes: 0, | |
| 160 | ioWriteBytes: 0, | |
| 161 | ioSemantics: "storage", | |
| 162 | }; | |
| 163 | } | |
| 164 | ||
| 165 | function processDepths( | |
| 166 | processes: ReadonlyArray<ResourceMonitorProcessSample>, | |
| 167 | roots: ReadonlySet<number>, | |
| 168 | ): ReadonlyMap<number, number> { | |
| 169 | const childrenByParent = new Map<number, number[]>(); | |
| 170 | for (const process of processes) { | |
| 171 | const children = childrenByParent.get(process.ppid) ?? []; | |
| 172 | children.push(process.pid); | |
| 173 | childrenByParent.set(process.ppid, children); | |
| 174 | } | |
| 175 | ||
| 176 | const depths = new Map<number, number>(); | |
| 177 | const queue = [...roots].map((pid) => ({ pid, depth: 0 })); | |
| 178 | while (queue.length > 0) { | |
| 179 | const current = queue.shift(); | |
| 180 | if (!current || depths.has(current.pid)) continue; | |
| 181 | depths.set(current.pid, current.depth); | |
| 182 | for (const childPid of childrenByParent.get(current.pid) ?? []) { | |
| 183 | queue.push({ pid: childPid, depth: current.depth + 1 }); | |
| 184 | } | |
| 185 | } | |
| 186 | return depths; | |
| 187 | } | |
| 188 | ||
| 189 | function isElectronDescendant( | |
| 190 | pid: number, | |
| 191 | processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>, | |
| 192 | electronPids: ReadonlySet<number>, | |
| 193 | ): boolean { | |
| 194 | const visited = new Set<number>(); | |
| 195 | let currentPid = pid; | |
| 196 | while (!visited.has(currentPid)) { | |
| 197 | visited.add(currentPid); | |
| 198 | if (electronPids.has(currentPid)) return true; | |
| 199 | const current = processesByPid.get(currentPid); | |
| 200 | if (!current || current.ppid <= 0 || current.ppid === currentPid) return false; | |
| 201 | currentPid = current.ppid; | |
| 202 | } | |
| 203 | return false; | |
| 204 | } | |
| 205 | ||
| 206 | function hasElectronAncestor( | |
| 207 | process: ResourceMonitorProcessSample, | |
| 208 | processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>, | |
| 209 | electronPids: ReadonlySet<number>, | |
| 210 | ): boolean { | |
| 211 | const visited = new Set<number>(); | |
| 212 | let currentPid = process.ppid; | |
| 213 | while (currentPid > 0 && !visited.has(currentPid)) { | |
| 214 | visited.add(currentPid); | |
| 215 | if (electronPids.has(currentPid)) return true; | |
| 216 | const current = processesByPid.get(currentPid); | |
| 217 | if (!current || current.ppid === currentPid) return false; | |
| 218 | currentPid = current.ppid; | |
| 219 | } | |
| 220 | return false; | |
| 221 | } | |
| 222 | ||
| 223 | function orderProcessTree( | |
| 224 | processes: ReadonlyArray<ResourceTelemetryProcess>, | |
| 225 | rootPids: ReadonlyArray<number>, | |
| 226 | ): ReadonlyArray<ResourceTelemetryProcess> { | |
| 227 | const processesByPid = new Map(processes.map((process) => [process.identity.pid, process])); | |
| 228 | const childrenByParent = new Map<number, ResourceTelemetryProcess[]>(); | |
| 229 | for (const process of processes) { | |
| 230 | const children = childrenByParent.get(process.ppid) ?? []; | |
| 231 | children.push(process); | |
| 232 | childrenByParent.set(process.ppid, children); | |
| 233 | } | |
| 234 | for (const children of childrenByParent.values()) { | |
| 235 | children.sort((left, right) => left.identity.pid - right.identity.pid); | |
| 236 | } | |
| 237 | ||
| 238 | const ordered: ResourceTelemetryProcess[] = []; | |
| 239 | const visited = new Set<number>(); | |
| 240 | const visit = (process: ResourceTelemetryProcess): void => { | |
| 241 | if (visited.has(process.identity.pid)) return; | |
| 242 | visited.add(process.identity.pid); | |
| 243 | ordered.push(process); | |
| 244 | for (const child of childrenByParent.get(process.identity.pid) ?? []) { | |
| 245 | visit(child); | |
| 246 | } | |
| 247 | }; | |
| 248 | ||
| 249 | for (const rootPid of rootPids) { | |
| 250 | const root = processesByPid.get(rootPid); | |
| 251 | if (root) visit(root); | |
| 252 | } | |
| 253 | for (const process of processes.toSorted( | |
| 254 | (left, right) => left.depth - right.depth || left.identity.pid - right.identity.pid, | |
| 255 | )) { | |
| 256 | visit(process); | |
| 257 | } | |
| 258 | return ordered; | |
| 259 | } | |
| 260 | ||
| 261 | function delta(input: { | |
| 262 | readonly current: number; | |
| 263 | readonly previous: number; | |
| 264 | readonly elapsedMs: number; | |
| 265 | }): number { | |
| 266 | if ( | |
| 267 | input.elapsedMs <= 0 || | |
| 268 | input.elapsedMs > MAX_DELTA_INTERVAL_MS || | |
| 269 | input.current < input.previous | |
| 270 | ) { | |
| 271 | return 0; | |
| 272 | } | |
| 273 | return input.current - input.previous; | |
| 274 | } | |
| 275 | ||
| 276 | function incrementCounters(counters: GroupCounters, update: Partial<GroupCounters>): GroupCounters { | |
| 277 | return { | |
| 278 | cpuTimeMs: counters.cpuTimeMs + (update.cpuTimeMs ?? 0), | |
| 279 | ioReadBytes: counters.ioReadBytes + (update.ioReadBytes ?? 0), | |
| 280 | ioWriteBytes: counters.ioWriteBytes + (update.ioWriteBytes ?? 0), | |
| 281 | processStarts: counters.processStarts + (update.processStarts ?? 0), | |
| 282 | processExits: counters.processExits + (update.processExits ?? 0), | |
| 283 | }; | |
| 284 | } | |
| 285 | ||
| 286 | function applyLifecycleCounters(input: { | |
| 287 | readonly counters: TelemetryCounters; | |
| 288 | readonly deltas: ReadonlyArray<ProcessDelta>; | |
| 289 | readonly current: ReadonlyMap<string, ProcessState>; | |
| 290 | readonly previous: ReadonlyMap<string, ProcessState>; | |
| 291 | }): TelemetryCounters { | |
| 292 | let backend = input.counters.backend; | |
| 293 | let electron = input.counters.electron; | |
| 294 | let monitor = input.counters.monitor; | |
| 295 | let allT3 = input.counters.allT3; | |
| 296 | for (const processDelta of input.deltas) { | |
| 297 | const group = categoryGroup(processDelta.category); | |
| 298 | switch (group) { | |
| 299 | case "backend": | |
| 300 | backend = incrementCounters(backend, processDelta); | |
| 301 | break; | |
| 302 | case "electron": | |
| 303 | electron = incrementCounters(electron, processDelta); | |
| 304 | break; | |
| 305 | case "monitor": | |
| 306 | monitor = incrementCounters(monitor, processDelta); | |
| 307 | break; | |
| 308 | } | |
| 309 | allT3 = incrementCounters(allT3, processDelta); | |
| 310 | } | |
| 311 | ||
| 312 | for (const [identityKey, current] of input.current) { | |
| 313 | if (input.previous.has(identityKey)) continue; | |
| 314 | const group = categoryGroup(current.process.category); | |
| 315 | switch (group) { | |
| 316 | case "backend": | |
| 317 | backend = incrementCounters(backend, { processStarts: 1 }); | |
| 318 | break; | |
| 319 | case "electron": | |
| 320 | electron = incrementCounters(electron, { processStarts: 1 }); | |
| 321 | break; | |
| 322 | case "monitor": | |
| 323 | monitor = incrementCounters(monitor, { processStarts: 1 }); | |
| 324 | break; | |
| 325 | } | |
| 326 | allT3 = incrementCounters(allT3, { processStarts: 1 }); | |
| 327 | } | |
| 328 | ||
| 329 | for (const [identityKey, previous] of input.previous) { | |
| 330 | if (input.current.has(identityKey)) continue; | |
| 331 | const group = categoryGroup(previous.process.category); | |
| 332 | switch (group) { | |
| 333 | case "backend": | |
| 334 | backend = incrementCounters(backend, { processExits: 1 }); | |
| 335 | break; | |
| 336 | case "electron": | |
| 337 | electron = incrementCounters(electron, { processExits: 1 }); | |
| 338 | break; | |
| 339 | case "monitor": | |
| 340 | monitor = incrementCounters(monitor, { processExits: 1 }); | |
| 341 | break; | |
| 342 | } | |
| 343 | allT3 = incrementCounters(allT3, { processExits: 1 }); | |
| 344 | } | |
| 345 | ||
| 346 | return { backend, electron, monitor, allT3 }; | |
| 347 | } | |
| 348 | ||
| 349 | function aggregate( | |
| 350 | processes: ReadonlyArray<ResourceTelemetryProcess>, | |
| 351 | counters: GroupCounters, | |
| 352 | ): ResourceTelemetryAggregate { | |
| 353 | return { | |
| 354 | processCount: processes.length, | |
| 355 | currentCpuPercent: processes.reduce((total, process) => total + process.cpuPercent, 0), | |
| 356 | cpuTimeMs: counters.cpuTimeMs, | |
| 357 | currentRssBytes: processes.reduce((total, process) => total + process.residentBytes, 0), | |
| 358 | peakRssBytes: processes.reduce((total, process) => total + process.peakResidentBytes, 0), | |
| 359 | ioReadBytes: counters.ioReadBytes, | |
| 360 | ioWriteBytes: counters.ioWriteBytes, | |
| 361 | ioReadBytesPerSecond: processes.reduce( | |
| 362 | (total, process) => total + process.ioReadBytesPerSecond, | |
| 363 | 0, | |
| 364 | ), | |
| 365 | ioWriteBytesPerSecond: processes.reduce( | |
| 366 | (total, process) => total + process.ioWriteBytesPerSecond, | |
| 367 | 0, | |
| 368 | ), | |
| 369 | processStarts: counters.processStarts, | |
| 370 | processExits: counters.processExits, | |
| 371 | }; | |
| 372 | } | |
| 373 | ||
| 374 | export function mergeProcesses(input: MergeProcessesInput): MergeProcessesResult { | |
| 375 | const nativeProcesses = Option.match(input.nativeSnapshot, { | |
| 376 | onNone: () => [] as ReadonlyArray<ResourceMonitorProcessSample>, | |
| 377 | onSome: (snapshot) => snapshot.processes, | |
| 378 | }); | |
| 379 | const electronMetrics = Option.match(input.desktopSnapshot, { | |
| 380 | onNone: () => [] as ReadonlyArray<DesktopElectronProcessMetric>, | |
| 381 | onSome: (snapshot) => snapshot.electronProcesses, | |
| 382 | }); | |
| 383 | const sampledAtMs = Option.match(input.nativeSnapshot, { | |
| 384 | onNone: () => | |
| 385 | Option.match(input.desktopSnapshot, { | |
| 386 | onNone: () => input.fallbackSampledAtMs, | |
| 387 | onSome: (snapshot) => snapshot.sampledAtUnixMs, | |
| 388 | }), | |
| 389 | onSome: (native) => | |
| 390 | Option.match(input.desktopSnapshot, { | |
| 391 | onNone: () => native.sampledAtUnixMs, | |
| 392 | onSome: (desktop) => Math.max(native.sampledAtUnixMs, desktop.sampledAtUnixMs), | |
| 393 | }), | |
| 394 | }); | |
| 395 | const nativeSampledAtMs = Option.map( | |
| 396 | input.nativeSnapshot, | |
| 397 | (snapshot) => snapshot.sampledAtUnixMs, | |
| 398 | ); | |
| 399 | const desktopSampledAtMs = Option.map( | |
| 400 | input.desktopSnapshot, | |
| 401 | (snapshot) => snapshot.sampledAtUnixMs, | |
| 402 | ); | |
| 403 | const nativeProcessPids = new Set(nativeProcesses.map((process) => process.pid)); | |
| 404 | const nativeByPid = new Map(nativeProcesses.map((process) => [process.pid, process])); | |
| 405 | const metricsByPid = new Map<number, DesktopElectronProcessMetric>(); | |
| 406 | for (const metric of electronMetrics) { | |
| 407 | const nativeProcess = nativeByPid.get(metric.pid); | |
| 408 | if (!nativeProcess) { | |
| 409 | nativeByPid.set( | |
| 410 | metric.pid, | |
| 411 | syntheticNativeSample( | |
| 412 | metric, | |
| 413 | Option.getOrElse(desktopSampledAtMs, () => sampledAtMs), | |
| 414 | input.previous.get(processIdentityKey(metric.pid, metric.creationTimeMs)), | |
| 415 | ), | |
| 416 | ); | |
| 417 | metricsByPid.set(metric.pid, metric); | |
| 418 | continue; | |
| 419 | } | |
| 420 | if ( | |
| 421 | Math.abs(metric.creationTimeMs - nativeProcess.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS | |
| 422 | ) { | |
| 423 | metricsByPid.set(metric.pid, metric); | |
| 424 | } | |
| 425 | } | |
| 426 | const processes = [...nativeByPid.values()]; | |
| 427 | const processesByPid = new Map(processes.map((process) => [process.pid, process])); | |
| 428 | const requestedElectronRootPids = input.electronRootPids ?? new Set<number>(); | |
| 429 | const explicitElectronRootPids = new Set( | |
| 430 | [...requestedElectronRootPids].filter((pid) => { | |
| 431 | const process = processesByPid.get(pid); | |
| 432 | if (!process) return false; | |
| 433 | const expectedStartTime = input.electronRootStartTimes?.get(pid); | |
| 434 | if (expectedStartTime !== undefined) { | |
| 435 | return Math.abs(process.startTimeMs - expectedStartTime) <= ELECTRON_IDENTITY_TOLERANCE_MS; | |
| 436 | } | |
| 437 | if (metricsByPid.has(pid)) return true; | |
| 438 | return [...input.previous.values()].some( | |
| 439 | (previous) => | |
| 440 | previous.process.category === "electron-main" && | |
| 441 | previous.process.identity.pid === pid && | |
| 442 | previous.process.identity.startTimeMs === process.startTimeMs, | |
| 443 | ); | |
| 444 | }), | |
| 445 | ); | |
| 446 | const electronPids = new Set([...metricsByPid.keys(), ...explicitElectronRootPids]); | |
| 447 | const electronRootPids = [ | |
| 448 | ...explicitElectronRootPids, | |
| 449 | ...[...electronPids] | |
| 450 | .filter((pid) => { | |
| 451 | if (explicitElectronRootPids.has(pid)) return false; | |
| 452 | const process = processesByPid.get(pid); | |
| 453 | return process === undefined | |
| 454 | ? true | |
| 455 | : !hasElectronAncestor(process, processesByPid, electronPids); | |
| 456 | }) | |
| 457 | .toSorted((left, right) => left - right), | |
| 458 | ].filter((pid, index, values) => values.indexOf(pid) === index); | |
| 459 | const rootPids = [input.serverPid, ...electronRootPids]; | |
| 460 | const roots = new Set(rootPids); | |
| 461 | const depths = processDepths(processes, roots); | |
| 462 | const childrenByParent = new Map<number, number[]>(); | |
| 463 | for (const process of processes) { | |
| 464 | const children = childrenByParent.get(process.ppid) ?? []; | |
| 465 | children.push(process.pid); | |
| 466 | childrenByParent.set(process.ppid, children); | |
| 467 | } | |
| 468 | ||
| 469 | const nextPrevious = new Map<string, ProcessState>(); | |
| 470 | const processDeltas: ProcessDelta[] = []; | |
| 471 | const normalized = processes.map((process): ResourceTelemetryProcess => { | |
| 472 | const identityKey = processIdentityKey(process.pid, process.startTimeMs); | |
| 473 | const previous = input.previous.get(identityKey); | |
| 474 | const counterSampledAtMs = nativeProcessPids.has(process.pid) | |
| 475 | ? Option.getOrElse(nativeSampledAtMs, () => sampledAtMs) | |
| 476 | : Option.getOrElse(desktopSampledAtMs, () => sampledAtMs); | |
| 477 | const elapsedMs = previous ? counterSampledAtMs - previous.sampledAtMs : 0; | |
| 478 | const cpuTimeDelta = previous | |
| 479 | ? delta({ | |
| 480 | current: process.cpuTimeMs, | |
| 481 | previous: previous.process.cpuTimeMs, | |
| 482 | elapsedMs, | |
| 483 | }) | |
| 484 | : 0; | |
| 485 | const ioReadDelta = previous | |
| 486 | ? delta({ | |
| 487 | current: process.ioReadBytes, | |
| 488 | previous: previous.process.ioReadBytes, | |
| 489 | elapsedMs, | |
| 490 | }) | |
| 491 | : 0; | |
| 492 | const ioWriteDelta = previous | |
| 493 | ? delta({ | |
| 494 | current: process.ioWriteBytes, | |
| 495 | previous: previous.process.ioWriteBytes, | |
| 496 | elapsedMs, | |
| 497 | }) | |
| 498 | : 0; | |
| 499 | const electronMetric = matchElectronMetric(process, metricsByPid); | |
| 500 | const category: ResourceTelemetryProcessCategory = | |
| 501 | process.pid === input.serverPid | |
| 502 | ? "server" | |
| 503 | : Option.contains(input.sidecarPid, process.pid) | |
| 504 | ? "resource-monitor" | |
| 505 | : explicitElectronRootPids.has(process.pid) | |
| 506 | ? "electron-main" | |
| 507 | : electronMetric | |
| 508 | ? electronCategory(electronMetric) | |
| 509 | : isElectronDescendant(process.pid, processesByPid, electronPids) | |
| 510 | ? inferredElectronCategory(process) | |
| 511 | : "server-child"; | |
| 512 | const firstSeenAt = previous?.process.firstSeenAt ?? DateTime.makeUnsafe(sampledAtMs); | |
| 513 | const preservePreviousRates = !input.updatePrevious && previous !== undefined; | |
| 514 | const cpuPercent = preservePreviousRates | |
| 515 | ? previous.process.cpuPercent | |
| 516 | : previous && elapsedMs > 0 && elapsedMs <= MAX_DELTA_INTERVAL_MS | |
| 517 | ? (cpuTimeDelta / elapsedMs) * 100 | |
| 518 | : finiteNonNegative(process.cpuPercent); | |
| 519 | const normalizedProcess: ResourceTelemetryProcess = { | |
| 520 | identity: { | |
| 521 | pid: process.pid, | |
| 522 | startTimeMs: process.startTimeMs, | |
| 523 | }, | |
| 524 | ppid: process.ppid, | |
| 525 | childPids: [...(childrenByParent.get(process.pid) ?? [])].toSorted( | |
| 526 | (left, right) => left - right, | |
| 527 | ), | |
| 528 | depth: depths.get(process.pid) ?? 0, | |
| 529 | name: process.name, | |
| 530 | command: process.command, | |
| 531 | status: process.status, | |
| 532 | category, | |
| 533 | ...(electronMetric ? { electronType: electronMetric.type } : {}), | |
| 534 | ...(electronMetric?.serviceName ? { electronServiceName: electronMetric.serviceName } : {}), | |
| 535 | cpuPercent: finiteNonNegative(cpuPercent), | |
| 536 | cpuTimeMs: process.cpuTimeMs, | |
| 537 | residentBytes: process.residentBytes, | |
| 538 | peakResidentBytes: Math.max( | |
| 539 | process.residentBytes, | |
| 540 | electronMetric?.peakWorkingSetBytes ?? 0, | |
| 541 | previous?.process.peakResidentBytes ?? 0, | |
| 542 | ), | |
| 543 | virtualBytes: process.virtualBytes, | |
| 544 | ioReadBytes: process.ioReadBytes, | |
| 545 | ioWriteBytes: process.ioWriteBytes, | |
| 546 | ioReadBytesPerSecond: preservePreviousRates | |
| 547 | ? previous.process.ioReadBytesPerSecond | |
| 548 | : elapsedMs > 0 | |
| 549 | ? finiteNonNegative((ioReadDelta * 1_000) / elapsedMs) | |
| 550 | : 0, | |
| 551 | ioWriteBytesPerSecond: preservePreviousRates | |
| 552 | ? previous.process.ioWriteBytesPerSecond | |
| 553 | : elapsedMs > 0 | |
| 554 | ? finiteNonNegative((ioWriteDelta * 1_000) / elapsedMs) | |
| 555 | : 0, | |
| 556 | ioSemantics: process.ioSemantics, | |
| 557 | ...(electronMetric ? { idleWakeupsPerSecond: electronMetric.idleWakeupsPerSecond } : {}), | |
| 558 | runTimeMs: process.runTimeMs, | |
| 559 | firstSeenAt, | |
| 560 | lastSeenAt: DateTime.makeUnsafe(sampledAtMs), | |
| 561 | }; | |
| 562 | nextPrevious.set(identityKey, { | |
| 563 | process: normalizedProcess, | |
| 564 | sampledAtMs: counterSampledAtMs, | |
| 565 | }); | |
| 566 | processDeltas.push({ | |
| 567 | identityKey, | |
| 568 | category, | |
| 569 | cpuTimeMs: cpuTimeDelta, | |
| 570 | ioReadBytes: ioReadDelta, | |
| 571 | ioWriteBytes: ioWriteDelta, | |
| 572 | }); | |
| 573 | return normalizedProcess; | |
| 574 | }); | |
| 575 | const ordered = orderProcessTree(normalized, rootPids); | |
| 576 | ||
| 577 | const counters = input.updatePrevious | |
| 578 | ? applyLifecycleCounters({ | |
| 579 | counters: input.counters, | |
| 580 | deltas: processDeltas, | |
| 581 | current: nextPrevious, | |
| 582 | previous: input.previous, | |
| 583 | }) | |
| 584 | : input.counters; | |
| 585 | const backendProcesses = ordered.filter( | |
| 586 | (process) => categoryGroup(process.category) === "backend", | |
| 587 | ); | |
| 588 | const electronProcesses = ordered.filter( | |
| 589 | (process) => categoryGroup(process.category) === "electron", | |
| 590 | ); | |
| 591 | const monitorProcesses = ordered.filter( | |
| 592 | (process) => categoryGroup(process.category) === "monitor", | |
| 593 | ); | |
| 594 | ||
| 595 | return { | |
| 596 | sampledAtMs, | |
| 597 | processes: ordered, | |
| 598 | previous: input.updatePrevious ? nextPrevious : input.previous, | |
| 599 | counters, | |
| 600 | groups: { | |
| 601 | backend: aggregate(backendProcesses, counters.backend), | |
| 602 | electron: aggregate(electronProcesses, counters.electron), | |
| 603 | monitor: aggregate(monitorProcesses, counters.monitor), | |
| 604 | allT3: aggregate(ordered, counters.allT3), | |
| 605 | }, | |
| 606 | deltas: processDeltas, | |
| 607 | }; | |
| 608 | } |
apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts created+241| ... | ... | @@ -0,0 +1,241 @@ |
| 1 | import type { HostPowerSnapshot } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import * as DateTime from "effect/DateTime"; | |
| 5 | import * as Deferred from "effect/Deferred"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as Ref from "effect/Ref"; | |
| 9 | import * as Semaphore from "effect/Semaphore"; | |
| 10 | ||
| 11 | import { | |
| 12 | NativeTelemetryRequestTimedOut, | |
| 13 | NativeTelemetryStreamClosed, | |
| 14 | canCommandNativeTelemetrySidecar, | |
| 15 | canRequestNativeTelemetryRetry, | |
| 16 | commitCollectionControlUpdate, | |
| 17 | nativeTelemetrySupervisorFailureMessage, | |
| 18 | retainRecentNativeTelemetryFailures, | |
| 19 | resolveNativeSampleIntervalMs, | |
| 20 | synchronizeCollectionControlOnStart, | |
| 21 | } from "./NativeTelemetryClient.ts"; | |
| 22 | ||
| 23 | const basePower: HostPowerSnapshot = { | |
| 24 | source: "electron-main", | |
| 25 | idle: "false", | |
| 26 | idleSeconds: 0, | |
| 27 | locked: "false", | |
| 28 | suspended: false, | |
| 29 | onBattery: "false", | |
| 30 | lowPowerMode: "false", | |
| 31 | thermalState: "nominal", | |
| 32 | stale: false, | |
| 33 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), | |
| 34 | }; | |
| 35 | ||
| 36 | describe("resolveNativeSampleIntervalMs", () => { | |
| 37 | it("keeps a recovery cadence while suspended and backs off under host constraints", () => { | |
| 38 | expect(resolveNativeSampleIntervalMs({ ...basePower, suspended: true }, 1)).toBe(15_000); | |
| 39 | expect(resolveNativeSampleIntervalMs({ ...basePower, locked: "true" }, 1)).toBe(15_000); | |
| 40 | expect(resolveNativeSampleIntervalMs({ ...basePower, lowPowerMode: "true" }, 1)).toBe(15_000); | |
| 41 | expect(resolveNativeSampleIntervalMs({ ...basePower, thermalState: "critical" }, 1)).toBe( | |
| 42 | 15_000, | |
| 43 | ); | |
| 44 | expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); | |
| 45 | }); | |
| 46 | ||
| 47 | it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { | |
| 48 | const unknown: HostPowerSnapshot = { | |
| 49 | ...basePower, | |
| 50 | source: "unknown", | |
| 51 | stale: true, | |
| 52 | }; | |
| 53 | expect(resolveNativeSampleIntervalMs(unknown, 0)).toBe(5_000); | |
| 54 | expect(resolveNativeSampleIntervalMs(unknown, 1)).toBe(1_000); | |
| 55 | expect( | |
| 56 | resolveNativeSampleIntervalMs( | |
| 57 | { ...basePower, stale: true, locked: "true", suspended: true }, | |
| 58 | 0, | |
| 59 | ), | |
| 60 | ).toBe(5_000); | |
| 61 | expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | describe("canRequestNativeTelemetryRetry", () => { | |
| 66 | it("only accepts retry while the supervisor is waiting without a live sidecar", () => { | |
| 67 | expect(canRequestNativeTelemetryRetry("degraded", false)).toBe(true); | |
| 68 | expect(canRequestNativeTelemetryRetry("unavailable", false)).toBe(true); | |
| 69 | expect(canRequestNativeTelemetryRetry("degraded", true)).toBe(false); | |
| 70 | expect(canRequestNativeTelemetryRetry("healthy", false)).toBe(false); | |
| 71 | expect(canRequestNativeTelemetryRetry("starting", false)).toBe(false); | |
| 72 | }); | |
| 73 | }); | |
| 74 | ||
| 75 | describe("canCommandNativeTelemetrySidecar", () => { | |
| 76 | it("keeps on-demand recovery commands available while a live sidecar is degraded", () => { | |
| 77 | expect(canCommandNativeTelemetrySidecar("healthy", true)).toBe(true); | |
| 78 | expect(canCommandNativeTelemetrySidecar("degraded", true)).toBe(true); | |
| 79 | expect(canCommandNativeTelemetrySidecar("unavailable", true)).toBe(false); | |
| 80 | expect(canCommandNativeTelemetrySidecar("degraded", false)).toBe(false); | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 84 | describe("NativeTelemetryRequestTimedOut", () => { | |
| 85 | it("models history and sample request deadlines without a fabricated cause", () => { | |
| 86 | const historyTimeout = new NativeTelemetryRequestTimedOut({ | |
| 87 | operation: "readHistory", | |
| 88 | timeoutMs: 15_000, | |
| 89 | }); | |
| 90 | const sampleTimeout = new NativeTelemetryRequestTimedOut({ | |
| 91 | operation: "sampleNow", | |
| 92 | timeoutMs: 5_000, | |
| 93 | }); | |
| 94 | ||
| 95 | expect(historyTimeout.message).toBe( | |
| 96 | "Resource monitor 'readHistory' request timed out after 15000ms.", | |
| 97 | ); | |
| 98 | expect(sampleTimeout.message).toBe( | |
| 99 | "Resource monitor 'sampleNow' request timed out after 5000ms.", | |
| 100 | ); | |
| 101 | expect("cause" in historyTimeout).toBe(false); | |
| 102 | expect("cause" in sampleTimeout).toBe(false); | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | describe("native telemetry supervisor failures", () => { | |
| 107 | it("distinguishes a closed event stream from a process exit", () => { | |
| 108 | expect(new NativeTelemetryStreamClosed().message).toBe( | |
| 109 | "Resource monitor event stream closed unexpectedly.", | |
| 110 | ); | |
| 111 | }); | |
| 112 | ||
| 113 | it("keeps defect details out of the caller-visible health message", () => { | |
| 114 | const secret = "credential=do-not-expose"; | |
| 115 | const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret))); | |
| 116 | ||
| 117 | expect(message).toBe("Resource monitor supervisor stopped unexpectedly."); | |
| 118 | expect(message).not.toContain(secret); | |
| 119 | }); | |
| 120 | }); | |
| 121 | ||
| 122 | describe("retainRecentNativeTelemetryFailures", () => { | |
| 123 | it("expires old failures so an isolated crash restarts from the initial backoff", () => { | |
| 124 | expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); | |
| 125 | expect(retainRecentNativeTelemetryFailures([30_000, 60_000], 90_000)).toEqual([30_000, 60_000]); | |
| 126 | }); | |
| 127 | }); | |
| 128 | ||
| 129 | describe("commitCollectionControlUpdate", () => { | |
| 130 | it.effect("retains desired demand and retries unapplied sidecar state", () => | |
| 131 | Effect.gen(function* () { | |
| 132 | const initial = { | |
| 133 | hostPower: basePower, | |
| 134 | liveSubscriberCount: 0, | |
| 135 | sampleIntervalMs: 5_000, | |
| 136 | }; | |
| 137 | const desired = yield* Ref.make(initial); | |
| 138 | const applied = yield* Ref.make(initial); | |
| 139 | const failure = new Error("sidecar write failed"); | |
| 140 | const receivedStates: Array<readonly [number, number]> = []; | |
| 141 | ||
| 142 | const received = yield* commitCollectionControlUpdate( | |
| 143 | desired, | |
| 144 | applied, | |
| 145 | (current) => ({ | |
| 146 | ...current, | |
| 147 | liveSubscriberCount: 1, | |
| 148 | sampleIntervalMs: 1_000, | |
| 149 | }), | |
| 150 | (previous, next) => { | |
| 151 | receivedStates.push([previous.sampleIntervalMs, next.sampleIntervalMs]); | |
| 152 | return Effect.fail(failure); | |
| 153 | }, | |
| 154 | ).pipe(Effect.flip); | |
| 155 | ||
| 156 | expect(received).toBe(failure); | |
| 157 | expect(yield* Ref.get(desired)).toEqual({ | |
| 158 | ...initial, | |
| 159 | liveSubscriberCount: 1, | |
| 160 | sampleIntervalMs: 1_000, | |
| 161 | }); | |
| 162 | expect(yield* Ref.get(applied)).toEqual(initial); | |
| 163 | ||
| 164 | yield* commitCollectionControlUpdate( | |
| 165 | desired, | |
| 166 | applied, | |
| 167 | (current) => current, | |
| 168 | (previous, next) => { | |
| 169 | receivedStates.push([previous.sampleIntervalMs, next.sampleIntervalMs]); | |
| 170 | return Effect.void; | |
| 171 | }, | |
| 172 | ); | |
| 173 | expect(receivedStates).toEqual([ | |
| 174 | [5_000, 1_000], | |
| 175 | [5_000, 1_000], | |
| 176 | ]); | |
| 177 | expect(yield* Ref.get(applied)).toEqual(yield* Ref.get(desired)); | |
| 178 | }), | |
| 179 | ); | |
| 180 | ||
| 181 | it.effect("serializes startup synchronization with runtime control updates", () => | |
| 182 | Effect.gen(function* () { | |
| 183 | const initial = { | |
| 184 | hostPower: basePower, | |
| 185 | liveSubscriberCount: 0, | |
| 186 | sampleIntervalMs: 5_000, | |
| 187 | }; | |
| 188 | const desired = yield* Ref.make(initial); | |
| 189 | const applied = yield* Ref.make(initial); | |
| 190 | const ready = yield* Ref.make(false); | |
| 191 | const mutex = yield* Semaphore.make(1); | |
| 192 | const startupApplying = yield* Deferred.make<void>(); | |
| 193 | const releaseStartup = yield* Deferred.make<void>(); | |
| 194 | const appliedIntervals: Array<number> = []; | |
| 195 | ||
| 196 | const startupFiber = yield* synchronizeCollectionControlOnStart( | |
| 197 | mutex, | |
| 198 | desired, | |
| 199 | applied, | |
| 200 | (control) => | |
| 201 | Effect.sync(() => { | |
| 202 | appliedIntervals.push(control.sampleIntervalMs); | |
| 203 | }).pipe( | |
| 204 | Effect.andThen(Deferred.succeed(startupApplying, undefined)), | |
| 205 | Effect.andThen(Deferred.await(releaseStartup)), | |
| 206 | Effect.asVoid, | |
| 207 | ), | |
| 208 | Ref.set(ready, true), | |
| 209 | ).pipe(Effect.forkChild); | |
| 210 | yield* Deferred.await(startupApplying); | |
| 211 | ||
| 212 | const updateFiber = yield* mutex | |
| 213 | .withPermits(1)( | |
| 214 | commitCollectionControlUpdate( | |
| 215 | desired, | |
| 216 | applied, | |
| 217 | (current) => ({ | |
| 218 | ...current, | |
| 219 | liveSubscriberCount: 1, | |
| 220 | sampleIntervalMs: 1_000, | |
| 221 | }), | |
| 222 | (_previous, next) => | |
| 223 | Effect.gen(function* () { | |
| 224 | expect(yield* Ref.get(ready)).toBe(true); | |
| 225 | appliedIntervals.push(next.sampleIntervalMs); | |
| 226 | }), | |
| 227 | ), | |
| 228 | ) | |
| 229 | .pipe(Effect.forkChild); | |
| 230 | yield* Effect.yieldNow; | |
| 231 | expect(yield* Ref.get(desired)).toEqual(initial); | |
| 232 | ||
| 233 | yield* Deferred.succeed(releaseStartup, undefined); | |
| 234 | yield* Fiber.join(startupFiber); | |
| 235 | yield* Fiber.join(updateFiber); | |
| 236 | ||
| 237 | expect(appliedIntervals).toEqual([5_000, 1_000]); | |
| 238 | expect(yield* Ref.get(applied)).toEqual(yield* Ref.get(desired)); | |
| 239 | }), | |
| 240 | ); | |
| 241 | }); |
apps/server/src/resourceTelemetry/NativeTelemetryClient.ts created+1024| ... | ... | @@ -0,0 +1,1024 @@ |
| 1 | import type { | |
| 2 | HostPowerSnapshot, | |
| 3 | ResourceMonitorCapabilities, | |
| 4 | ResourceMonitorCommand, | |
| 5 | ResourceMonitorEvent, | |
| 6 | ResourceMonitorExternalProcess, | |
| 7 | ResourceMonitorHelloEvent, | |
| 8 | ResourceMonitorSnapshotEvent, | |
| 9 | ResourceTelemetrySourceStatus, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { | |
| 12 | RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 13 | ResourceMonitorCommand as ResourceMonitorCommandSchema, | |
| 14 | ResourceMonitorEvent as ResourceMonitorEventSchema, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import * as Cause from "effect/Cause"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import * as Crypto from "effect/Crypto"; | |
| 19 | import * as DateTime from "effect/DateTime"; | |
| 20 | import * as Deferred from "effect/Deferred"; | |
| 21 | import * as Duration from "effect/Duration"; | |
| 22 | import * as Effect from "effect/Effect"; | |
| 23 | import * as Fiber from "effect/Fiber"; | |
| 24 | import * as Layer from "effect/Layer"; | |
| 25 | import * as Option from "effect/Option"; | |
| 26 | import * as PubSub from "effect/PubSub"; | |
| 27 | import * as Queue from "effect/Queue"; | |
| 28 | import * as Ref from "effect/Ref"; | |
| 29 | import * as Result from "effect/Result"; | |
| 30 | import * as Schema from "effect/Schema"; | |
| 31 | import * as Semaphore from "effect/Semaphore"; | |
| 32 | import * as Scope from "effect/Scope"; | |
| 33 | import * as Stream from "effect/Stream"; | |
| 34 | import * as Ndjson from "effect/unstable/encoding/Ndjson"; | |
| 35 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 36 | ||
| 37 | import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; | |
| 38 | import { ServerConfig } from "../config.ts"; | |
| 39 | import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts"; | |
| 40 | ||
| 41 | const SAMPLE_INTERVAL_MS = 1_000; | |
| 42 | const UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS = 5_000; | |
| 43 | const BATTERY_SAMPLE_INTERVAL_MS = 5_000; | |
| 44 | const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000; | |
| 45 | const HANDSHAKE_TIMEOUT = Duration.seconds(5); | |
| 46 | const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5); | |
| 47 | const HISTORY_REQUEST_TIMEOUT = Duration.seconds(15); | |
| 48 | const INITIAL_RESTART_DELAY = Duration.millis(500); | |
| 49 | const MAX_RESTART_DELAY = Duration.seconds(10); | |
| 50 | const FAILURE_WINDOW_MS = 60_000; | |
| 51 | const MAX_FAILURES_PER_WINDOW = 5; | |
| 52 | ||
| 53 | export class NativeTelemetrySpawnFailed extends Schema.TaggedErrorClass<NativeTelemetrySpawnFailed>()( | |
| 54 | "NativeTelemetrySpawnFailed", | |
| 55 | { | |
| 56 | path: Schema.String, | |
| 57 | cause: Schema.Defect(), | |
| 58 | }, | |
| 59 | ) { | |
| 60 | override get message(): string { | |
| 61 | return `Failed to start resource monitor '${this.path}'.`; | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass<NativeTelemetryHandshakeTimedOut>()( | |
| 66 | "NativeTelemetryHandshakeTimedOut", | |
| 67 | { | |
| 68 | timeoutMs: Schema.Number, | |
| 69 | }, | |
| 70 | ) { | |
| 71 | override get message(): string { | |
| 72 | return `Resource monitor handshake timed out after ${this.timeoutMs}ms.`; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | export class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass<NativeTelemetryRequestTimedOut>()( | |
| 77 | "NativeTelemetryRequestTimedOut", | |
| 78 | { | |
| 79 | operation: Schema.Literals(["readHistory", "sampleNow"]), | |
| 80 | timeoutMs: Schema.Number, | |
| 81 | }, | |
| 82 | ) { | |
| 83 | override get message(): string { | |
| 84 | return `Resource monitor '${this.operation}' request timed out after ${this.timeoutMs}ms.`; | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | export class NativeTelemetryProtocolMismatch extends Schema.TaggedErrorClass<NativeTelemetryProtocolMismatch>()( | |
| 89 | "NativeTelemetryProtocolMismatch", | |
| 90 | { | |
| 91 | expectedVersion: Schema.Number, | |
| 92 | receivedVersion: Schema.Number, | |
| 93 | }, | |
| 94 | ) { | |
| 95 | override get message(): string { | |
| 96 | return `Resource monitor protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`; | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | export class NativeTelemetryDecodeFailed extends Schema.TaggedErrorClass<NativeTelemetryDecodeFailed>()( | |
| 101 | "NativeTelemetryDecodeFailed", | |
| 102 | { | |
| 103 | cause: Schema.Defect(), | |
| 104 | }, | |
| 105 | ) { | |
| 106 | override get message(): string { | |
| 107 | return "Failed to decode resource monitor output."; | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | export class NativeTelemetryCommandFailed extends Schema.TaggedErrorClass<NativeTelemetryCommandFailed>()( | |
| 112 | "NativeTelemetryCommandFailed", | |
| 113 | { | |
| 114 | operation: Schema.String, | |
| 115 | cause: Schema.Defect(), | |
| 116 | }, | |
| 117 | ) { | |
| 118 | override get message(): string { | |
| 119 | return `Resource monitor command '${this.operation}' failed.`; | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | export class NativeTelemetryExited extends Schema.TaggedErrorClass<NativeTelemetryExited>()( | |
| 124 | "NativeTelemetryExited", | |
| 125 | { | |
| 126 | exitCode: Schema.Number, | |
| 127 | }, | |
| 128 | ) { | |
| 129 | override get message(): string { | |
| 130 | return `Resource monitor exited with code ${this.exitCode}.`; | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | export class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass<NativeTelemetryStreamClosed>()( | |
| 135 | "NativeTelemetryStreamClosed", | |
| 136 | {}, | |
| 137 | ) { | |
| 138 | override get message(): string { | |
| 139 | return "Resource monitor event stream closed unexpectedly."; | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | export class NativeTelemetryUnavailable extends Schema.TaggedErrorClass<NativeTelemetryUnavailable>()( | |
| 144 | "NativeTelemetryUnavailable", | |
| 145 | { | |
| 146 | reason: Schema.String, | |
| 147 | }, | |
| 148 | ) { | |
| 149 | override get message(): string { | |
| 150 | return `Resource monitor is unavailable: ${this.reason}`; | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | export type NativeTelemetryClientError = | |
| 155 | | ResourceMonitorBinary.ResourceMonitorBinaryError | |
| 156 | | NativeTelemetrySpawnFailed | |
| 157 | | NativeTelemetryHandshakeTimedOut | |
| 158 | | NativeTelemetryRequestTimedOut | |
| 159 | | NativeTelemetryProtocolMismatch | |
| 160 | | NativeTelemetryDecodeFailed | |
| 161 | | NativeTelemetryCommandFailed | |
| 162 | | NativeTelemetryExited | |
| 163 | | NativeTelemetryStreamClosed | |
| 164 | | NativeTelemetryUnavailable; | |
| 165 | ||
| 166 | export interface NativeTelemetryClientHealth { | |
| 167 | readonly status: ResourceTelemetrySourceStatus; | |
| 168 | readonly hello: Option.Option<ResourceMonitorHelloEvent>; | |
| 169 | readonly lastSampleAt: Option.Option<DateTime.Utc>; | |
| 170 | readonly lastError: Option.Option<string>; | |
| 171 | readonly restartCount: number; | |
| 172 | readonly sampleIntervalMs: number; | |
| 173 | } | |
| 174 | ||
| 175 | export interface NativeTelemetrySnapshot { | |
| 176 | readonly generation: number; | |
| 177 | readonly snapshot: ResourceMonitorSnapshotEvent; | |
| 178 | } | |
| 179 | ||
| 180 | export class NativeTelemetryClient extends Context.Service< | |
| 181 | NativeTelemetryClient, | |
| 182 | { | |
| 183 | readonly capabilities: Effect.Effect<ResourceMonitorCapabilities, NativeTelemetryClientError>; | |
| 184 | readonly snapshots: Stream.Stream<NativeTelemetrySnapshot, NativeTelemetryClientError>; | |
| 185 | readonly readHistory: ( | |
| 186 | windowMs: number, | |
| 187 | ) => Effect.Effect<ReadonlyArray<ResourceMonitorSnapshotEvent>, NativeTelemetryClientError>; | |
| 188 | readonly setExternalProcesses: ( | |
| 189 | processes: ReadonlyArray<ResourceMonitorExternalProcess>, | |
| 190 | ) => Effect.Effect<void, NativeTelemetryClientError>; | |
| 191 | readonly setHostPowerState: ( | |
| 192 | snapshot: HostPowerSnapshot, | |
| 193 | ) => Effect.Effect<void, NativeTelemetryClientError>; | |
| 194 | readonly sampleNow: Effect.Effect<NativeTelemetrySnapshot, NativeTelemetryClientError>; | |
| 195 | readonly retry: Effect.Effect<boolean>; | |
| 196 | readonly health: Effect.Effect<NativeTelemetryClientHealth>; | |
| 197 | readonly subscribeHealth: Effect.Effect< | |
| 198 | { | |
| 199 | readonly latest: NativeTelemetryClientHealth; | |
| 200 | readonly changes: Stream.Stream<NativeTelemetryClientHealth>; | |
| 201 | }, | |
| 202 | never, | |
| 203 | Scope.Scope | |
| 204 | >; | |
| 205 | } | |
| 206 | >()("t3/resourceTelemetry/NativeTelemetryClient") {} | |
| 207 | ||
| 208 | interface ClientState { | |
| 209 | readonly status: ResourceTelemetrySourceStatus; | |
| 210 | readonly handle: Option.Option<ChildProcessSpawner.ChildProcessHandle>; | |
| 211 | readonly hello: Option.Option<ResourceMonitorHelloEvent>; | |
| 212 | readonly lastSampleAt: Option.Option<DateTime.Utc>; | |
| 213 | readonly lastError: Option.Option<string>; | |
| 214 | readonly restartCount: number; | |
| 215 | } | |
| 216 | ||
| 217 | export interface CollectionControl { | |
| 218 | readonly hostPower: HostPowerSnapshot; | |
| 219 | readonly liveSubscriberCount: number; | |
| 220 | readonly sampleIntervalMs: number; | |
| 221 | } | |
| 222 | ||
| 223 | interface PendingHistoryRequest { | |
| 224 | readonly deferred: Deferred.Deferred< | |
| 225 | ReadonlyArray<ResourceMonitorSnapshotEvent>, | |
| 226 | NativeTelemetryClientError | |
| 227 | >; | |
| 228 | readonly snapshots: ReadonlyArray<ResourceMonitorSnapshotEvent>; | |
| 229 | } | |
| 230 | ||
| 231 | const initialState: ClientState = { | |
| 232 | status: "starting", | |
| 233 | handle: Option.none(), | |
| 234 | hello: Option.none(), | |
| 235 | lastSampleAt: Option.none(), | |
| 236 | lastError: Option.none(), | |
| 237 | restartCount: 0, | |
| 238 | }; | |
| 239 | ||
| 240 | function toHealth(state: ClientState, sampleIntervalMs: number): NativeTelemetryClientHealth { | |
| 241 | return { | |
| 242 | status: state.status, | |
| 243 | hello: state.hello, | |
| 244 | lastSampleAt: state.lastSampleAt, | |
| 245 | lastError: state.lastError, | |
| 246 | restartCount: state.restartCount, | |
| 247 | sampleIntervalMs, | |
| 248 | }; | |
| 249 | } | |
| 250 | ||
| 251 | function isThermallyConstrained(snapshot: HostPowerSnapshot): boolean { | |
| 252 | return snapshot.thermalState === "serious" || snapshot.thermalState === "critical"; | |
| 253 | } | |
| 254 | ||
| 255 | export function resolveNativeSampleIntervalMs( | |
| 256 | snapshot: HostPowerSnapshot, | |
| 257 | liveSubscriberCount: number, | |
| 258 | ): number { | |
| 259 | if (snapshot.stale || snapshot.source === "unknown") { | |
| 260 | return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; | |
| 261 | } | |
| 262 | if ( | |
| 263 | snapshot.suspended || | |
| 264 | snapshot.locked === "true" || | |
| 265 | snapshot.lowPowerMode === "true" || | |
| 266 | isThermallyConstrained(snapshot) | |
| 267 | ) { | |
| 268 | return CONSTRAINED_SAMPLE_INTERVAL_MS; | |
| 269 | } | |
| 270 | if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; | |
| 271 | return SAMPLE_INTERVAL_MS; | |
| 272 | } | |
| 273 | ||
| 274 | export function commitCollectionControlUpdate<E, R>( | |
| 275 | desiredState: Ref.Ref<CollectionControl>, | |
| 276 | appliedState: Ref.Ref<CollectionControl>, | |
| 277 | update: (current: CollectionControl) => CollectionControl, | |
| 278 | apply: (previous: CollectionControl, next: CollectionControl) => Effect.Effect<void, E, R>, | |
| 279 | ): Effect.Effect<readonly [CollectionControl, CollectionControl], E, R> { | |
| 280 | return Effect.gen(function* () { | |
| 281 | const [previousDesired, next] = yield* Ref.modify(desiredState, (previous) => { | |
| 282 | const next = update(previous); | |
| 283 | return [[previous, next] as const, next]; | |
| 284 | }); | |
| 285 | const previousApplied = yield* Ref.get(appliedState); | |
| 286 | yield* apply(previousApplied, next); | |
| 287 | yield* Ref.set(appliedState, next); | |
| 288 | return [previousDesired, next] as const; | |
| 289 | }); | |
| 290 | } | |
| 291 | ||
| 292 | export function synchronizeCollectionControlOnStart<E1, R1, E2, R2>( | |
| 293 | mutex: Semaphore.Semaphore, | |
| 294 | desiredState: Ref.Ref<CollectionControl>, | |
| 295 | appliedState: Ref.Ref<CollectionControl>, | |
| 296 | apply: (control: CollectionControl) => Effect.Effect<void, E1, R1>, | |
| 297 | markReady: Effect.Effect<void, E2, R2>, | |
| 298 | ) { | |
| 299 | return mutex.withPermits(1)( | |
| 300 | Effect.gen(function* () { | |
| 301 | const control = yield* Ref.get(desiredState); | |
| 302 | yield* apply(control); | |
| 303 | yield* Ref.set(appliedState, control); | |
| 304 | yield* markReady; | |
| 305 | return control; | |
| 306 | }), | |
| 307 | ); | |
| 308 | } | |
| 309 | ||
| 310 | const decodeMonitorEvent: ( | |
| 311 | value: unknown, | |
| 312 | ) => Effect.Effect<ResourceMonitorEvent, Schema.SchemaError> = Schema.decodeUnknownEffect( | |
| 313 | ResourceMonitorEventSchema, | |
| 314 | ); | |
| 315 | const encodeMonitorCommand = Schema.encodeEffect( | |
| 316 | Schema.fromJsonString(ResourceMonitorCommandSchema), | |
| 317 | ); | |
| 318 | const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch); | |
| 319 | const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed); | |
| 320 | const isCommandFailed = Schema.is(NativeTelemetryCommandFailed); | |
| 321 | ||
| 322 | function eventVersion(value: unknown): number | undefined { | |
| 323 | if (typeof value !== "object" || value === null) return undefined; | |
| 324 | const version = Reflect.get(value, "version"); | |
| 325 | return typeof version === "number" ? version : undefined; | |
| 326 | } | |
| 327 | ||
| 328 | function restartDelay(attempt: number): Duration.Duration { | |
| 329 | return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); | |
| 330 | } | |
| 331 | ||
| 332 | export function retainRecentNativeTelemetryFailures( | |
| 333 | failures: ReadonlyArray<number>, | |
| 334 | now: number, | |
| 335 | ): ReadonlyArray<number> { | |
| 336 | return failures.filter((failedAt) => now - failedAt <= FAILURE_WINDOW_MS); | |
| 337 | } | |
| 338 | ||
| 339 | function errorMessage(error: NativeTelemetryClientError): string { | |
| 340 | return error.message; | |
| 341 | } | |
| 342 | ||
| 343 | export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause<unknown>): string { | |
| 344 | return "Resource monitor supervisor stopped unexpectedly."; | |
| 345 | } | |
| 346 | ||
| 347 | export function canRequestNativeTelemetryRetry( | |
| 348 | status: ResourceTelemetrySourceStatus, | |
| 349 | hasHandle: boolean, | |
| 350 | ): boolean { | |
| 351 | return status !== "healthy" && status !== "starting" && !hasHandle; | |
| 352 | } | |
| 353 | ||
| 354 | export function canCommandNativeTelemetrySidecar( | |
| 355 | status: ResourceTelemetrySourceStatus, | |
| 356 | hasHandle: boolean, | |
| 357 | ): boolean { | |
| 358 | return hasHandle && (status === "healthy" || status === "degraded"); | |
| 359 | } | |
| 360 | ||
| 361 | export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { | |
| 362 | const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; | |
| 363 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 364 | const crypto = yield* Crypto.Crypto; | |
| 365 | const config = yield* ServerConfig; | |
| 366 | const initializedAt = yield* DateTime.now; | |
| 367 | const state = yield* Ref.make(initialState); | |
| 368 | const collectionControl = yield* Ref.make<CollectionControl>({ | |
| 369 | hostPower: { | |
| 370 | source: "unknown", | |
| 371 | idle: "unknown", | |
| 372 | idleSeconds: null, | |
| 373 | locked: "unknown", | |
| 374 | suspended: false, | |
| 375 | onBattery: "unknown", | |
| 376 | lowPowerMode: "unknown", | |
| 377 | thermalState: "unknown", | |
| 378 | stale: true, | |
| 379 | updatedAt: initializedAt, | |
| 380 | }, | |
| 381 | liveSubscriberCount: 0, | |
| 382 | sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS, | |
| 383 | }); | |
| 384 | const appliedCollectionControl = yield* Ref.make(yield* Ref.get(collectionControl)); | |
| 385 | const externalProcesses = yield* Ref.make<ReadonlyArray<ResourceMonitorExternalProcess>>([]); | |
| 386 | const pendingSamples = yield* Ref.make( | |
| 387 | new Map<string, Deferred.Deferred<NativeTelemetrySnapshot, NativeTelemetryClientError>>(), | |
| 388 | ); | |
| 389 | const pendingHistories = yield* Ref.make(new Map<string, PendingHistoryRequest>()); | |
| 390 | const snapshots = yield* PubSub.sliding<NativeTelemetrySnapshot>(8); | |
| 391 | const healthChanges = yield* PubSub.sliding<NativeTelemetryClientHealth>(4); | |
| 392 | const retryQueue = yield* Queue.sliding<void>(1); | |
| 393 | const commandMutex = yield* Semaphore.make(1); | |
| 394 | const controlMutex = yield* Semaphore.make(1); | |
| 395 | const currentHealth = Effect.all([Ref.get(state), Ref.get(collectionControl)]).pipe( | |
| 396 | Effect.map(([current, control]) => toHealth(current, control.sampleIntervalMs)), | |
| 397 | ); | |
| 398 | const publishHealth = currentHealth.pipe( | |
| 399 | Effect.flatMap((health) => PubSub.publish(healthChanges, health)), | |
| 400 | Effect.asVoid, | |
| 401 | ); | |
| 402 | ||
| 403 | const failPending = (error: NativeTelemetryClientError) => | |
| 404 | Effect.gen(function* () { | |
| 405 | const samples = yield* Ref.getAndSet(pendingSamples, new Map()); | |
| 406 | const histories = yield* Ref.getAndSet(pendingHistories, new Map()); | |
| 407 | yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), { | |
| 408 | discard: true, | |
| 409 | }); | |
| 410 | yield* Effect.forEach( | |
| 411 | histories.values(), | |
| 412 | (request) => Deferred.fail(request.deferred, error), | |
| 413 | { discard: true }, | |
| 414 | ); | |
| 415 | }); | |
| 416 | ||
| 417 | const writeCommand = ( | |
| 418 | handle: ChildProcessSpawner.ChildProcessHandle, | |
| 419 | command: ResourceMonitorCommand, | |
| 420 | ): Effect.Effect<void, NativeTelemetryClientError> => | |
| 421 | commandMutex.withPermits(1)( | |
| 422 | encodeMonitorCommand(command).pipe( | |
| 423 | Effect.map((encoded) => `${encoded}\n`), | |
| 424 | Effect.mapError( | |
| 425 | (cause) => | |
| 426 | new NativeTelemetryCommandFailed({ | |
| 427 | operation: command.type, | |
| 428 | cause, | |
| 429 | }), | |
| 430 | ), | |
| 431 | Effect.flatMap((encoded) => | |
| 432 | Stream.run(Stream.encodeText(Stream.make(encoded)), handle.stdin), | |
| 433 | ), | |
| 434 | Effect.mapError( | |
| 435 | (cause) => | |
| 436 | new NativeTelemetryCommandFailed({ | |
| 437 | operation: command.type, | |
| 438 | cause, | |
| 439 | }), | |
| 440 | ), | |
| 441 | ), | |
| 442 | ); | |
| 443 | ||
| 444 | const processEvent = ( | |
| 445 | event: ResourceMonitorEvent, | |
| 446 | helloDeferred: Deferred.Deferred<ResourceMonitorHelloEvent>, | |
| 447 | generation: number, | |
| 448 | ): Effect.Effect<void, NativeTelemetryClientError> => { | |
| 449 | switch (event.type) { | |
| 450 | case "hello": | |
| 451 | return Ref.update(state, (current) => ({ | |
| 452 | ...current, | |
| 453 | status: "starting" as const, | |
| 454 | hello: Option.some(event), | |
| 455 | lastError: Option.none(), | |
| 456 | })).pipe( | |
| 457 | Effect.andThen(publishHealth), | |
| 458 | Effect.andThen(Deferred.succeed(helloDeferred, event)), | |
| 459 | Effect.asVoid, | |
| 460 | ); | |
| 461 | case "snapshot": | |
| 462 | return Effect.gen(function* () { | |
| 463 | const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; | |
| 464 | const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); | |
| 465 | yield* Ref.update(state, (current) => ({ | |
| 466 | ...current, | |
| 467 | status: "healthy" as const, | |
| 468 | lastSampleAt: Option.some(sampledAt), | |
| 469 | lastError: Option.none(), | |
| 470 | })); | |
| 471 | yield* publishHealth; | |
| 472 | yield* PubSub.publish(snapshots, nativeSnapshot); | |
| 473 | if (event.requestId) { | |
| 474 | const deferred = yield* Ref.modify(pendingSamples, (pending) => { | |
| 475 | const next = new Map(pending); | |
| 476 | const current = next.get(event.requestId!); | |
| 477 | next.delete(event.requestId!); | |
| 478 | return [Option.fromUndefinedOr(current), next]; | |
| 479 | }); | |
| 480 | if (Option.isSome(deferred)) { | |
| 481 | yield* Deferred.succeed(deferred.value, nativeSnapshot); | |
| 482 | } | |
| 483 | } | |
| 484 | }); | |
| 485 | case "historyChunk": | |
| 486 | return Effect.gen(function* () { | |
| 487 | const latestSnapshot = event.snapshots.at(-1); | |
| 488 | yield* Ref.update(state, (current) => ({ | |
| 489 | ...current, | |
| 490 | status: "healthy" as const, | |
| 491 | lastSampleAt: latestSnapshot | |
| 492 | ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) | |
| 493 | : current.lastSampleAt, | |
| 494 | lastError: Option.none(), | |
| 495 | })); | |
| 496 | yield* publishHealth; | |
| 497 | const completed = yield* Ref.modify(pendingHistories, (pending) => { | |
| 498 | const request = pending.get(event.requestId); | |
| 499 | if (!request) return [Option.none(), pending] as const; | |
| 500 | const snapshots = [...request.snapshots, ...event.snapshots]; | |
| 501 | const next = new Map(pending); | |
| 502 | if (event.done) { | |
| 503 | next.delete(event.requestId); | |
| 504 | return [Option.some({ deferred: request.deferred, snapshots }), next] as const; | |
| 505 | } | |
| 506 | next.set(event.requestId, { deferred: request.deferred, snapshots }); | |
| 507 | return [Option.none(), next] as const; | |
| 508 | }); | |
| 509 | if (Option.isSome(completed)) { | |
| 510 | yield* Deferred.succeed(completed.value.deferred, completed.value.snapshots); | |
| 511 | } | |
| 512 | }); | |
| 513 | case "error": | |
| 514 | return Ref.update(state, (current) => ({ | |
| 515 | ...current, | |
| 516 | status: "degraded" as const, | |
| 517 | lastError: Option.some(event.message), | |
| 518 | })).pipe( | |
| 519 | Effect.andThen(publishHealth), | |
| 520 | Effect.andThen( | |
| 521 | event.recoverable | |
| 522 | ? Effect.void | |
| 523 | : Effect.fail( | |
| 524 | new NativeTelemetryCommandFailed({ | |
| 525 | operation: event.code, | |
| 526 | cause: event.message, | |
| 527 | }), | |
| 528 | ), | |
| 529 | ), | |
| 530 | ); | |
| 531 | } | |
| 532 | }; | |
| 533 | ||
| 534 | const runAttempt: Effect.Effect<void, NativeTelemetryClientError> = Effect.scoped( | |
| 535 | Effect.gen(function* () { | |
| 536 | const executablePath = yield* binary.resolve; | |
| 537 | const command = ChildProcess.make(executablePath, [], { | |
| 538 | cwd: config.cwd, | |
| 539 | stdin: { | |
| 540 | stream: "pipe", | |
| 541 | endOnDone: false, | |
| 542 | }, | |
| 543 | stdout: "pipe", | |
| 544 | stderr: "pipe", | |
| 545 | killSignal: "SIGTERM", | |
| 546 | forceKillAfter: Duration.seconds(2), | |
| 547 | }); | |
| 548 | const handle = yield* Effect.acquireRelease( | |
| 549 | spawner | |
| 550 | .spawn(command) | |
| 551 | .pipe( | |
| 552 | Effect.mapError( | |
| 553 | (cause) => new NativeTelemetrySpawnFailed({ path: executablePath, cause }), | |
| 554 | ), | |
| 555 | ), | |
| 556 | (child) => child.kill().pipe(Effect.ignore), | |
| 557 | ); | |
| 558 | yield* Ref.update(state, (current) => ({ | |
| 559 | ...current, | |
| 560 | status: "starting" as const, | |
| 561 | handle: Option.some(handle), | |
| 562 | hello: Option.none(), | |
| 563 | })); | |
| 564 | yield* publishHealth; | |
| 565 | const generation = (yield* Ref.get(state)).restartCount; | |
| 566 | ||
| 567 | const helloDeferred = yield* Deferred.make<ResourceMonitorHelloEvent>(); | |
| 568 | const eventFiber = yield* handle.stdout.pipe( | |
| 569 | Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), | |
| 570 | Stream.mapEffect( | |
| 571 | ( | |
| 572 | value, | |
| 573 | ): Effect.Effect< | |
| 574 | ResourceMonitorEvent, | |
| 575 | NativeTelemetryProtocolMismatch | NativeTelemetryDecodeFailed | |
| 576 | > => { | |
| 577 | const version = eventVersion(value); | |
| 578 | if (version !== undefined && version !== RESOURCE_MONITOR_PROTOCOL_VERSION) { | |
| 579 | return Effect.fail( | |
| 580 | new NativeTelemetryProtocolMismatch({ | |
| 581 | expectedVersion: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 582 | receivedVersion: version, | |
| 583 | }), | |
| 584 | ); | |
| 585 | } | |
| 586 | return decodeMonitorEvent(value).pipe( | |
| 587 | Effect.mapError((cause) => new NativeTelemetryDecodeFailed({ cause })), | |
| 588 | ); | |
| 589 | }, | |
| 590 | ), | |
| 591 | Stream.runForEach((event) => processEvent(event, helloDeferred, generation)), | |
| 592 | Effect.mapError((cause) => | |
| 593 | isProtocolMismatch(cause) || isDecodeFailed(cause) || isCommandFailed(cause) | |
| 594 | ? cause | |
| 595 | : new NativeTelemetryDecodeFailed({ cause }), | |
| 596 | ), | |
| 597 | Effect.forkScoped, | |
| 598 | ); | |
| 599 | yield* handle.stderr.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped); | |
| 600 | ||
| 601 | const hello = yield* Deferred.await(helloDeferred).pipe( | |
| 602 | Effect.timeoutOption(HANDSHAKE_TIMEOUT), | |
| 603 | Effect.flatMap( | |
| 604 | Option.match({ | |
| 605 | onNone: () => | |
| 606 | Effect.fail( | |
| 607 | new NativeTelemetryHandshakeTimedOut({ | |
| 608 | timeoutMs: Duration.toMillis(HANDSHAKE_TIMEOUT), | |
| 609 | }), | |
| 610 | ), | |
| 611 | onSome: Effect.succeed, | |
| 612 | }), | |
| 613 | ), | |
| 614 | ); | |
| 615 | yield* synchronizeCollectionControlOnStart( | |
| 616 | controlMutex, | |
| 617 | collectionControl, | |
| 618 | appliedCollectionControl, | |
| 619 | (control) => | |
| 620 | Effect.gen(function* () { | |
| 621 | yield* writeCommand(handle, { | |
| 622 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 623 | type: "configure", | |
| 624 | rootPid: process.pid, | |
| 625 | sampleIntervalMs: control.sampleIntervalMs, | |
| 626 | externalProcesses: [...(yield* Ref.get(externalProcesses))], | |
| 627 | }); | |
| 628 | if (control.liveSubscriberCount > 0) { | |
| 629 | yield* writeCommand(handle, { | |
| 630 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 631 | type: "setStreaming", | |
| 632 | enabled: true, | |
| 633 | }); | |
| 634 | } | |
| 635 | }), | |
| 636 | Ref.update(state, (current) => ({ | |
| 637 | ...current, | |
| 638 | status: "healthy" as const, | |
| 639 | hello: Option.some(hello), | |
| 640 | })).pipe(Effect.andThen(publishHealth)), | |
| 641 | ); | |
| 642 | ||
| 643 | yield* writeCommand(handle, { | |
| 644 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 645 | type: "setExternalProcesses", | |
| 646 | processes: [...(yield* Ref.get(externalProcesses))], | |
| 647 | }); | |
| 648 | ||
| 649 | const exitEffect = handle.exitCode.pipe( | |
| 650 | Effect.mapError( | |
| 651 | (cause) => | |
| 652 | new NativeTelemetryCommandFailed({ | |
| 653 | operation: "waitForExit", | |
| 654 | cause, | |
| 655 | }), | |
| 656 | ), | |
| 657 | Effect.flatMap((exitCode) => | |
| 658 | Effect.fail(new NativeTelemetryExited({ exitCode: Number(exitCode) })), | |
| 659 | ), | |
| 660 | ); | |
| 661 | const decoderEffect = Fiber.join(eventFiber).pipe( | |
| 662 | Effect.andThen(Effect.fail(new NativeTelemetryStreamClosed())), | |
| 663 | ); | |
| 664 | return yield* Effect.raceFirst(exitEffect, decoderEffect); | |
| 665 | }), | |
| 666 | ).pipe( | |
| 667 | Effect.ensuring( | |
| 668 | Ref.update(state, (current) => ({ | |
| 669 | ...current, | |
| 670 | handle: Option.none(), | |
| 671 | })), | |
| 672 | ), | |
| 673 | ); | |
| 674 | ||
| 675 | yield* Effect.gen(function* () { | |
| 676 | let failures: ReadonlyArray<number> = []; | |
| 677 | let restartAttempt = 0; | |
| 678 | ||
| 679 | while (true) { | |
| 680 | const result = yield* Effect.result(runAttempt); | |
| 681 | if (Result.isSuccess(result)) { | |
| 682 | return; | |
| 683 | } | |
| 684 | ||
| 685 | const error = result.failure; | |
| 686 | const now = DateTime.toEpochMillis(yield* DateTime.now); | |
| 687 | const recentFailures = retainRecentNativeTelemetryFailures(failures, now); | |
| 688 | if (recentFailures.length === 0) { | |
| 689 | restartAttempt = 0; | |
| 690 | } | |
| 691 | failures = [...recentFailures, now]; | |
| 692 | const exhausted = failures.length >= MAX_FAILURES_PER_WINDOW; | |
| 693 | yield* Ref.update(state, (current) => ({ | |
| 694 | ...current, | |
| 695 | status: exhausted ? ("unavailable" as const) : ("degraded" as const), | |
| 696 | hello: Option.none(), | |
| 697 | lastError: Option.some(errorMessage(error)), | |
| 698 | restartCount: current.restartCount + 1, | |
| 699 | })); | |
| 700 | yield* publishHealth; | |
| 701 | yield* failPending(error); | |
| 702 | ||
| 703 | if (exhausted) { | |
| 704 | yield* Queue.take(retryQueue); | |
| 705 | failures = []; | |
| 706 | restartAttempt = 0; | |
| 707 | yield* Ref.update(state, (current) => ({ | |
| 708 | ...current, | |
| 709 | status: "starting" as const, | |
| 710 | hello: Option.none(), | |
| 711 | lastError: Option.none(), | |
| 712 | })); | |
| 713 | yield* publishHealth; | |
| 714 | continue; | |
| 715 | } | |
| 716 | ||
| 717 | const manuallyRetried = yield* Effect.raceFirst( | |
| 718 | Effect.sleep(restartDelay(restartAttempt)).pipe(Effect.as(false)), | |
| 719 | Queue.take(retryQueue).pipe(Effect.as(true)), | |
| 720 | ); | |
| 721 | restartAttempt = manuallyRetried ? 0 : restartAttempt + 1; | |
| 722 | } | |
| 723 | }).pipe( | |
| 724 | Effect.catchCause((cause) => | |
| 725 | Cause.hasInterruptsOnly(cause) | |
| 726 | ? Effect.void | |
| 727 | : Ref.update(state, (current) => ({ | |
| 728 | ...current, | |
| 729 | status: "unavailable" as const, | |
| 730 | hello: Option.none(), | |
| 731 | lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)), | |
| 732 | })).pipe( | |
| 733 | Effect.andThen(publishHealth), | |
| 734 | Effect.andThen( | |
| 735 | Effect.logWarning("Resource monitor supervisor failed", { | |
| 736 | cause: Cause.pretty(cause), | |
| 737 | }), | |
| 738 | ), | |
| 739 | ), | |
| 740 | ), | |
| 741 | Effect.forkScoped, | |
| 742 | ); | |
| 743 | ||
| 744 | const applyCollectionControl = Effect.fn( | |
| 745 | "resourceTelemetry.nativeTelemetryClient.applyCollectionControl", | |
| 746 | )(function* (previous: CollectionControl, next: CollectionControl) { | |
| 747 | const current = yield* Ref.get(state); | |
| 748 | if (canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { | |
| 749 | const handle = Option.getOrThrow(current.handle); | |
| 750 | if (previous.sampleIntervalMs !== next.sampleIntervalMs) { | |
| 751 | yield* writeCommand(handle, { | |
| 752 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 753 | type: "setSampleInterval", | |
| 754 | sampleIntervalMs: next.sampleIntervalMs, | |
| 755 | }); | |
| 756 | } | |
| 757 | const wasStreaming = previous.liveSubscriberCount > 0; | |
| 758 | const isStreaming = next.liveSubscriberCount > 0; | |
| 759 | if (wasStreaming !== isStreaming) { | |
| 760 | yield* writeCommand(handle, { | |
| 761 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 762 | type: "setStreaming", | |
| 763 | enabled: isStreaming, | |
| 764 | }); | |
| 765 | } | |
| 766 | } | |
| 767 | }); | |
| 768 | ||
| 769 | const updateCollectionControl = (update: (current: CollectionControl) => CollectionControl) => | |
| 770 | controlMutex.withPermits(1)( | |
| 771 | commitCollectionControlUpdate( | |
| 772 | collectionControl, | |
| 773 | appliedCollectionControl, | |
| 774 | update, | |
| 775 | applyCollectionControl, | |
| 776 | ).pipe(Effect.ensuring(publishHealth), Effect.asVoid), | |
| 777 | ); | |
| 778 | ||
| 779 | const setHostPowerState: NativeTelemetryClient["Service"]["setHostPowerState"] = (hostPower) => | |
| 780 | updateCollectionControl((current) => ({ | |
| 781 | ...current, | |
| 782 | hostPower, | |
| 783 | sampleIntervalMs: resolveNativeSampleIntervalMs(hostPower, current.liveSubscriberCount), | |
| 784 | })); | |
| 785 | ||
| 786 | const changeLiveSubscriberCount = Effect.fn( | |
| 787 | "resourceTelemetry.nativeTelemetryClient.changeLiveSubscriberCount", | |
| 788 | )(function* (delta: 1 | -1) { | |
| 789 | yield* updateCollectionControl((current) => { | |
| 790 | const liveSubscriberCount = Math.max(0, current.liveSubscriberCount + delta); | |
| 791 | return { | |
| 792 | ...current, | |
| 793 | liveSubscriberCount, | |
| 794 | sampleIntervalMs: resolveNativeSampleIntervalMs(current.hostPower, liveSubscriberCount), | |
| 795 | }; | |
| 796 | }); | |
| 797 | }); | |
| 798 | ||
| 799 | const liveSnapshots = Stream.unwrap( | |
| 800 | Effect.gen(function* () { | |
| 801 | const subscription = yield* PubSub.subscribe(snapshots); | |
| 802 | yield* Effect.acquireRelease(changeLiveSubscriberCount(1), () => | |
| 803 | changeLiveSubscriberCount(-1).pipe(Effect.ignore), | |
| 804 | ); | |
| 805 | return Stream.fromSubscription(subscription); | |
| 806 | }), | |
| 807 | ); | |
| 808 | ||
| 809 | const setExternalProcesses: NativeTelemetryClient["Service"]["setExternalProcesses"] = ( | |
| 810 | processes, | |
| 811 | ) => | |
| 812 | Effect.gen(function* () { | |
| 813 | yield* Ref.set(externalProcesses, [...processes]); | |
| 814 | const current = yield* Ref.get(state); | |
| 815 | if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) return; | |
| 816 | yield* writeCommand(Option.getOrThrow(current.handle), { | |
| 817 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 818 | type: "setExternalProcesses", | |
| 819 | processes: [...processes], | |
| 820 | }); | |
| 821 | }); | |
| 822 | ||
| 823 | const readHistory: NativeTelemetryClient["Service"]["readHistory"] = (windowMs) => | |
| 824 | Effect.gen(function* () { | |
| 825 | const current = yield* Ref.get(state); | |
| 826 | if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { | |
| 827 | return yield* new NativeTelemetryUnavailable({ | |
| 828 | reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), | |
| 829 | }); | |
| 830 | } | |
| 831 | const requestId = yield* crypto.randomUUIDv4.pipe( | |
| 832 | Effect.mapError( | |
| 833 | (cause) => | |
| 834 | new NativeTelemetryCommandFailed({ | |
| 835 | operation: "createHistoryRequestId", | |
| 836 | cause, | |
| 837 | }), | |
| 838 | ), | |
| 839 | ); | |
| 840 | const deferred = yield* Deferred.make< | |
| 841 | ReadonlyArray<ResourceMonitorSnapshotEvent>, | |
| 842 | NativeTelemetryClientError | |
| 843 | >(); | |
| 844 | yield* Ref.update(pendingHistories, (pending) => { | |
| 845 | const next = new Map(pending); | |
| 846 | next.set(requestId, { deferred, snapshots: [] }); | |
| 847 | return next; | |
| 848 | }); | |
| 849 | return yield* writeCommand(Option.getOrThrow(current.handle), { | |
| 850 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 851 | type: "readHistory", | |
| 852 | requestId, | |
| 853 | windowMs: Math.max(0, Math.round(windowMs)), | |
| 854 | }).pipe( | |
| 855 | Effect.andThen( | |
| 856 | Deferred.await(deferred).pipe( | |
| 857 | Effect.timeoutOption(HISTORY_REQUEST_TIMEOUT), | |
| 858 | Effect.flatMap( | |
| 859 | Option.match({ | |
| 860 | onNone: () => | |
| 861 | Effect.fail( | |
| 862 | new NativeTelemetryRequestTimedOut({ | |
| 863 | operation: "readHistory", | |
| 864 | timeoutMs: Duration.toMillis(HISTORY_REQUEST_TIMEOUT), | |
| 865 | }), | |
| 866 | ), | |
| 867 | onSome: Effect.succeed, | |
| 868 | }), | |
| 869 | ), | |
| 870 | ), | |
| 871 | ), | |
| 872 | Effect.ensuring( | |
| 873 | Ref.update(pendingHistories, (pending) => { | |
| 874 | const next = new Map(pending); | |
| 875 | next.delete(requestId); | |
| 876 | return next; | |
| 877 | }), | |
| 878 | ), | |
| 879 | ); | |
| 880 | }); | |
| 881 | ||
| 882 | const sampleNow: NativeTelemetryClient["Service"]["sampleNow"] = Effect.gen(function* () { | |
| 883 | const current = yield* Ref.get(state); | |
| 884 | if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { | |
| 885 | return yield* new NativeTelemetryUnavailable({ | |
| 886 | reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), | |
| 887 | }); | |
| 888 | } | |
| 889 | ||
| 890 | const requestId = yield* crypto.randomUUIDv4.pipe( | |
| 891 | Effect.mapError( | |
| 892 | (cause) => | |
| 893 | new NativeTelemetryCommandFailed({ | |
| 894 | operation: "createRequestId", | |
| 895 | cause, | |
| 896 | }), | |
| 897 | ), | |
| 898 | ); | |
| 899 | const deferred = yield* Deferred.make<NativeTelemetrySnapshot, NativeTelemetryClientError>(); | |
| 900 | yield* Ref.update(pendingSamples, (pending) => { | |
| 901 | const next = new Map(pending); | |
| 902 | next.set(requestId, deferred); | |
| 903 | return next; | |
| 904 | }); | |
| 905 | return yield* writeCommand(Option.getOrThrow(current.handle), { | |
| 906 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | |
| 907 | type: "sampleNow", | |
| 908 | requestId, | |
| 909 | }).pipe( | |
| 910 | Effect.andThen( | |
| 911 | Deferred.await(deferred).pipe( | |
| 912 | Effect.timeoutOption(SAMPLE_REQUEST_TIMEOUT), | |
| 913 | Effect.flatMap( | |
| 914 | Option.match({ | |
| 915 | onNone: () => | |
| 916 | Effect.fail( | |
| 917 | new NativeTelemetryRequestTimedOut({ | |
| 918 | operation: "sampleNow", | |
| 919 | timeoutMs: Duration.toMillis(SAMPLE_REQUEST_TIMEOUT), | |
| 920 | }), | |
| 921 | ), | |
| 922 | onSome: Effect.succeed, | |
| 923 | }), | |
| 924 | ), | |
| 925 | ), | |
| 926 | ), | |
| 927 | Effect.ensuring( | |
| 928 | Ref.update(pendingSamples, (pending) => { | |
| 929 | const next = new Map(pending); | |
| 930 | next.delete(requestId); | |
| 931 | return next; | |
| 932 | }), | |
| 933 | ), | |
| 934 | ); | |
| 935 | }); | |
| 936 | ||
| 937 | const health = currentHealth; | |
| 938 | ||
| 939 | return NativeTelemetryClient.of({ | |
| 940 | capabilities: Ref.get(state).pipe( | |
| 941 | Effect.flatMap((current) => | |
| 942 | Option.match(current.hello, { | |
| 943 | onNone: () => | |
| 944 | Effect.fail( | |
| 945 | new NativeTelemetryUnavailable({ | |
| 946 | reason: Option.getOrElse(current.lastError, () => "handshake is incomplete"), | |
| 947 | }), | |
| 948 | ), | |
| 949 | onSome: (hello) => Effect.succeed(hello.capabilities), | |
| 950 | }), | |
| 951 | ), | |
| 952 | ), | |
| 953 | snapshots: liveSnapshots, | |
| 954 | readHistory, | |
| 955 | setExternalProcesses, | |
| 956 | setHostPowerState, | |
| 957 | sampleNow, | |
| 958 | retry: Ref.get(state).pipe( | |
| 959 | Effect.flatMap((current) => | |
| 960 | !canRequestNativeTelemetryRetry(current.status, Option.isSome(current.handle)) | |
| 961 | ? Effect.succeed(false) | |
| 962 | : Queue.offer(retryQueue, undefined).pipe(Effect.as(true)), | |
| 963 | ), | |
| 964 | ), | |
| 965 | health, | |
| 966 | subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, health), | |
| 967 | }); | |
| 968 | }); | |
| 969 | ||
| 970 | export const layer = Layer.effect(NativeTelemetryClient, make()); | |
| 971 | ||
| 972 | export const layerTest = ( | |
| 973 | overrides: Partial<NativeTelemetryClient["Service"]> = {}, | |
| 974 | ): Layer.Layer<NativeTelemetryClient> => { | |
| 975 | const health = | |
| 976 | overrides.health ?? | |
| 977 | Effect.succeed({ | |
| 978 | status: "unavailable" as const, | |
| 979 | hello: Option.none<ResourceMonitorHelloEvent>(), | |
| 980 | lastSampleAt: Option.none<DateTime.Utc>(), | |
| 981 | lastError: Option.some("Resource monitor test implementation is unavailable."), | |
| 982 | restartCount: 0, | |
| 983 | sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS, | |
| 984 | }); | |
| 985 | return Layer.succeed( | |
| 986 | NativeTelemetryClient, | |
| 987 | NativeTelemetryClient.of({ | |
| 988 | capabilities: Effect.succeed({ | |
| 989 | cumulativeCpuTime: true, | |
| 990 | currentCpuPercent: true, | |
| 991 | residentMemory: true, | |
| 992 | virtualMemory: true, | |
| 993 | ioBytes: true, | |
| 994 | processStartTime: true, | |
| 995 | processTree: true, | |
| 996 | }), | |
| 997 | snapshots: Stream.empty, | |
| 998 | readHistory: () => | |
| 999 | Effect.fail( | |
| 1000 | new NativeTelemetryUnavailable({ | |
| 1001 | reason: "No resource monitor history was configured for this test.", | |
| 1002 | }), | |
| 1003 | ), | |
| 1004 | setExternalProcesses: () => Effect.void, | |
| 1005 | setHostPowerState: () => Effect.void, | |
| 1006 | sampleNow: Effect.fail( | |
| 1007 | new NativeTelemetryUnavailable({ | |
| 1008 | reason: "No resource monitor sample was configured for this test.", | |
| 1009 | }), | |
| 1010 | ), | |
| 1011 | retry: Effect.succeed(false), | |
| 1012 | health, | |
| 1013 | subscribeHealth: | |
| 1014 | overrides.subscribeHealth ?? | |
| 1015 | health.pipe( | |
| 1016 | Effect.map((initial) => ({ | |
| 1017 | latest: initial, | |
| 1018 | changes: Stream.empty, | |
| 1019 | })), | |
| 1020 | ), | |
| 1021 | ...overrides, | |
| 1022 | }), | |
| 1023 | ); | |
| 1024 | }; |
apps/server/src/resourceTelemetry/ResourceAttribution.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import type { ResourceAttributionEntry, ResourceAttributionSnapshot } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Ref from "effect/Ref"; | |
| 7 | ||
| 8 | export interface ResourceAttributionRecord { | |
| 9 | readonly component: string; | |
| 10 | readonly operation: string; | |
| 11 | readonly logicalReadBytes?: number; | |
| 12 | readonly logicalWriteBytes?: number; | |
| 13 | readonly count?: number; | |
| 14 | readonly durationMs?: number; | |
| 15 | } | |
| 16 | ||
| 17 | export class ResourceAttribution extends Context.Service< | |
| 18 | ResourceAttribution, | |
| 19 | { | |
| 20 | readonly record: (input: ResourceAttributionRecord) => Effect.Effect<void>; | |
| 21 | readonly snapshot: Effect.Effect<ResourceAttributionSnapshot>; | |
| 22 | } | |
| 23 | >()("t3/resourceTelemetry/ResourceAttribution") {} | |
| 24 | ||
| 25 | function key(input: Pick<ResourceAttributionRecord, "component" | "operation">): string { | |
| 26 | return `${input.component}\u0000${input.operation}`; | |
| 27 | } | |
| 28 | ||
| 29 | function nonNegativeInteger(value: number | undefined, fallback: number): number { | |
| 30 | if (value === undefined) return fallback; | |
| 31 | if (!Number.isFinite(value)) return 0; | |
| 32 | return Math.max(0, Math.round(value)); | |
| 33 | } | |
| 34 | ||
| 35 | export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () { | |
| 36 | const entries = yield* Ref.make(new Map<string, ResourceAttributionEntry>()); | |
| 37 | ||
| 38 | const record: ResourceAttribution["Service"]["record"] = (input) => | |
| 39 | Ref.update(entries, (current) => { | |
| 40 | const next = new Map(current); | |
| 41 | const entryKey = key(input); | |
| 42 | const existing = next.get(entryKey); | |
| 43 | next.set(entryKey, { | |
| 44 | component: input.component, | |
| 45 | operation: input.operation, | |
| 46 | logicalReadBytes: | |
| 47 | (existing?.logicalReadBytes ?? 0) + nonNegativeInteger(input.logicalReadBytes, 0), | |
| 48 | logicalWriteBytes: | |
| 49 | (existing?.logicalWriteBytes ?? 0) + nonNegativeInteger(input.logicalWriteBytes, 0), | |
| 50 | count: (existing?.count ?? 0) + nonNegativeInteger(input.count, 1), | |
| 51 | durationMs: (existing?.durationMs ?? 0) + nonNegativeInteger(input.durationMs, 0), | |
| 52 | }); | |
| 53 | return next; | |
| 54 | }); | |
| 55 | ||
| 56 | return ResourceAttribution.of({ | |
| 57 | record, | |
| 58 | snapshot: Effect.gen(function* () { | |
| 59 | const readAt = yield* DateTime.now; | |
| 60 | const current = yield* Ref.get(entries); | |
| 61 | return { | |
| 62 | readAt, | |
| 63 | entries: [...current.values()].toSorted( | |
| 64 | (left, right) => right.logicalWriteBytes - left.logicalWriteBytes, | |
| 65 | ), | |
| 66 | }; | |
| 67 | }), | |
| 68 | }); | |
| 69 | }); | |
| 70 | ||
| 71 | export const layer = Layer.effect(ResourceAttribution, make()); |
apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts created+124| ... | ... | @@ -0,0 +1,124 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { | |
| 3 | HostProcessArchitecture, | |
| 4 | HostProcessEnvironment, | |
| 5 | HostProcessPlatform, | |
| 6 | } from "@t3tools/shared/hostProcess"; | |
| 7 | import { assert, describe, it } from "@effect/vitest"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | ||
| 11 | import { ServerConfig } from "../config.ts"; | |
| 12 | import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; | |
| 13 | ||
| 14 | describe("ResourceMonitorBinary", () => { | |
| 15 | it.effect("resolves an executable override", () => | |
| 16 | Effect.gen(function* () { | |
| 17 | const fileSystem = yield* FileSystem.FileSystem; | |
| 18 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 19 | prefix: "t3-resource-monitor-binary-", | |
| 20 | }); | |
| 21 | const binaryPath = `${baseDir}/t3-resource-monitor`; | |
| 22 | yield* fileSystem.writeFileString(binaryPath, "binary"); | |
| 23 | yield* fileSystem.chmod(binaryPath, 0o755); | |
| 24 | ||
| 25 | const service = yield* ResourceMonitorBinary.make().pipe( | |
| 26 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | |
| 27 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 28 | Effect.provideService(HostProcessArchitecture, "x64"), | |
| 29 | Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), | |
| 30 | Effect.provideService(HostProcessEnvironment, { | |
| 31 | T3CODE_RESOURCE_MONITOR_PATH: binaryPath, | |
| 32 | }), | |
| 33 | ); | |
| 34 | ||
| 35 | assert.equal(yield* service.resolve, binaryPath); | |
| 36 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 37 | ); | |
| 38 | ||
| 39 | it.effect("resolves an executable override on an unsupported platform", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const fileSystem = yield* FileSystem.FileSystem; | |
| 42 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 43 | prefix: "t3-resource-monitor-binary-", | |
| 44 | }); | |
| 45 | const binaryPath = `${baseDir}/custom-resource-monitor`; | |
| 46 | yield* fileSystem.writeFileString(binaryPath, "binary"); | |
| 47 | yield* fileSystem.chmod(binaryPath, 0o755); | |
| 48 | ||
| 49 | const service = yield* ResourceMonitorBinary.make().pipe( | |
| 50 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | |
| 51 | Effect.provideService(HostProcessPlatform, "freebsd"), | |
| 52 | Effect.provideService(HostProcessArchitecture, "ia32"), | |
| 53 | Effect.provideService(HostProcessEnvironment, { | |
| 54 | T3CODE_RESOURCE_MONITOR_PATH: binaryPath, | |
| 55 | }), | |
| 56 | ); | |
| 57 | ||
| 58 | assert.equal(yield* service.resolve, binaryPath); | |
| 59 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 60 | ); | |
| 61 | ||
| 62 | it.effect("rejects a non-executable POSIX override", () => | |
| 63 | Effect.gen(function* () { | |
| 64 | const fileSystem = yield* FileSystem.FileSystem; | |
| 65 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 66 | prefix: "t3-resource-monitor-binary-", | |
| 67 | }); | |
| 68 | const binaryPath = `${baseDir}/t3-resource-monitor`; | |
| 69 | yield* fileSystem.writeFileString(binaryPath, "binary"); | |
| 70 | yield* fileSystem.chmod(binaryPath, 0o644); | |
| 71 | ||
| 72 | const service = yield* ResourceMonitorBinary.make().pipe( | |
| 73 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | |
| 74 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 75 | Effect.provideService(HostProcessArchitecture, "x64"), | |
| 76 | Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "gnu"), | |
| 77 | Effect.provideService(HostProcessEnvironment, { | |
| 78 | T3CODE_RESOURCE_MONITOR_PATH: binaryPath, | |
| 79 | }), | |
| 80 | ); | |
| 81 | const error = yield* Effect.flip(service.resolve); | |
| 82 | ||
| 83 | assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryNotExecutable); | |
| 84 | assert.equal(error.path, binaryPath); | |
| 85 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 86 | ); | |
| 87 | ||
| 88 | it.effect("rejects unsupported platform and architecture pairs", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const fileSystem = yield* FileSystem.FileSystem; | |
| 91 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 92 | prefix: "t3-resource-monitor-binary-", | |
| 93 | }); | |
| 94 | const service = yield* ResourceMonitorBinary.make().pipe( | |
| 95 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | |
| 96 | Effect.provideService(HostProcessPlatform, "freebsd"), | |
| 97 | Effect.provideService(HostProcessArchitecture, "ia32"), | |
| 98 | Effect.provideService(HostProcessEnvironment, {}), | |
| 99 | ); | |
| 100 | const error = yield* Effect.flip(service.resolve); | |
| 101 | ||
| 102 | assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); | |
| 103 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 104 | ); | |
| 105 | ||
| 106 | it.effect("rejects bundled glibc binaries on musl Linux hosts", () => | |
| 107 | Effect.gen(function* () { | |
| 108 | const fileSystem = yield* FileSystem.FileSystem; | |
| 109 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 110 | prefix: "t3-resource-monitor-binary-", | |
| 111 | }); | |
| 112 | const service = yield* ResourceMonitorBinary.make().pipe( | |
| 113 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | |
| 114 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 115 | Effect.provideService(HostProcessArchitecture, "x64"), | |
| 116 | Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), | |
| 117 | Effect.provideService(HostProcessEnvironment, {}), | |
| 118 | ); | |
| 119 | const error = yield* Effect.flip(service.resolve); | |
| 120 | ||
| 121 | assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); | |
| 122 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 123 | ); | |
| 124 | }); |
apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | import { | |
| 2 | HostProcessArchitecture, | |
| 3 | HostProcessEnvironment, | |
| 4 | HostProcessPlatform, | |
| 5 | } from "@t3tools/shared/hostProcess"; | |
| 6 | import * as Context from "effect/Context"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Path from "effect/Path"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | ||
| 14 | import { ServerConfig } from "../config.ts"; | |
| 15 | ||
| 16 | export class ResourceMonitorBinaryUnsupported extends Schema.TaggedErrorClass<ResourceMonitorBinaryUnsupported>()( | |
| 17 | "ResourceMonitorBinaryUnsupported", | |
| 18 | { | |
| 19 | platform: Schema.String, | |
| 20 | architecture: Schema.String, | |
| 21 | }, | |
| 22 | ) { | |
| 23 | override get message(): string { | |
| 24 | return `Resource monitoring is unsupported on ${this.platform}/${this.architecture}.`; | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | export class ResourceMonitorBinaryNotFound extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotFound>()( | |
| 29 | "ResourceMonitorBinaryNotFound", | |
| 30 | { | |
| 31 | platform: Schema.String, | |
| 32 | architecture: Schema.String, | |
| 33 | candidates: Schema.Array(Schema.String), | |
| 34 | }, | |
| 35 | ) { | |
| 36 | override get message(): string { | |
| 37 | return `Resource monitor binary was not found for ${this.platform}/${this.architecture}.`; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | export class ResourceMonitorBinaryNotExecutable extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotExecutable>()( | |
| 42 | "ResourceMonitorBinaryNotExecutable", | |
| 43 | { | |
| 44 | path: Schema.String, | |
| 45 | mode: Schema.Number, | |
| 46 | }, | |
| 47 | ) { | |
| 48 | override get message(): string { | |
| 49 | return `Resource monitor binary at '${this.path}' is not executable.`; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | export type ResourceMonitorBinaryError = | |
| 54 | | ResourceMonitorBinaryUnsupported | |
| 55 | | ResourceMonitorBinaryNotFound | |
| 56 | | ResourceMonitorBinaryNotExecutable; | |
| 57 | ||
| 58 | export class ResourceMonitorBinary extends Context.Service< | |
| 59 | ResourceMonitorBinary, | |
| 60 | { | |
| 61 | readonly resolve: Effect.Effect<string, ResourceMonitorBinaryError>; | |
| 62 | } | |
| 63 | >()("t3/resourceTelemetry/ResourceMonitorBinary") {} | |
| 64 | ||
| 65 | function binaryName(platform: NodeJS.Platform): string { | |
| 66 | return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; | |
| 67 | } | |
| 68 | ||
| 69 | export type ResourceMonitorLinuxLibc = "gnu" | "musl"; | |
| 70 | ||
| 71 | function detectResourceMonitorLinuxLibc(): ResourceMonitorLinuxLibc { | |
| 72 | try { | |
| 73 | const report = process.report?.getReport() as | |
| 74 | | { | |
| 75 | readonly header?: { | |
| 76 | readonly glibcVersionRuntime?: unknown; | |
| 77 | }; | |
| 78 | } | |
| 79 | | undefined; | |
| 80 | return typeof report?.header?.glibcVersionRuntime === "string" ? "gnu" : "musl"; | |
| 81 | } catch { | |
| 82 | return "musl"; | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | export const ResourceMonitorHostLinuxLibc = Context.Reference<ResourceMonitorLinuxLibc>( | |
| 87 | "t3/resourceTelemetry/ResourceMonitorHostLinuxLibc", | |
| 88 | { | |
| 89 | defaultValue: detectResourceMonitorLinuxLibc, | |
| 90 | }, | |
| 91 | ); | |
| 92 | ||
| 93 | export function resourceMonitorPlatformKey( | |
| 94 | platform: NodeJS.Platform, | |
| 95 | architecture: NodeJS.Architecture, | |
| 96 | ): string | undefined { | |
| 97 | if ( | |
| 98 | (platform !== "darwin" && platform !== "linux" && platform !== "win32") || | |
| 99 | (architecture !== "arm64" && architecture !== "x64") | |
| 100 | ) { | |
| 101 | return undefined; | |
| 102 | } | |
| 103 | return `${platform}-${architecture}`; | |
| 104 | } | |
| 105 | ||
| 106 | export function resourceMonitorRustTarget( | |
| 107 | platform: NodeJS.Platform, | |
| 108 | architecture: NodeJS.Architecture, | |
| 109 | linuxLibc: ResourceMonitorLinuxLibc, | |
| 110 | ): string | undefined { | |
| 111 | if (platform === "darwin") { | |
| 112 | return architecture === "arm64" | |
| 113 | ? "aarch64-apple-darwin" | |
| 114 | : architecture === "x64" | |
| 115 | ? "x86_64-apple-darwin" | |
| 116 | : undefined; | |
| 117 | } | |
| 118 | if (platform === "linux") { | |
| 119 | if (linuxLibc !== "gnu") { | |
| 120 | return undefined; | |
| 121 | } | |
| 122 | return architecture === "arm64" | |
| 123 | ? "aarch64-unknown-linux-gnu" | |
| 124 | : architecture === "x64" | |
| 125 | ? "x86_64-unknown-linux-gnu" | |
| 126 | : undefined; | |
| 127 | } | |
| 128 | if (platform === "win32") { | |
| 129 | return architecture === "arm64" | |
| 130 | ? "aarch64-pc-windows-msvc" | |
| 131 | : architecture === "x64" | |
| 132 | ? "x86_64-pc-windows-msvc" | |
| 133 | : undefined; | |
| 134 | } | |
| 135 | return undefined; | |
| 136 | } | |
| 137 | ||
| 138 | export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(function* () { | |
| 139 | const config = yield* ServerConfig; | |
| 140 | const fileSystem = yield* FileSystem.FileSystem; | |
| 141 | const path = yield* Path.Path; | |
| 142 | const platform = yield* HostProcessPlatform; | |
| 143 | const architecture = yield* HostProcessArchitecture; | |
| 144 | const environment = yield* HostProcessEnvironment; | |
| 145 | const linuxLibc = yield* ResourceMonitorHostLinuxLibc; | |
| 146 | const executableName = binaryName(platform); | |
| 147 | const platformKey = resourceMonitorPlatformKey(platform, architecture); | |
| 148 | const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc); | |
| 149 | const overrideCandidates = [ | |
| 150 | environment.T3CODE_RESOURCE_MONITOR_PATH, | |
| 151 | config.resourceMonitorPath, | |
| 152 | ].filter((candidate): candidate is string => Boolean(candidate)); | |
| 153 | const bundledCandidates = | |
| 154 | platformKey === undefined || rustTarget === undefined | |
| 155 | ? [] | |
| 156 | : [ | |
| 157 | path.resolve(import.meta.dirname, "resource-monitor", platformKey, executableName), | |
| 158 | path.resolve(import.meta.dirname, "resource-monitor", executableName), | |
| 159 | path.resolve(import.meta.dirname, "../resource-monitor", executableName), | |
| 160 | path.resolve( | |
| 161 | import.meta.dirname, | |
| 162 | "../../../../native/resource-monitor/target", | |
| 163 | rustTarget, | |
| 164 | "release", | |
| 165 | executableName, | |
| 166 | ), | |
| 167 | path.resolve( | |
| 168 | import.meta.dirname, | |
| 169 | "../../../native/resource-monitor/target", | |
| 170 | rustTarget, | |
| 171 | "release", | |
| 172 | executableName, | |
| 173 | ), | |
| 174 | path.resolve( | |
| 175 | import.meta.dirname, | |
| 176 | "../../../../native/resource-monitor/target/release", | |
| 177 | executableName, | |
| 178 | ), | |
| 179 | path.resolve( | |
| 180 | import.meta.dirname, | |
| 181 | "../../../../native/resource-monitor/target/debug", | |
| 182 | executableName, | |
| 183 | ), | |
| 184 | ]; | |
| 185 | if (overrideCandidates.length === 0 && bundledCandidates.length === 0) { | |
| 186 | return ResourceMonitorBinary.of({ | |
| 187 | resolve: Effect.fail( | |
| 188 | new ResourceMonitorBinaryUnsupported({ | |
| 189 | platform, | |
| 190 | architecture, | |
| 191 | }), | |
| 192 | ), | |
| 193 | }); | |
| 194 | } | |
| 195 | ||
| 196 | const candidates = [...overrideCandidates, ...bundledCandidates]; | |
| 197 | ||
| 198 | const resolve: ResourceMonitorBinary["Service"]["resolve"] = Effect.gen(function* () { | |
| 199 | for (const candidate of candidates) { | |
| 200 | const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); | |
| 201 | if (!exists) continue; | |
| 202 | ||
| 203 | if (platform !== "win32") { | |
| 204 | const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); | |
| 205 | if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) { | |
| 206 | return yield* new ResourceMonitorBinaryNotExecutable({ | |
| 207 | path: candidate, | |
| 208 | mode: stat.value.mode, | |
| 209 | }); | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | return candidate; | |
| 214 | } | |
| 215 | ||
| 216 | return yield* new ResourceMonitorBinaryNotFound({ | |
| 217 | platform, | |
| 218 | architecture, | |
| 219 | candidates, | |
| 220 | }); | |
| 221 | }); | |
| 222 | ||
| 223 | return ResourceMonitorBinary.of({ resolve }); | |
| 224 | }); | |
| 225 | ||
| 226 | export const layer = Layer.effect(ResourceMonitorBinary, make()); |
apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts created+647| ... | ... | @@ -0,0 +1,647 @@ |
| 1 | import type { | |
| 2 | DesktopHostTelemetrySnapshot, | |
| 3 | ResourceMonitorProcessSample, | |
| 4 | ResourceMonitorSnapshotEvent, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { describe, expect, it } from "@effect/vitest"; | |
| 7 | import * as DateTime from "effect/DateTime"; | |
| 8 | import * as Deferred from "effect/Deferred"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as PubSub from "effect/PubSub"; | |
| 15 | import * as Ref from "effect/Ref"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | import * as TestClock from "effect/testing/TestClock"; | |
| 18 | ||
| 19 | import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; | |
| 20 | import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; | |
| 21 | import * as ResourceAttribution from "./ResourceAttribution.ts"; | |
| 22 | import * as ResourceTelemetry from "./ResourceTelemetry.ts"; | |
| 23 | ||
| 24 | function processSample( | |
| 25 | input: Partial<ResourceMonitorProcessSample> & | |
| 26 | Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">, | |
| 27 | ): ResourceMonitorProcessSample { | |
| 28 | return { | |
| 29 | runTimeMs: 1_000, | |
| 30 | name: `process-${input.pid}`, | |
| 31 | command: `process-${input.pid}`, | |
| 32 | status: "Running", | |
| 33 | cpuPercent: 0, | |
| 34 | cpuTimeMs: 0, | |
| 35 | residentBytes: 1_024, | |
| 36 | virtualBytes: 2_048, | |
| 37 | ioReadBytes: 0, | |
| 38 | ioWriteBytes: 0, | |
| 39 | ioSemantics: "storage", | |
| 40 | ...input, | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | function nativeSnapshot(input: { | |
| 45 | readonly sequence: number; | |
| 46 | readonly sampledAtUnixMs: number; | |
| 47 | readonly childCpuTimeMs: number; | |
| 48 | readonly childWriteBytes: number; | |
| 49 | readonly externalProcesses?: ResourceMonitorSnapshotEvent["externalProcesses"]; | |
| 50 | }): ResourceMonitorSnapshotEvent { | |
| 51 | const processes = [ | |
| 52 | processSample({ | |
| 53 | pid: process.pid, | |
| 54 | ppid: 1, | |
| 55 | startTimeMs: 100, | |
| 56 | cpuTimeMs: input.sequence * 10, | |
| 57 | }), | |
| 58 | processSample({ | |
| 59 | pid: 4_242, | |
| 60 | ppid: process.pid, | |
| 61 | startTimeMs: 200, | |
| 62 | name: "codex", | |
| 63 | command: "codex app-server", | |
| 64 | cpuTimeMs: input.childCpuTimeMs, | |
| 65 | ioWriteBytes: input.childWriteBytes, | |
| 66 | }), | |
| 67 | processSample({ | |
| 68 | pid: 5_000, | |
| 69 | ppid: 1, | |
| 70 | startTimeMs: 300, | |
| 71 | name: "electron", | |
| 72 | command: "electron", | |
| 73 | cpuTimeMs: input.sequence * 20, | |
| 74 | }), | |
| 75 | processSample({ | |
| 76 | pid: 9_000, | |
| 77 | ppid: process.pid, | |
| 78 | startTimeMs: 400, | |
| 79 | name: "t3-resource-monitor", | |
| 80 | command: "t3-resource-monitor", | |
| 81 | cpuTimeMs: input.sequence * 5, | |
| 82 | }), | |
| 83 | ]; | |
| 84 | return { | |
| 85 | version: 2, | |
| 86 | type: "snapshot", | |
| 87 | sequence: input.sequence, | |
| 88 | sampledAtUnixMs: input.sampledAtUnixMs, | |
| 89 | collectionDurationMicros: 300, | |
| 90 | scannedProcessCount: 80, | |
| 91 | retainedProcessCount: processes.length, | |
| 92 | inaccessibleProcessCount: 1, | |
| 93 | ...(input.externalProcesses === undefined | |
| 94 | ? {} | |
| 95 | : { externalProcesses: input.externalProcesses }), | |
| 96 | processes, | |
| 97 | }; | |
| 98 | } | |
| 99 | ||
| 100 | function nativeGeneration( | |
| 101 | snapshot: ResourceMonitorSnapshotEvent, | |
| 102 | generation: number, | |
| 103 | ): NativeTelemetryClient.NativeTelemetrySnapshot { | |
| 104 | return { generation, snapshot }; | |
| 105 | } | |
| 106 | ||
| 107 | function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot { | |
| 108 | const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); | |
| 109 | return { | |
| 110 | version: 1, | |
| 111 | type: "desktopTelemetry", | |
| 112 | sequence: 1, | |
| 113 | sampledAtUnixMs, | |
| 114 | electronPid: 5_000, | |
| 115 | power: { | |
| 116 | source: "electron-main", | |
| 117 | idle: "false", | |
| 118 | idleSeconds: 2, | |
| 119 | locked: "false", | |
| 120 | suspended: false, | |
| 121 | onBattery: "true", | |
| 122 | lowPowerMode: "unknown", | |
| 123 | thermalState: "fair", | |
| 124 | stale: false, | |
| 125 | updatedAt: sampledAt, | |
| 126 | }, | |
| 127 | speedLimitPercent: Option.some(90), | |
| 128 | electronProcesses: [ | |
| 129 | { | |
| 130 | pid: 5_000, | |
| 131 | creationTimeMs: 300, | |
| 132 | type: "Browser", | |
| 133 | name: "electron", | |
| 134 | cpuPercent: 2, | |
| 135 | cumulativeCpuSeconds: 0.02, | |
| 136 | idleWakeupsPerSecond: 3, | |
| 137 | workingSetBytes: 4_096, | |
| 138 | peakWorkingSetBytes: 8_192, | |
| 139 | }, | |
| 140 | ], | |
| 141 | }; | |
| 142 | } | |
| 143 | ||
| 144 | describe("ResourceTelemetry", () => { | |
| 145 | it.effect("enables live native and Electron collection only while changes are retained", () => | |
| 146 | Effect.gen(function* () { | |
| 147 | const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); | |
| 148 | const sample = nativeSnapshot({ | |
| 149 | sequence: 1, | |
| 150 | sampledAtUnixMs, | |
| 151 | childCpuTimeMs: 100, | |
| 152 | childWriteBytes: 1_000, | |
| 153 | }); | |
| 154 | const demandChanges = yield* Ref.make<ReadonlyArray<boolean>>([]); | |
| 155 | const nativeLayer = NativeTelemetryClient.layerTest({ | |
| 156 | sampleNow: Effect.succeed(nativeGeneration(sample, 0)), | |
| 157 | health: Effect.succeed({ | |
| 158 | status: "healthy", | |
| 159 | hello: Option.none(), | |
| 160 | lastSampleAt: Option.none(), | |
| 161 | lastError: Option.none(), | |
| 162 | restartCount: 0, | |
| 163 | sampleIntervalMs: 1_000, | |
| 164 | }), | |
| 165 | }); | |
| 166 | const desktopLayer = DesktopTelemetryReceiver.layerTest({ | |
| 167 | latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), | |
| 168 | setDiagnosticsDemand: (enabled) => | |
| 169 | Ref.update(demandChanges, (changes) => [...changes, enabled]), | |
| 170 | }); | |
| 171 | const telemetryLayer = ResourceTelemetry.layer.pipe( | |
| 172 | Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), | |
| 173 | ); | |
| 174 | ||
| 175 | const live = yield* Stream.runHead( | |
| 176 | Effect.gen(function* () { | |
| 177 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 178 | return telemetry.changes; | |
| 179 | }).pipe(Stream.unwrap), | |
| 180 | ).pipe(Effect.provide(telemetryLayer)); | |
| 181 | ||
| 182 | expect(Option.isSome(live)).toBe(true); | |
| 183 | expect(yield* Ref.get(demandChanges)).toEqual([true, false]); | |
| 184 | }), | |
| 185 | ); | |
| 186 | ||
| 187 | it.effect("releases live demand when acquisition is interrupted by an idle collector", () => | |
| 188 | Effect.gen(function* () { | |
| 189 | const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); | |
| 190 | const demandChanges = yield* Ref.make<ReadonlyArray<boolean>>([]); | |
| 191 | const nativeLayer = NativeTelemetryClient.layerTest({ | |
| 192 | sampleNow: Effect.never, | |
| 193 | health: Effect.succeed({ | |
| 194 | status: "healthy", | |
| 195 | hello: Option.none(), | |
| 196 | lastSampleAt: Option.none(), | |
| 197 | lastError: Option.none(), | |
| 198 | restartCount: 0, | |
| 199 | sampleIntervalMs: 1_000, | |
| 200 | }), | |
| 201 | }); | |
| 202 | const desktopLayer = DesktopTelemetryReceiver.layerTest({ | |
| 203 | latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), | |
| 204 | setDiagnosticsDemand: (enabled) => | |
| 205 | Ref.update(demandChanges, (changes) => [...changes, enabled]), | |
| 206 | }); | |
| 207 | const telemetryLayer = ResourceTelemetry.layer.pipe( | |
| 208 | Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), | |
| 209 | ); | |
| 210 | ||
| 211 | const resultFiber = yield* Stream.runHead( | |
| 212 | Effect.gen(function* () { | |
| 213 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 214 | return telemetry.changes; | |
| 215 | }).pipe(Stream.unwrap), | |
| 216 | ).pipe(Effect.timeoutOption("10 millis"), Effect.provide(telemetryLayer), Effect.forkChild); | |
| 217 | yield* Effect.yieldNow; | |
| 218 | yield* TestClock.adjust("10 millis"); | |
| 219 | const result = yield* Fiber.join(resultFiber); | |
| 220 | ||
| 221 | expect(Option.isNone(result)).toBe(true); | |
| 222 | expect(yield* Ref.get(demandChanges)).toEqual([true, false]); | |
| 223 | }), | |
| 224 | ); | |
| 225 | ||
| 226 | it.effect( | |
| 227 | "attributes an initial Electron root from the identity recorded by the native snapshot", | |
| 228 | () => | |
| 229 | Effect.gen(function* () { | |
| 230 | const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); | |
| 231 | const sample = nativeSnapshot({ | |
| 232 | sequence: 1, | |
| 233 | sampledAtUnixMs, | |
| 234 | childCpuTimeMs: 100, | |
| 235 | childWriteBytes: 1_000, | |
| 236 | externalProcesses: [{ pid: 5_000, startTimeMs: 300 }], | |
| 237 | }); | |
| 238 | const desktop = { | |
| 239 | ...desktopSnapshot(sampledAtUnixMs), | |
| 240 | electronProcesses: [], | |
| 241 | }; | |
| 242 | const nativeLayer = NativeTelemetryClient.layerTest({ | |
| 243 | sampleNow: Effect.succeed(nativeGeneration(sample, 0)), | |
| 244 | health: Effect.succeed({ | |
| 245 | status: "healthy", | |
| 246 | hello: Option.none(), | |
| 247 | lastSampleAt: Option.none(), | |
| 248 | lastError: Option.none(), | |
| 249 | restartCount: 0, | |
| 250 | sampleIntervalMs: 1_000, | |
| 251 | }), | |
| 252 | }); | |
| 253 | const desktopLayer = DesktopTelemetryReceiver.layerTest({ | |
| 254 | latest: Effect.succeedSome(desktop), | |
| 255 | }); | |
| 256 | const telemetryLayer = ResourceTelemetry.layer.pipe( | |
| 257 | Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), | |
| 258 | ); | |
| 259 | ||
| 260 | const snapshot = yield* Effect.gen(function* () { | |
| 261 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 262 | return yield* telemetry.refresh; | |
| 263 | }).pipe(Effect.provide(telemetryLayer)); | |
| 264 | ||
| 265 | expect(snapshot.processes.find((entry) => entry.identity.pid === 5_000)?.category).toBe( | |
| 266 | "electron-main", | |
| 267 | ); | |
| 268 | expect(snapshot.groups.electron.processStarts).toBe(1); | |
| 269 | expect(snapshot.groups.backend.processStarts).toBe(3); | |
| 270 | }), | |
| 271 | ); | |
| 272 | ||
| 273 | it.effect("rejects buffered snapshots from an earlier sidecar generation", () => | |
| 274 | Effect.scoped( | |
| 275 | Effect.gen(function* () { | |
| 276 | const startedAt = DateTime.toEpochMillis(yield* DateTime.now); | |
| 277 | const stale = nativeSnapshot({ | |
| 278 | sequence: 100, | |
| 279 | sampledAtUnixMs: startedAt + 1_000, | |
| 280 | childCpuTimeMs: 100, | |
| 281 | childWriteBytes: 1_000, | |
| 282 | }); | |
| 283 | const current = nativeSnapshot({ | |
| 284 | sequence: 1, | |
| 285 | sampledAtUnixMs: startedAt + 2_000, | |
| 286 | childCpuTimeMs: 200, | |
| 287 | childWriteBytes: 2_000, | |
| 288 | }); | |
| 289 | const nativeSnapshots = | |
| 290 | yield* PubSub.unbounded<NativeTelemetryClient.NativeTelemetrySnapshot>(); | |
| 291 | const nativeLayer = NativeTelemetryClient.layerTest({ | |
| 292 | snapshots: Stream.fromPubSub(nativeSnapshots), | |
| 293 | sampleNow: Effect.never, | |
| 294 | health: Effect.succeed({ | |
| 295 | status: "healthy", | |
| 296 | hello: Option.none(), | |
| 297 | lastSampleAt: Option.none(), | |
| 298 | lastError: Option.none(), | |
| 299 | restartCount: 1, | |
| 300 | sampleIntervalMs: 1_000, | |
| 301 | }), | |
| 302 | }); | |
| 303 | const telemetryLayer = ResourceTelemetry.layer.pipe( | |
| 304 | Layer.provide( | |
| 305 | Layer.mergeAll( | |
| 306 | nativeLayer, | |
| 307 | DesktopTelemetryReceiver.layerTest(), | |
| 308 | ResourceAttribution.layer, | |
| 309 | ), | |
| 310 | ), | |
| 311 | ); | |
| 312 | ||
| 313 | yield* Effect.gen(function* () { | |
| 314 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 315 | const subscription = yield* telemetry.subscribe; | |
| 316 | const nextSnapshot = yield* Stream.runHead(subscription.changes).pipe(Effect.forkChild); | |
| 317 | ||
| 318 | yield* PubSub.publish(nativeSnapshots, nativeGeneration(stale, 0)); | |
| 319 | yield* Effect.yieldNow; | |
| 320 | expect((yield* telemetry.latest).health.scannedProcessCount).toBe(0); | |
| 321 | ||
| 322 | yield* PubSub.publish(nativeSnapshots, nativeGeneration(current, 1)); | |
| 323 | const received = yield* Fiber.join(nextSnapshot); | |
| 324 | ||
| 325 | expect(Option.isSome(received)).toBe(true); | |
| 326 | expect(DateTime.toEpochMillis(Option.getOrThrow(received).readAt)).toBe( | |
| 327 | current.sampledAtUnixMs, | |
| 328 | ); | |
| 329 | }).pipe(Effect.provide(telemetryLayer)); | |
| 330 | }), | |
| 331 | ), | |
| 332 | ); | |
| 333 | ||
| 334 | it.effect("retains desktop health changes while aggregate telemetry initializes", () => | |
| 335 | Effect.scoped( | |
| 336 | Effect.gen(function* () { | |
| 337 | const health = yield* Ref.make<DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth>({ | |
| 338 | status: "starting", | |
| 339 | lastSampleAt: Option.none(), | |
| 340 | lastError: Option.none(), | |
| 341 | }); | |
| 342 | const healthChanges = | |
| 343 | yield* PubSub.sliding<DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth>(4); | |
| 344 | const healthSubscribed = yield* Deferred.make<void>(); | |
| 345 | const finishDesktopSnapshot = yield* Deferred.make<void>(); | |
| 346 | const desktopLayer = DesktopTelemetryReceiver.layerTest({ | |
| 347 | health: Ref.get(health), | |
| 348 | subscribeHealth: Effect.gen(function* () { | |
| 349 | const subscription = yield* PubSub.subscribe(healthChanges); | |
| 350 | const latest = yield* Ref.get(health); | |
| 351 | yield* Deferred.succeed(healthSubscribed, undefined); | |
| 352 | return { | |
| 353 | latest, | |
| 354 | changes: Stream.fromSubscription(subscription), | |
| 355 | }; | |
| 356 | }), | |
| 357 | subscribe: Deferred.await(finishDesktopSnapshot).pipe( | |
| 358 | Effect.as({ | |
| 359 | latest: Option.none(), | |
| 360 | changes: Stream.empty, | |
| 361 | }), | |
| 362 | ), | |
| 363 | }); | |
| 364 | const telemetryLayer = ResourceTelemetry.layer.pipe( | |
| 365 | Layer.provide( | |
| 366 | Layer.mergeAll( | |
| 367 | NativeTelemetryClient.layerTest(), | |
| 368 | desktopLayer, | |
| 369 | ResourceAttribution.layer, | |
| 370 | ), | |
| 371 | ), | |
| 372 | ); | |
| 373 | const resultFiber = yield* Effect.gen(function* () { | |
| 374 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 375 | while (true) { | |
| 376 | const snapshot = yield* telemetry.latest; | |
| 377 | if (snapshot.health.desktop.status === "degraded") return snapshot; | |
| 378 | yield* Effect.yieldNow; | |
| 379 | } | |
| 380 | }).pipe(Effect.provide(telemetryLayer), Effect.timeout("1 second"), Effect.forkChild); | |
| 381 | ||
| 382 | yield* Deferred.await(healthSubscribed); | |
| 383 | const degraded: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth = { | |
| 384 | status: "degraded", | |
| 385 | lastSampleAt: Option.none(), | |
| 386 | lastError: Option.some("desktop telemetry failed"), | |
| 387 | }; | |
| 388 | yield* Ref.set(health, degraded); | |
| 389 | yield* PubSub.publish(healthChanges, degraded); | |
| 390 | yield* Deferred.succeed(finishDesktopSnapshot, undefined); | |
| 391 | ||
| 392 | const snapshot = yield* Fiber.join(resultFiber); | |
| 393 | expect(snapshot.health.desktop.status).toBe("degraded"); | |
| 394 | expect(Option.getOrNull(snapshot.health.desktop.lastError)).toBe( | |
| 395 | "desktop telemetry failed", | |
| 396 | ); | |
| 397 | }), | |
| 398 | ), | |
| 399 | ); | |
| 400 | ||
| 401 | it.effect("atomically subscribes while a desktop update is being rebuilt", () => | |
| 402 | Effect.scoped( | |
| 403 | Effect.gen(function* () { | |
| 404 | const startedAt = DateTime.toEpochMillis(yield* DateTime.now); | |
| 405 | const nextDesktopSnapshot = desktopSnapshot(startedAt + 1_000); | |
| 406 | const desktopChanges = yield* PubSub.unbounded<DesktopHostTelemetrySnapshot>(); | |
| 407 | const rebuildStarted = yield* Deferred.make<void>(); | |
| 408 | const finishRebuild = yield* Deferred.make<void>(); | |
| 409 | const attributionReads = yield* Ref.make(0); | |
| 410 | const attributionLayer = Layer.succeed( | |
| 411 | ResourceAttribution.ResourceAttribution, | |
| 412 | ResourceAttribution.ResourceAttribution.of({ | |
| 413 | record: () => Effect.void, | |
| 414 | snapshot: Ref.updateAndGet(attributionReads, (count) => count + 1).pipe( | |
| 415 | Effect.flatMap((count) => | |
| 416 | count === 1 | |
| 417 | ? Effect.void | |
| 418 | : Deferred.succeed(rebuildStarted, undefined).pipe( | |
| 419 | Effect.andThen(Deferred.await(finishRebuild)), | |
| 420 | ), | |
| 421 | ), | |
| 422 | Effect.andThen( | |
| 423 | Effect.succeed({ | |
| 424 | readAt: DateTime.makeUnsafe(startedAt), | |
| 425 | entries: [], | |
| 426 | }), | |
| 427 | ), | |
| 428 | ), | |
| 429 | }), | |
| 430 | ); | |
| 431 | const nativeLayer = NativeTelemetryClient.layerTest({ | |
| 432 | sampleNow: Effect.never, | |
| 433 | health: Effect.succeed({ | |
| 434 | status: "healthy", | |
| 435 | hello: Option.none(), | |
| 436 | lastSampleAt: Option.none(), | |
| 437 | lastError: Option.none(), | |
| 438 | restartCount: 0, | |
| 439 | sampleIntervalMs: 1_000, | |
| 440 | }), | |
| 441 | }); | |
| 442 | const desktopLayer = DesktopTelemetryReceiver.layerTest({ | |
| 443 | changes: Stream.fromPubSub(desktopChanges), | |
| 444 | }); | |
| 445 | const telemetryLayer = ResourceTelemetry.layer.pipe( | |
| 446 | Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer)), | |
| 447 | ); | |
| 448 | ||
| 449 | yield* Effect.gen(function* () { | |
| 450 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 451 | yield* Effect.yieldNow; | |
| 452 | yield* PubSub.publish(desktopChanges, nextDesktopSnapshot); | |
| 453 | yield* Deferred.await(rebuildStarted); | |
| 454 | ||
| 455 | const subscriptionFiber = yield* telemetry.subscribe.pipe(Effect.forkChild); | |
| 456 | yield* Effect.yieldNow; | |
| 457 | yield* Deferred.succeed(finishRebuild, undefined); | |
| 458 | const subscription = yield* Fiber.join(subscriptionFiber); | |
| 459 | ||
| 460 | expect(DateTime.toEpochMillis(subscription.latest.readAt)).toBe( | |
| 461 | nextDesktopSnapshot.sampledAtUnixMs, | |
| 462 | ); | |
| 463 | expect(subscription.latest.power).toEqual(nextDesktopSnapshot.power); | |
| 464 | }).pipe(Effect.provide(telemetryLayer)); | |
| 465 | }), | |
| 466 | ), | |
| 467 | ); | |
| 468 | ||
| 469 | it.effect("combines native, Electron, attribution, retry, and history data", () => | |
| 470 | Effect.gen(function* () { | |
| 471 | const startedAt = DateTime.toEpochMillis(yield* DateTime.now); | |
| 472 | const samples = [ | |
| 473 | nativeSnapshot({ | |
| 474 | sequence: 1, | |
| 475 | sampledAtUnixMs: startedAt, | |
| 476 | childCpuTimeMs: 100, | |
| 477 | childWriteBytes: 1_000, | |
| 478 | }), | |
| 479 | nativeSnapshot({ | |
| 480 | sequence: 2, | |
| 481 | sampledAtUnixMs: startedAt + 1_000, | |
| 482 | childCpuTimeMs: 350, | |
| 483 | childWriteBytes: 5_000, | |
| 484 | }), | |
| 485 | nativeSnapshot({ | |
| 486 | sequence: 1, | |
| 487 | sampledAtUnixMs: startedAt + 2_000, | |
| 488 | childCpuTimeMs: 500, | |
| 489 | childWriteBytes: 7_000, | |
| 490 | }), | |
| 491 | ] as const; | |
| 492 | const sampleIndex = yield* Ref.make(0); | |
| 493 | const externalProcesses = yield* Ref.make< | |
| 494 | ReadonlyArray<{ readonly pid: number; readonly startTimeMs?: number }> | |
| 495 | >([]); | |
| 496 | const retryCount = yield* Ref.make(0); | |
| 497 | const nativeHealth = yield* Ref.make<NativeTelemetryClient.NativeTelemetryClientHealth>({ | |
| 498 | status: "healthy", | |
| 499 | hello: Option.some({ | |
| 500 | version: 2, | |
| 501 | type: "hello", | |
| 502 | sidecarVersion: "0.1.0", | |
| 503 | sidecarPid: 9_000, | |
| 504 | platform: "test", | |
| 505 | arch: "test", | |
| 506 | capabilities: { | |
| 507 | cumulativeCpuTime: true, | |
| 508 | currentCpuPercent: true, | |
| 509 | residentMemory: true, | |
| 510 | virtualMemory: true, | |
| 511 | ioBytes: true, | |
| 512 | processStartTime: true, | |
| 513 | processTree: true, | |
| 514 | }, | |
| 515 | }), | |
| 516 | lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), | |
| 517 | lastError: Option.none(), | |
| 518 | restartCount: 2, | |
| 519 | sampleIntervalMs: 1_000, | |
| 520 | }); | |
| 521 | const nativeHealthChanges = | |
| 522 | yield* PubSub.sliding<NativeTelemetryClient.NativeTelemetryClientHealth>(4); | |
| 523 | const nativeLayer = NativeTelemetryClient.layerTest({ | |
| 524 | setExternalProcesses: (processes) => Ref.set(externalProcesses, processes), | |
| 525 | readHistory: () => Effect.succeed(samples.slice(0, 2)), | |
| 526 | sampleNow: Ref.modify(sampleIndex, (index) => { | |
| 527 | const sampleIndex = Math.min(index, samples.length - 1); | |
| 528 | return [nativeGeneration(samples[sampleIndex]!, sampleIndex === 2 ? 3 : 2), index + 1]; | |
| 529 | }), | |
| 530 | retry: Ref.updateAndGet(retryCount, (count) => count + 1).pipe(Effect.as(true)), | |
| 531 | health: Ref.get(nativeHealth), | |
| 532 | subscribeHealth: Effect.gen(function* () { | |
| 533 | const subscription = yield* PubSub.subscribe(nativeHealthChanges); | |
| 534 | const latest = yield* Ref.get(nativeHealth); | |
| 535 | return { | |
| 536 | latest, | |
| 537 | changes: Stream.fromSubscription(subscription), | |
| 538 | }; | |
| 539 | }), | |
| 540 | }); | |
| 541 | const desktopLayer = DesktopTelemetryReceiver.layerTest({ | |
| 542 | latest: Effect.succeedSome(desktopSnapshot(startedAt)), | |
| 543 | health: Effect.succeed({ | |
| 544 | status: "healthy", | |
| 545 | lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), | |
| 546 | lastError: Option.none(), | |
| 547 | }), | |
| 548 | }); | |
| 549 | const attributionLayer = ResourceAttribution.layer; | |
| 550 | const dependencies = Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer); | |
| 551 | const telemetryLayer = ResourceTelemetry.layer.pipe(Layer.provide(dependencies)); | |
| 552 | const layer = Layer.mergeAll(dependencies, telemetryLayer); | |
| 553 | ||
| 554 | yield* Effect.gen(function* () { | |
| 555 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 556 | const attribution = yield* ResourceAttribution.ResourceAttribution; | |
| 557 | ||
| 558 | expect(yield* Ref.get(externalProcesses)).toEqual([{ pid: 5_000, startTimeMs: 300 }]); | |
| 559 | ||
| 560 | yield* attribution.record({ | |
| 561 | component: "provider-event-log", | |
| 562 | operation: "append", | |
| 563 | logicalWriteBytes: 512, | |
| 564 | count: 2, | |
| 565 | durationMs: 4, | |
| 566 | }); | |
| 567 | const first = yield* telemetry.refresh; | |
| 568 | expect(first.groups.backend.processCount).toBe(2); | |
| 569 | expect(first.groups.electron.processCount).toBe(1); | |
| 570 | expect(first.groups.monitor.processCount).toBe(1); | |
| 571 | expect(first.power.onBattery).toBe("true"); | |
| 572 | expect(Option.getOrNull(first.speedLimitPercent)).toBe(90); | |
| 573 | expect(first.attribution.entries).toEqual([ | |
| 574 | { | |
| 575 | component: "provider-event-log", | |
| 576 | operation: "append", | |
| 577 | logicalReadBytes: 0, | |
| 578 | logicalWriteBytes: 512, | |
| 579 | count: 2, | |
| 580 | durationMs: 4, | |
| 581 | }, | |
| 582 | ]); | |
| 583 | ||
| 584 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 585 | const second = yield* telemetry.refresh; | |
| 586 | const codex = second.processes.find((entry) => entry.identity.pid === 4_242); | |
| 587 | expect(codex?.cpuPercent).toBe(25); | |
| 588 | expect(codex?.ioWriteBytesPerSecond).toBe(4_000); | |
| 589 | expect(second.groups.backend.ioWriteBytes).toBe(4_000); | |
| 590 | expect(second.health.collectionDurationMicros).toBe(300); | |
| 591 | expect(second.health.scannedProcessCount).toBe(80); | |
| 592 | expect(second.health.inaccessibleProcessCount).toBe(1); | |
| 593 | ||
| 594 | const history = yield* telemetry.readHistory({ | |
| 595 | windowMs: 60_000, | |
| 596 | bucketMs: 10_000, | |
| 597 | }); | |
| 598 | expect(history.retainedSampleCount).toBeGreaterThan(0); | |
| 599 | expect( | |
| 600 | history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.sampleCount, | |
| 601 | ).toBe(2); | |
| 602 | expect(history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.cpuTimeMs).toBe( | |
| 603 | 250, | |
| 604 | ); | |
| 605 | expect( | |
| 606 | history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.ioWriteBytes, | |
| 607 | ).toBe(4_000); | |
| 608 | expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe( | |
| 609 | 4_000, | |
| 610 | ); | |
| 611 | ||
| 612 | const retry = yield* telemetry.retry; | |
| 613 | expect(retry.accepted).toBe(true); | |
| 614 | expect(yield* Ref.get(retryCount)).toBe(1); | |
| 615 | ||
| 616 | yield* Ref.update(nativeHealth, (current) => ({ | |
| 617 | ...current, | |
| 618 | hello: Option.map(current.hello, (hello) => ({ | |
| 619 | ...hello, | |
| 620 | sidecarPid: 9_001, | |
| 621 | })), | |
| 622 | restartCount: 3, | |
| 623 | })); | |
| 624 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 625 | const restarted = yield* telemetry.refresh; | |
| 626 | expect(DateTime.toEpochMillis(restarted.readAt)).toBe(startedAt + 2_000); | |
| 627 | expect(Option.getOrNull(restarted.health.sidecarPid)).toBe(9_001); | |
| 628 | ||
| 629 | yield* Ref.update(nativeHealth, (current) => ({ | |
| 630 | ...current, | |
| 631 | status: "degraded" as const, | |
| 632 | lastError: Option.some("collector exited"), | |
| 633 | })); | |
| 634 | yield* PubSub.publish(nativeHealthChanges, yield* Ref.get(nativeHealth)); | |
| 635 | yield* Effect.yieldNow; | |
| 636 | const healthUpdate = yield* telemetry.latest; | |
| 637 | expect(healthUpdate.health.native.status).toBe("degraded"); | |
| 638 | expect(Option.getOrNull(healthUpdate.health.native.lastError)).toBe("collector exited"); | |
| 639 | const degradedHistory = yield* telemetry.readHistory({ | |
| 640 | windowMs: 60_000, | |
| 641 | bucketMs: 10_000, | |
| 642 | }); | |
| 643 | expect(degradedHistory.health.native.status).toBe("degraded"); | |
| 644 | }).pipe(Effect.provide(layer)); | |
| 645 | }), | |
| 646 | ); | |
| 647 | }); |
apps/server/src/resourceTelemetry/ResourceTelemetry.ts created+504| ... | ... | @@ -0,0 +1,504 @@ |
| 1 | import type { | |
| 2 | DesktopHostTelemetrySnapshot, | |
| 3 | HostPowerSnapshot, | |
| 4 | ResourceMonitorSnapshotEvent, | |
| 5 | ResourceTelemetryHealth, | |
| 6 | ResourceTelemetryHistoryInput, | |
| 7 | ResourceTelemetryProcessIdentity, | |
| 8 | ResourceTelemetryRetryResult, | |
| 9 | ResourceTelemetrySnapshot, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Context from "effect/Context"; | |
| 12 | import * as DateTime from "effect/DateTime"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Exit from "effect/Exit"; | |
| 15 | import * as Layer from "effect/Layer"; | |
| 16 | import * as Option from "effect/Option"; | |
| 17 | import * as PubSub from "effect/PubSub"; | |
| 18 | import * as Ref from "effect/Ref"; | |
| 19 | import * as Result from "effect/Result"; | |
| 20 | import * as Schema from "effect/Schema"; | |
| 21 | import * as Scope from "effect/Scope"; | |
| 22 | import * as Semaphore from "effect/Semaphore"; | |
| 23 | import * as Stream from "effect/Stream"; | |
| 24 | ||
| 25 | import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; | |
| 26 | import { | |
| 27 | emptyTelemetryCounters, | |
| 28 | mergeProcesses, | |
| 29 | type ProcessState, | |
| 30 | type TelemetryCounters, | |
| 31 | } from "./Model.ts"; | |
| 32 | import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; | |
| 33 | import * as ResourceAttribution from "./ResourceAttribution.ts"; | |
| 34 | import { | |
| 35 | buildResourceTelemetryHistory, | |
| 36 | normalizeResourceTelemetryHistoryInput, | |
| 37 | type ResourceTelemetryHistoryWithLegacyBuckets, | |
| 38 | } from "./ResourceTelemetryHistory.ts"; | |
| 39 | import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts"; | |
| 40 | ||
| 41 | export class ResourceTelemetryRefreshFailed extends Schema.TaggedErrorClass<ResourceTelemetryRefreshFailed>()( | |
| 42 | "ResourceTelemetryRefreshFailed", | |
| 43 | { | |
| 44 | operation: Schema.String, | |
| 45 | cause: Schema.Defect(), | |
| 46 | }, | |
| 47 | ) { | |
| 48 | override get message(): string { | |
| 49 | return `Resource telemetry operation '${this.operation}' failed.`; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | export class ResourceTelemetry extends Context.Service< | |
| 54 | ResourceTelemetry, | |
| 55 | { | |
| 56 | readonly latest: Effect.Effect<ResourceTelemetrySnapshot>; | |
| 57 | readonly changes: Stream.Stream<ResourceTelemetrySnapshot>; | |
| 58 | readonly subscribe: Effect.Effect< | |
| 59 | { | |
| 60 | readonly latest: ResourceTelemetrySnapshot; | |
| 61 | readonly changes: Stream.Stream<ResourceTelemetrySnapshot>; | |
| 62 | }, | |
| 63 | never, | |
| 64 | Scope.Scope | |
| 65 | >; | |
| 66 | readonly readHistory: ( | |
| 67 | input: ResourceTelemetryHistoryInput, | |
| 68 | ) => Effect.Effect<ResourceTelemetryHistoryWithLegacyBuckets>; | |
| 69 | readonly refresh: Effect.Effect<ResourceTelemetrySnapshot, ResourceTelemetryRefreshFailed>; | |
| 70 | readonly validateProcessIdentity: ( | |
| 71 | identity: ResourceTelemetryProcessIdentity, | |
| 72 | ) => Effect.Effect<boolean, ResourceTelemetryRefreshFailed>; | |
| 73 | readonly retry: Effect.Effect<ResourceTelemetryRetryResult>; | |
| 74 | } | |
| 75 | >()("t3/resourceTelemetry/ResourceTelemetry") {} | |
| 76 | ||
| 77 | interface TelemetryState { | |
| 78 | readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>; | |
| 79 | readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>; | |
| 80 | readonly previous: ReadonlyMap<string, ProcessState>; | |
| 81 | readonly counters: TelemetryCounters; | |
| 82 | readonly latest: ResourceTelemetrySnapshot; | |
| 83 | readonly lastNativeSequence: number; | |
| 84 | readonly lastNativeGeneration: number; | |
| 85 | } | |
| 86 | ||
| 87 | interface LiveTelemetryState { | |
| 88 | readonly retainCount: number; | |
| 89 | readonly scope: Option.Option<Scope.Closeable>; | |
| 90 | } | |
| 91 | ||
| 92 | function unknownPower(updatedAt: DateTime.Utc): HostPowerSnapshot { | |
| 93 | return { | |
| 94 | source: "unknown", | |
| 95 | idle: "unknown", | |
| 96 | idleSeconds: null, | |
| 97 | locked: "unknown", | |
| 98 | suspended: false, | |
| 99 | onBattery: "unknown", | |
| 100 | lowPowerMode: "unknown", | |
| 101 | thermalState: "unknown", | |
| 102 | stale: true, | |
| 103 | updatedAt, | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | function buildHealth(input: { | |
| 108 | readonly native: NativeTelemetryClient.NativeTelemetryClientHealth; | |
| 109 | readonly desktop: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth; | |
| 110 | readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>; | |
| 111 | }): ResourceTelemetryHealth { | |
| 112 | return { | |
| 113 | native: { | |
| 114 | status: input.native.status, | |
| 115 | lastSampleAt: input.native.lastSampleAt, | |
| 116 | lastError: input.native.lastError, | |
| 117 | }, | |
| 118 | desktop: { | |
| 119 | status: input.desktop.status, | |
| 120 | lastSampleAt: input.desktop.lastSampleAt, | |
| 121 | lastError: input.desktop.lastError, | |
| 122 | }, | |
| 123 | sidecarVersion: Option.map(input.native.hello, (hello) => hello.sidecarVersion), | |
| 124 | sidecarPid: Option.map(input.native.hello, (hello) => hello.sidecarPid), | |
| 125 | restartCount: input.native.restartCount, | |
| 126 | collectionDurationMicros: Option.match(input.nativeSnapshot, { | |
| 127 | onNone: () => 0, | |
| 128 | onSome: (snapshot) => snapshot.collectionDurationMicros, | |
| 129 | }), | |
| 130 | scannedProcessCount: Option.match(input.nativeSnapshot, { | |
| 131 | onNone: () => 0, | |
| 132 | onSome: (snapshot) => snapshot.scannedProcessCount, | |
| 133 | }), | |
| 134 | retainedProcessCount: Option.match(input.nativeSnapshot, { | |
| 135 | onNone: () => 0, | |
| 136 | onSome: (snapshot) => snapshot.retainedProcessCount, | |
| 137 | }), | |
| 138 | inaccessibleProcessCount: Option.match(input.nativeSnapshot, { | |
| 139 | onNone: () => 0, | |
| 140 | onSome: (snapshot) => snapshot.inaccessibleProcessCount, | |
| 141 | }), | |
| 142 | }; | |
| 143 | } | |
| 144 | ||
| 145 | export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () { | |
| 146 | const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient; | |
| 147 | const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; | |
| 148 | const attribution = yield* ResourceAttribution.ResourceAttribution; | |
| 149 | const mutex = yield* Semaphore.make(1); | |
| 150 | const changes = yield* PubSub.sliding<ResourceTelemetrySnapshot>(8); | |
| 151 | const initialReadAt = yield* DateTime.now; | |
| 152 | const nativeHealthSubscription = yield* nativeClient.subscribeHealth; | |
| 153 | const desktopHealthSubscription = yield* desktopReceiver.subscribeHealth; | |
| 154 | const desktopSubscription = yield* desktopReceiver.subscribe; | |
| 155 | const initialDesktop = desktopSubscription.latest; | |
| 156 | if (Option.isSome(initialDesktop)) { | |
| 157 | const electronRoot = initialDesktop.value.electronProcesses.find( | |
| 158 | (process) => process.pid === initialDesktop.value.electronPid, | |
| 159 | ); | |
| 160 | yield* nativeClient | |
| 161 | .setExternalProcesses([ | |
| 162 | { | |
| 163 | pid: initialDesktop.value.electronPid, | |
| 164 | ...(electronRoot === undefined ? {} : { startTimeMs: electronRoot.creationTimeMs }), | |
| 165 | }, | |
| 166 | ]) | |
| 167 | .pipe(Effect.ignore); | |
| 168 | yield* nativeClient.setHostPowerState(initialDesktop.value.power).pipe(Effect.ignore); | |
| 169 | } | |
| 170 | const initialNativeHealth = nativeHealthSubscription.latest; | |
| 171 | const initialDesktopHealth = desktopHealthSubscription.latest; | |
| 172 | const initialAttribution = yield* attribution.snapshot; | |
| 173 | const initialMerge = mergeProcesses({ | |
| 174 | serverPid: process.pid, | |
| 175 | sidecarPid: Option.map(initialNativeHealth.hello, (hello) => hello.sidecarPid), | |
| 176 | fallbackSampledAtMs: DateTime.toEpochMillis(initialReadAt), | |
| 177 | nativeSnapshot: Option.none(), | |
| 178 | desktopSnapshot: initialDesktop, | |
| 179 | previous: new Map(), | |
| 180 | counters: emptyTelemetryCounters(), | |
| 181 | updatePrevious: false, | |
| 182 | }); | |
| 183 | const initialSnapshot: ResourceTelemetrySnapshot = { | |
| 184 | readAt: initialReadAt, | |
| 185 | sampleIntervalMs: initialNativeHealth.sampleIntervalMs, | |
| 186 | processes: initialMerge.processes, | |
| 187 | groups: initialMerge.groups, | |
| 188 | power: Option.match(initialDesktop, { | |
| 189 | onNone: () => unknownPower(initialReadAt), | |
| 190 | onSome: (desktop) => desktop.power, | |
| 191 | }), | |
| 192 | speedLimitPercent: Option.flatMap(initialDesktop, (desktop) => desktop.speedLimitPercent), | |
| 193 | attribution: initialAttribution, | |
| 194 | health: buildHealth({ | |
| 195 | native: initialNativeHealth, | |
| 196 | desktop: initialDesktopHealth, | |
| 197 | nativeSnapshot: Option.none(), | |
| 198 | }), | |
| 199 | }; | |
| 200 | const state = yield* Ref.make<TelemetryState>({ | |
| 201 | nativeSnapshot: Option.none(), | |
| 202 | desktopSnapshot: initialDesktop, | |
| 203 | previous: new Map(), | |
| 204 | counters: emptyTelemetryCounters(), | |
| 205 | latest: initialSnapshot, | |
| 206 | lastNativeSequence: 0, | |
| 207 | lastNativeGeneration: initialNativeHealth.restartCount, | |
| 208 | }); | |
| 209 | const liveState = yield* Ref.make<LiveTelemetryState>({ | |
| 210 | retainCount: 0, | |
| 211 | scope: Option.none(), | |
| 212 | }); | |
| 213 | const liveMutex = yield* Semaphore.make(1); | |
| 214 | const refreshHealth = mutex.withPermits(1)( | |
| 215 | Effect.gen(function* () { | |
| 216 | const current = yield* Ref.get(state); | |
| 217 | const [nativeHealth, desktopHealth] = yield* Effect.all([ | |
| 218 | nativeClient.health, | |
| 219 | desktopReceiver.health, | |
| 220 | ]); | |
| 221 | const snapshot: ResourceTelemetrySnapshot = { | |
| 222 | ...current.latest, | |
| 223 | health: buildHealth({ | |
| 224 | native: nativeHealth, | |
| 225 | desktop: desktopHealth, | |
| 226 | nativeSnapshot: current.nativeSnapshot, | |
| 227 | }), | |
| 228 | }; | |
| 229 | yield* Ref.set(state, { | |
| 230 | ...current, | |
| 231 | latest: snapshot, | |
| 232 | }); | |
| 233 | if ((yield* Ref.get(liveState)).retainCount > 0) { | |
| 234 | yield* PubSub.publish(changes, snapshot); | |
| 235 | } | |
| 236 | }), | |
| 237 | ); | |
| 238 | ||
| 239 | const rebuild = (input: { | |
| 240 | readonly nativeSnapshot?: NativeTelemetryClient.NativeTelemetrySnapshot; | |
| 241 | readonly desktopSnapshot?: DesktopHostTelemetrySnapshot; | |
| 242 | readonly updatePrevious: boolean; | |
| 243 | readonly publishWhenLive?: boolean; | |
| 244 | }): Effect.Effect<ResourceTelemetrySnapshot> => | |
| 245 | mutex.withPermits(1)( | |
| 246 | Effect.gen(function* () { | |
| 247 | const current = yield* Ref.get(state); | |
| 248 | const nativeHealth = yield* nativeClient.health; | |
| 249 | const incomingNativeSnapshot = input.nativeSnapshot; | |
| 250 | if ( | |
| 251 | incomingNativeSnapshot && | |
| 252 | (incomingNativeSnapshot.generation < nativeHealth.restartCount || | |
| 253 | incomingNativeSnapshot.generation < current.lastNativeGeneration || | |
| 254 | (incomingNativeSnapshot.generation === current.lastNativeGeneration && | |
| 255 | incomingNativeSnapshot.snapshot.sequence <= current.lastNativeSequence)) | |
| 256 | ) { | |
| 257 | return current.latest; | |
| 258 | } | |
| 259 | const nativeSnapshot = incomingNativeSnapshot | |
| 260 | ? Option.some(incomingNativeSnapshot.snapshot) | |
| 261 | : current.nativeSnapshot; | |
| 262 | const desktopSnapshot = input.desktopSnapshot | |
| 263 | ? Option.some(input.desktopSnapshot) | |
| 264 | : current.desktopSnapshot; | |
| 265 | const [desktopHealth, attributionSnapshot] = yield* Effect.all([ | |
| 266 | desktopReceiver.health, | |
| 267 | attribution.snapshot, | |
| 268 | ]); | |
| 269 | const recordedElectronRoots = Option.match(nativeSnapshot, { | |
| 270 | onNone: () => [], | |
| 271 | onSome: (snapshot) => snapshot.externalProcesses ?? [], | |
| 272 | }); | |
| 273 | const electronRootPids = new Set(recordedElectronRoots.map((process) => process.pid)); | |
| 274 | Option.match(desktopSnapshot, { | |
| 275 | onNone: () => undefined, | |
| 276 | onSome: (desktop) => electronRootPids.add(desktop.electronPid), | |
| 277 | }); | |
| 278 | const electronRootStartTimes = new Map( | |
| 279 | recordedElectronRoots.flatMap((process) => | |
| 280 | process.startTimeMs === undefined ? [] : [[process.pid, process.startTimeMs] as const], | |
| 281 | ), | |
| 282 | ); | |
| 283 | const merged = mergeProcesses({ | |
| 284 | serverPid: process.pid, | |
| 285 | sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), | |
| 286 | fallbackSampledAtMs: DateTime.toEpochMillis(current.latest.readAt), | |
| 287 | nativeSnapshot, | |
| 288 | desktopSnapshot, | |
| 289 | electronRootPids, | |
| 290 | electronRootStartTimes, | |
| 291 | previous: current.previous, | |
| 292 | counters: current.counters, | |
| 293 | updatePrevious: input.updatePrevious, | |
| 294 | }); | |
| 295 | const readAt = DateTime.makeUnsafe(merged.sampledAtMs); | |
| 296 | const snapshot: ResourceTelemetrySnapshot = { | |
| 297 | readAt, | |
| 298 | sampleIntervalMs: nativeHealth.sampleIntervalMs, | |
| 299 | processes: merged.processes, | |
| 300 | groups: merged.groups, | |
| 301 | power: Option.match(desktopSnapshot, { | |
| 302 | onNone: () => unknownPower(readAt), | |
| 303 | onSome: (desktop) => desktop.power, | |
| 304 | }), | |
| 305 | speedLimitPercent: Option.match(desktopSnapshot, { | |
| 306 | onNone: () => Option.none(), | |
| 307 | onSome: (desktop) => desktop.speedLimitPercent, | |
| 308 | }), | |
| 309 | attribution: attributionSnapshot, | |
| 310 | health: buildHealth({ | |
| 311 | native: nativeHealth, | |
| 312 | desktop: desktopHealth, | |
| 313 | nativeSnapshot, | |
| 314 | }), | |
| 315 | }; | |
| 316 | yield* Ref.set(state, { | |
| 317 | nativeSnapshot, | |
| 318 | desktopSnapshot, | |
| 319 | previous: merged.previous, | |
| 320 | counters: merged.counters, | |
| 321 | latest: snapshot, | |
| 322 | lastNativeSequence: | |
| 323 | incomingNativeSnapshot?.snapshot.sequence ?? current.lastNativeSequence, | |
| 324 | lastNativeGeneration: incomingNativeSnapshot?.generation ?? current.lastNativeGeneration, | |
| 325 | }); | |
| 326 | if (!input.publishWhenLive || (yield* Ref.get(liveState)).retainCount > 0) { | |
| 327 | yield* PubSub.publish(changes, snapshot); | |
| 328 | } | |
| 329 | return snapshot; | |
| 330 | }), | |
| 331 | ); | |
| 332 | ||
| 333 | const ingestNative = (snapshot: NativeTelemetryClient.NativeTelemetrySnapshot) => | |
| 334 | rebuild({ nativeSnapshot: snapshot, updatePrevious: true }); | |
| 335 | const ingestDesktop = (snapshot: DesktopHostTelemetrySnapshot) => | |
| 336 | Effect.gen(function* () { | |
| 337 | const electronRoot = snapshot.electronProcesses.find( | |
| 338 | (process) => process.pid === snapshot.electronPid, | |
| 339 | ); | |
| 340 | yield* nativeClient | |
| 341 | .setExternalProcesses([ | |
| 342 | { | |
| 343 | pid: snapshot.electronPid, | |
| 344 | ...(electronRoot === undefined ? {} : { startTimeMs: electronRoot.creationTimeMs }), | |
| 345 | }, | |
| 346 | ]) | |
| 347 | .pipe(Effect.ignore); | |
| 348 | yield* nativeClient.setHostPowerState(snapshot.power).pipe(Effect.ignore); | |
| 349 | return yield* rebuild({ | |
| 350 | desktopSnapshot: snapshot, | |
| 351 | updatePrevious: false, | |
| 352 | publishWhenLive: true, | |
| 353 | }); | |
| 354 | }); | |
| 355 | ||
| 356 | yield* desktopSubscription.changes.pipe( | |
| 357 | Stream.runForEach((snapshot) => ingestDesktop(snapshot)), | |
| 358 | Effect.forkScoped, | |
| 359 | ); | |
| 360 | ||
| 361 | const acquireLive = liveMutex.withPermits(1)( | |
| 362 | Effect.uninterruptible( | |
| 363 | Effect.gen(function* () { | |
| 364 | const current = yield* Ref.get(liveState); | |
| 365 | if (current.retainCount > 0) { | |
| 366 | yield* Ref.set(liveState, { ...current, retainCount: current.retainCount + 1 }); | |
| 367 | return; | |
| 368 | } | |
| 369 | ||
| 370 | const scope = yield* Scope.make(); | |
| 371 | yield* desktopReceiver.setDiagnosticsDemand(true).pipe(Effect.ignore); | |
| 372 | yield* nativeClient.snapshots.pipe( | |
| 373 | Stream.runForEach(ingestNative), | |
| 374 | Effect.catch((error) => | |
| 375 | Effect.logWarning("Native resource telemetry stream stopped", { | |
| 376 | cause: error.message, | |
| 377 | }), | |
| 378 | ), | |
| 379 | Effect.forkIn(scope), | |
| 380 | ); | |
| 381 | yield* nativeClient.sampleNow.pipe( | |
| 382 | Effect.flatMap(ingestNative), | |
| 383 | Effect.ignore, | |
| 384 | Effect.forkIn(scope), | |
| 385 | ); | |
| 386 | yield* Ref.set(liveState, { retainCount: 1, scope: Option.some(scope) }); | |
| 387 | }), | |
| 388 | ), | |
| 389 | ); | |
| 390 | ||
| 391 | const releaseLive = liveMutex.withPermits(1)( | |
| 392 | Effect.gen(function* () { | |
| 393 | const current = yield* Ref.get(liveState); | |
| 394 | if (current.retainCount <= 1) { | |
| 395 | yield* Ref.set(liveState, { retainCount: 0, scope: Option.none() }); | |
| 396 | if (Option.isSome(current.scope)) { | |
| 397 | yield* Scope.close(current.scope.value, Exit.void).pipe(Effect.ignore); | |
| 398 | } | |
| 399 | yield* desktopReceiver.setDiagnosticsDemand(false).pipe(Effect.ignore); | |
| 400 | return; | |
| 401 | } | |
| 402 | yield* Ref.set(liveState, { ...current, retainCount: current.retainCount - 1 }); | |
| 403 | }), | |
| 404 | ); | |
| 405 | ||
| 406 | const latest = Ref.get(state).pipe(Effect.map((current) => current.latest)); | |
| 407 | const subscribe = subscribeBeforeSnapshot( | |
| 408 | changes, | |
| 409 | Effect.acquireRelease(acquireLive, () => releaseLive).pipe(Effect.andThen(latest)), | |
| 410 | mutex, | |
| 411 | ); | |
| 412 | const liveChanges = Stream.unwrap(Effect.map(subscribe, ({ changes }) => changes)); | |
| 413 | ||
| 414 | const readHistory: ResourceTelemetry["Service"]["readHistory"] = (input) => | |
| 415 | Effect.gen(function* () { | |
| 416 | const readAt = yield* DateTime.now; | |
| 417 | const normalizedInput = normalizeResourceTelemetryHistoryInput(input); | |
| 418 | const historyResult = yield* Effect.result( | |
| 419 | nativeClient.readHistory(normalizedInput.windowMs), | |
| 420 | ); | |
| 421 | if (Result.isFailure(historyResult)) { | |
| 422 | yield* Effect.logWarning("Failed to read native resource telemetry history", { | |
| 423 | cause: historyResult.failure.message, | |
| 424 | }); | |
| 425 | } | |
| 426 | const [nativeHealth, desktopHealth] = yield* Effect.all([ | |
| 427 | nativeClient.health, | |
| 428 | desktopReceiver.health, | |
| 429 | ]); | |
| 430 | const current = yield* Ref.get(state); | |
| 431 | return buildResourceTelemetryHistory({ | |
| 432 | readAt, | |
| 433 | windowMs: normalizedInput.windowMs, | |
| 434 | bucketMs: normalizedInput.bucketMs, | |
| 435 | sampleIntervalMs: nativeHealth.sampleIntervalMs, | |
| 436 | serverPid: process.pid, | |
| 437 | sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), | |
| 438 | desktopSnapshot: current.desktopSnapshot, | |
| 439 | snapshots: Result.isSuccess(historyResult) ? historyResult.success : [], | |
| 440 | health: buildHealth({ | |
| 441 | native: nativeHealth, | |
| 442 | desktop: desktopHealth, | |
| 443 | nativeSnapshot: current.nativeSnapshot, | |
| 444 | }), | |
| 445 | }); | |
| 446 | }); | |
| 447 | yield* nativeHealthSubscription.changes.pipe( | |
| 448 | Stream.runForEach(() => refreshHealth), | |
| 449 | Effect.forkScoped, | |
| 450 | ); | |
| 451 | yield* desktopHealthSubscription.changes.pipe( | |
| 452 | Stream.runForEach(() => refreshHealth), | |
| 453 | Effect.forkScoped, | |
| 454 | ); | |
| 455 | ||
| 456 | const refresh: ResourceTelemetry["Service"]["refresh"] = nativeClient.sampleNow.pipe( | |
| 457 | Effect.flatMap(ingestNative), | |
| 458 | Effect.mapError( | |
| 459 | (cause) => | |
| 460 | new ResourceTelemetryRefreshFailed({ | |
| 461 | operation: "refresh", | |
| 462 | cause, | |
| 463 | }), | |
| 464 | ), | |
| 465 | ); | |
| 466 | ||
| 467 | const validateProcessIdentity: ResourceTelemetry["Service"]["validateProcessIdentity"] = ( | |
| 468 | identity, | |
| 469 | ) => | |
| 470 | nativeClient.sampleNow.pipe( | |
| 471 | Effect.map(({ snapshot }) => | |
| 472 | snapshot.processes.some( | |
| 473 | (process) => process.pid === identity.pid && process.startTimeMs === identity.startTimeMs, | |
| 474 | ), | |
| 475 | ), | |
| 476 | Effect.mapError( | |
| 477 | (cause) => | |
| 478 | new ResourceTelemetryRefreshFailed({ | |
| 479 | operation: "validateProcessIdentity", | |
| 480 | cause, | |
| 481 | }), | |
| 482 | ), | |
| 483 | ); | |
| 484 | ||
| 485 | return ResourceTelemetry.of({ | |
| 486 | latest, | |
| 487 | changes: liveChanges, | |
| 488 | subscribe, | |
| 489 | readHistory, | |
| 490 | refresh, | |
| 491 | validateProcessIdentity, | |
| 492 | retry: nativeClient.retry.pipe( | |
| 493 | Effect.zip(Ref.get(state)), | |
| 494 | Effect.map( | |
| 495 | ([accepted, current]): ResourceTelemetryRetryResult => ({ | |
| 496 | accepted, | |
| 497 | snapshot: current.latest, | |
| 498 | }), | |
| 499 | ), | |
| 500 | ), | |
| 501 | }); | |
| 502 | }); | |
| 503 | ||
| 504 | export const layer = Layer.effect(ResourceTelemetry, make()); |
apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts created+357| ... | ... | @@ -0,0 +1,357 @@ |
| 1 | import type { | |
| 2 | DesktopHostTelemetrySnapshot, | |
| 3 | ResourceMonitorProcessSample, | |
| 4 | ResourceMonitorSnapshotEvent, | |
| 5 | ResourceTelemetryHealth, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { describe, expect, it } from "@effect/vitest"; | |
| 8 | import * as DateTime from "effect/DateTime"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | ||
| 11 | import { | |
| 12 | buildResourceTelemetryHistory, | |
| 13 | normalizeResourceTelemetryHistoryInput, | |
| 14 | } from "./ResourceTelemetryHistory.ts"; | |
| 15 | ||
| 16 | const SERVER_PID = 100; | |
| 17 | const ELECTRON_PID = 200; | |
| 18 | const CHILD_PID = 300; | |
| 19 | const STARTED_AT_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z")); | |
| 20 | ||
| 21 | function processSample( | |
| 22 | input: Partial<ResourceMonitorProcessSample> & | |
| 23 | Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">, | |
| 24 | ): ResourceMonitorProcessSample { | |
| 25 | return { | |
| 26 | runTimeMs: 1_000, | |
| 27 | name: `process-${input.pid}`, | |
| 28 | command: `process-${input.pid}`, | |
| 29 | status: "Running", | |
| 30 | cpuPercent: 0, | |
| 31 | cpuTimeMs: 0, | |
| 32 | residentBytes: 1_024, | |
| 33 | virtualBytes: 2_048, | |
| 34 | ioReadBytes: 0, | |
| 35 | ioWriteBytes: 0, | |
| 36 | ioSemantics: "storage", | |
| 37 | ...input, | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | function snapshot( | |
| 42 | sequence: number, | |
| 43 | sampledAtUnixMs: number, | |
| 44 | childCpuTimeMs: number, | |
| 45 | childWriteBytes: number, | |
| 46 | ): ResourceMonitorSnapshotEvent { | |
| 47 | const processes = [ | |
| 48 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 10 }), | |
| 49 | processSample({ | |
| 50 | pid: ELECTRON_PID, | |
| 51 | ppid: 1, | |
| 52 | startTimeMs: 20, | |
| 53 | name: "electron", | |
| 54 | command: "electron", | |
| 55 | }), | |
| 56 | processSample({ | |
| 57 | pid: CHILD_PID, | |
| 58 | ppid: SERVER_PID, | |
| 59 | startTimeMs: 30, | |
| 60 | name: "codex", | |
| 61 | command: "codex app-server", | |
| 62 | cpuTimeMs: childCpuTimeMs, | |
| 63 | ioWriteBytes: childWriteBytes, | |
| 64 | }), | |
| 65 | ]; | |
| 66 | return { | |
| 67 | version: 2, | |
| 68 | type: "snapshot", | |
| 69 | sequence, | |
| 70 | sampledAtUnixMs, | |
| 71 | collectionDurationMicros: 100, | |
| 72 | scannedProcessCount: processes.length, | |
| 73 | retainedProcessCount: processes.length, | |
| 74 | inaccessibleProcessCount: 0, | |
| 75 | processes, | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 79 | const health: ResourceTelemetryHealth = { | |
| 80 | native: { | |
| 81 | status: "healthy", | |
| 82 | lastSampleAt: Option.none(), | |
| 83 | lastError: Option.none(), | |
| 84 | }, | |
| 85 | desktop: { | |
| 86 | status: "healthy", | |
| 87 | lastSampleAt: Option.none(), | |
| 88 | lastError: Option.none(), | |
| 89 | }, | |
| 90 | sidecarVersion: Option.some("0.1.0"), | |
| 91 | sidecarPid: Option.some(400), | |
| 92 | restartCount: 0, | |
| 93 | collectionDurationMicros: 100, | |
| 94 | scannedProcessCount: 3, | |
| 95 | retainedProcessCount: 3, | |
| 96 | inaccessibleProcessCount: 0, | |
| 97 | }; | |
| 98 | ||
| 99 | function desktopSnapshot(): DesktopHostTelemetrySnapshot { | |
| 100 | const sampledAt = DateTime.makeUnsafe(STARTED_AT_MS + 1_000); | |
| 101 | return { | |
| 102 | version: 1, | |
| 103 | type: "desktopTelemetry", | |
| 104 | sequence: 1, | |
| 105 | sampledAtUnixMs: STARTED_AT_MS + 1_000, | |
| 106 | electronPid: ELECTRON_PID, | |
| 107 | power: { | |
| 108 | source: "electron-main", | |
| 109 | idle: "false", | |
| 110 | idleSeconds: 0, | |
| 111 | locked: "false", | |
| 112 | suspended: false, | |
| 113 | onBattery: "false", | |
| 114 | lowPowerMode: "unknown", | |
| 115 | thermalState: "nominal", | |
| 116 | stale: false, | |
| 117 | updatedAt: sampledAt, | |
| 118 | }, | |
| 119 | speedLimitPercent: Option.none(), | |
| 120 | electronProcesses: [ | |
| 121 | { | |
| 122 | pid: ELECTRON_PID, | |
| 123 | creationTimeMs: 20, | |
| 124 | type: "Browser", | |
| 125 | cpuPercent: 999, | |
| 126 | idleWakeupsPerSecond: 999, | |
| 127 | workingSetBytes: 999_999, | |
| 128 | peakWorkingSetBytes: 999_999, | |
| 129 | }, | |
| 130 | ], | |
| 131 | }; | |
| 132 | } | |
| 133 | ||
| 134 | describe("buildResourceTelemetryHistory", () => { | |
| 135 | it("normalizes query bounds before requesting native history", () => { | |
| 136 | expect(normalizeResourceTelemetryHistoryInput({ windowMs: 0, bucketMs: 0 })).toEqual({ | |
| 137 | windowMs: 1_000, | |
| 138 | bucketMs: 1_000, | |
| 139 | }); | |
| 140 | }); | |
| 141 | ||
| 142 | it("replays native snapshots on demand without applying current Electron metrics", () => { | |
| 143 | const history = buildResourceTelemetryHistory({ | |
| 144 | readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), | |
| 145 | windowMs: 10_000, | |
| 146 | bucketMs: 10_000, | |
| 147 | sampleIntervalMs: 1_000, | |
| 148 | serverPid: SERVER_PID, | |
| 149 | sidecarPid: Option.some(400), | |
| 150 | desktopSnapshot: Option.some(desktopSnapshot()), | |
| 151 | snapshots: [ | |
| 152 | snapshot(1, STARTED_AT_MS, 100, 1_000), | |
| 153 | snapshot(2, STARTED_AT_MS + 1_000, 350, 5_000), | |
| 154 | ], | |
| 155 | health, | |
| 156 | }); | |
| 157 | ||
| 158 | const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); | |
| 159 | const electron = history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID); | |
| 160 | expect(child?.sampleCount).toBe(2); | |
| 161 | expect(child?.cpuTimeMs).toBe(250); | |
| 162 | expect(child?.ioWriteBytes).toBe(4_000); | |
| 163 | expect(electron?.category).toBe("electron-main"); | |
| 164 | expect(electron?.currentRssBytes).toBe(1_024); | |
| 165 | expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(4_000); | |
| 166 | }); | |
| 167 | ||
| 168 | it("uses observed RSS for the history-window peak instead of the lifetime process peak", () => { | |
| 169 | const first = snapshot(1, STARTED_AT_MS, 100, 1_000); | |
| 170 | const second = snapshot(2, STARTED_AT_MS + 1_000, 200, 2_000); | |
| 171 | const history = buildResourceTelemetryHistory({ | |
| 172 | readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), | |
| 173 | windowMs: 10_000, | |
| 174 | bucketMs: 10_000, | |
| 175 | sampleIntervalMs: 1_000, | |
| 176 | serverPid: SERVER_PID, | |
| 177 | sidecarPid: Option.none(), | |
| 178 | desktopSnapshot: Option.none(), | |
| 179 | snapshots: [ | |
| 180 | { | |
| 181 | ...first, | |
| 182 | processes: first.processes.map((process) => | |
| 183 | process.pid === CHILD_PID | |
| 184 | ? { ...process, residentBytes: 2_000, peakResidentBytes: 50_000 } | |
| 185 | : process, | |
| 186 | ), | |
| 187 | }, | |
| 188 | { | |
| 189 | ...second, | |
| 190 | processes: second.processes.map((process) => | |
| 191 | process.pid === CHILD_PID | |
| 192 | ? { ...process, residentBytes: 3_000, peakResidentBytes: 60_000 } | |
| 193 | : process, | |
| 194 | ), | |
| 195 | }, | |
| 196 | ], | |
| 197 | health, | |
| 198 | }); | |
| 199 | ||
| 200 | expect( | |
| 201 | history.topProcesses.find((process) => process.identity.pid === CHILD_PID)?.peakRssBytes, | |
| 202 | ).toBe(3_000); | |
| 203 | }); | |
| 204 | ||
| 205 | it("retains cumulative baselines while a process is absent from an intermediate sample", () => { | |
| 206 | const first = snapshot(1, STARTED_AT_MS, 100, 1_000); | |
| 207 | const absent = snapshot(2, STARTED_AT_MS + 1_000, 0, 0); | |
| 208 | const returned = snapshot(3, STARTED_AT_MS + 2_000, 350, 5_000); | |
| 209 | const history = buildResourceTelemetryHistory({ | |
| 210 | readAt: DateTime.makeUnsafe(STARTED_AT_MS + 3_000), | |
| 211 | windowMs: 10_000, | |
| 212 | bucketMs: 10_000, | |
| 213 | sampleIntervalMs: 1_000, | |
| 214 | serverPid: SERVER_PID, | |
| 215 | sidecarPid: Option.none(), | |
| 216 | desktopSnapshot: Option.none(), | |
| 217 | snapshots: [ | |
| 218 | first, | |
| 219 | { | |
| 220 | ...absent, | |
| 221 | processes: absent.processes.filter((process) => process.pid !== CHILD_PID), | |
| 222 | }, | |
| 223 | returned, | |
| 224 | ], | |
| 225 | health, | |
| 226 | }); | |
| 227 | ||
| 228 | const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); | |
| 229 | expect(child?.cpuTimeMs).toBe(250); | |
| 230 | expect(child?.ioWriteBytes).toBe(4_000); | |
| 231 | }); | |
| 232 | ||
| 233 | it("uses an exact current Electron identity for slightly older native samples", () => { | |
| 234 | const history = buildResourceTelemetryHistory({ | |
| 235 | readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), | |
| 236 | windowMs: 10_000, | |
| 237 | bucketMs: 10_000, | |
| 238 | sampleIntervalMs: 1_000, | |
| 239 | serverPid: SERVER_PID, | |
| 240 | sidecarPid: Option.none(), | |
| 241 | desktopSnapshot: Option.some(desktopSnapshot()), | |
| 242 | snapshots: [snapshot(1, STARTED_AT_MS, 100, 1_000)], | |
| 243 | health, | |
| 244 | }); | |
| 245 | ||
| 246 | expect( | |
| 247 | history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID)?.category, | |
| 248 | ).toBe("electron-main"); | |
| 249 | }); | |
| 250 | ||
| 251 | it("uses the preceding sample as a baseline without attributing pre-window deltas", () => { | |
| 252 | const readAtMs = STARTED_AT_MS + 10_000; | |
| 253 | const history = buildResourceTelemetryHistory({ | |
| 254 | readAt: DateTime.makeUnsafe(readAtMs), | |
| 255 | windowMs: 5_000, | |
| 256 | bucketMs: 5_000, | |
| 257 | sampleIntervalMs: 1_000, | |
| 258 | serverPid: SERVER_PID, | |
| 259 | sidecarPid: Option.none(), | |
| 260 | desktopSnapshot: Option.none(), | |
| 261 | snapshots: [ | |
| 262 | snapshot(1, STARTED_AT_MS, 100, 1_000), | |
| 263 | snapshot(2, STARTED_AT_MS + 5_000, 600, 6_000), | |
| 264 | snapshot(3, readAtMs + 1_000, 10_000, 20_000), | |
| 265 | ], | |
| 266 | health, | |
| 267 | }); | |
| 268 | ||
| 269 | const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); | |
| 270 | expect(child?.sampleCount).toBe(1); | |
| 271 | expect(child?.cpuTimeMs).toBe(0); | |
| 272 | expect(child?.ioWriteBytes).toBe(0); | |
| 273 | expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(0); | |
| 274 | expect( | |
| 275 | history.buckets.every((bucket) => DateTime.toEpochMillis(bucket.startedAt) <= readAtMs), | |
| 276 | ).toBe(true); | |
| 277 | }); | |
| 278 | ||
| 279 | it("prorates a cumulative delta that crosses the history window boundary", () => { | |
| 280 | const readAtMs = STARTED_AT_MS + 10_000; | |
| 281 | const history = buildResourceTelemetryHistory({ | |
| 282 | readAt: DateTime.makeUnsafe(readAtMs), | |
| 283 | windowMs: 5_000, | |
| 284 | bucketMs: 5_000, | |
| 285 | sampleIntervalMs: 1_000, | |
| 286 | serverPid: SERVER_PID, | |
| 287 | sidecarPid: Option.none(), | |
| 288 | desktopSnapshot: Option.none(), | |
| 289 | snapshots: [ | |
| 290 | snapshot(1, STARTED_AT_MS, 100, 1_000), | |
| 291 | snapshot(2, STARTED_AT_MS + 7_500, 850, 8_500), | |
| 292 | ], | |
| 293 | health, | |
| 294 | }); | |
| 295 | ||
| 296 | const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); | |
| 297 | expect(child?.cpuTimeMs).toBe(250); | |
| 298 | expect(child?.ioWriteBytes).toBe(2_500); | |
| 299 | expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(2_500); | |
| 300 | }); | |
| 301 | ||
| 302 | it("replays the Electron root identity recorded with each native sample", () => { | |
| 303 | const oldElectron = snapshot(1, STARTED_AT_MS, 100, 1_000); | |
| 304 | const restartedElectron = snapshot(2, STARTED_AT_MS + 1_000, 200, 2_000); | |
| 305 | const history = buildResourceTelemetryHistory({ | |
| 306 | readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), | |
| 307 | windowMs: 10_000, | |
| 308 | bucketMs: 10_000, | |
| 309 | sampleIntervalMs: 1_000, | |
| 310 | serverPid: SERVER_PID, | |
| 311 | sidecarPid: Option.none(), | |
| 312 | desktopSnapshot: Option.none(), | |
| 313 | snapshots: [ | |
| 314 | { | |
| 315 | ...oldElectron, | |
| 316 | externalProcesses: [{ pid: ELECTRON_PID, startTimeMs: 20 }], | |
| 317 | }, | |
| 318 | { | |
| 319 | ...restartedElectron, | |
| 320 | externalProcesses: [{ pid: 201, startTimeMs: 40 }], | |
| 321 | processes: [ | |
| 322 | ...restartedElectron.processes.filter((process) => process.pid !== ELECTRON_PID), | |
| 323 | processSample({ | |
| 324 | pid: ELECTRON_PID, | |
| 325 | ppid: SERVER_PID, | |
| 326 | startTimeMs: 999, | |
| 327 | name: "reused", | |
| 328 | command: "unrelated process", | |
| 329 | }), | |
| 330 | processSample({ | |
| 331 | pid: 201, | |
| 332 | ppid: 1, | |
| 333 | startTimeMs: 40, | |
| 334 | name: "electron", | |
| 335 | command: "electron", | |
| 336 | }), | |
| 337 | ], | |
| 338 | }, | |
| 339 | ], | |
| 340 | health, | |
| 341 | }); | |
| 342 | ||
| 343 | expect( | |
| 344 | history.topProcesses.find( | |
| 345 | (process) => process.identity.pid === ELECTRON_PID && process.identity.startTimeMs === 20, | |
| 346 | )?.category, | |
| 347 | ).toBe("electron-main"); | |
| 348 | expect( | |
| 349 | history.topProcesses.find( | |
| 350 | (process) => process.identity.pid === ELECTRON_PID && process.identity.startTimeMs === 999, | |
| 351 | )?.category, | |
| 352 | ).toBe("server-child"); | |
| 353 | expect(history.topProcesses.find((process) => process.identity.pid === 201)?.category).toBe( | |
| 354 | "electron-main", | |
| 355 | ); | |
| 356 | }); | |
| 357 | }); |
apps/server/src/resourceTelemetry/ResourceTelemetryHistory.ts created+290| ... | ... | @@ -0,0 +1,290 @@ |
| 1 | import type { | |
| 2 | DesktopHostTelemetrySnapshot, | |
| 3 | ResourceMonitorSnapshotEvent, | |
| 4 | ResourceTelemetryHealth, | |
| 5 | ResourceTelemetryHistory, | |
| 6 | ResourceTelemetryHistoryBucket, | |
| 7 | ResourceTelemetryProcess, | |
| 8 | ResourceTelemetryProcessSummary, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as DateTime from "effect/DateTime"; | |
| 11 | import * as Option from "effect/Option"; | |
| 12 | ||
| 13 | import { | |
| 14 | emptyTelemetryCounters, | |
| 15 | mergeProcesses, | |
| 16 | processIdentityKey, | |
| 17 | type ProcessState, | |
| 18 | type TelemetryCounters, | |
| 19 | } from "./Model.ts"; | |
| 20 | ||
| 21 | const MAX_HISTORY_WINDOW_MS = 60 * 60_000; | |
| 22 | ||
| 23 | export function normalizeResourceTelemetryHistoryInput(input: { | |
| 24 | readonly windowMs: number; | |
| 25 | readonly bucketMs: number; | |
| 26 | }): { readonly windowMs: number; readonly bucketMs: number } { | |
| 27 | const windowMs = Math.max(1_000, Math.min(MAX_HISTORY_WINDOW_MS, input.windowMs)); | |
| 28 | return { | |
| 29 | windowMs, | |
| 30 | bucketMs: Math.max(1_000, Math.min(windowMs, input.bucketMs)), | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | interface AggregateSample { | |
| 35 | readonly sampledAtMs: number; | |
| 36 | readonly cpuPercent: number; | |
| 37 | readonly rssBytes: number; | |
| 38 | readonly processCount: number; | |
| 39 | readonly ioReadBytes: number; | |
| 40 | readonly ioWriteBytes: number; | |
| 41 | } | |
| 42 | ||
| 43 | interface ProcessSample { | |
| 44 | readonly sampledAtMs: number; | |
| 45 | readonly process: ResourceTelemetryProcess; | |
| 46 | readonly cpuTimeMs: number; | |
| 47 | readonly ioReadBytes: number; | |
| 48 | readonly ioWriteBytes: number; | |
| 49 | } | |
| 50 | ||
| 51 | export interface BuildResourceTelemetryHistoryInput { | |
| 52 | readonly readAt: DateTime.Utc; | |
| 53 | readonly windowMs: number; | |
| 54 | readonly bucketMs: number; | |
| 55 | readonly sampleIntervalMs: number; | |
| 56 | readonly serverPid: number; | |
| 57 | readonly sidecarPid: Option.Option<number>; | |
| 58 | readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>; | |
| 59 | readonly snapshots: ReadonlyArray<ResourceMonitorSnapshotEvent>; | |
| 60 | readonly health: ResourceTelemetryHealth; | |
| 61 | } | |
| 62 | ||
| 63 | export type ResourceTelemetryHistoryWithLegacyBuckets = ResourceTelemetryHistory & { | |
| 64 | readonly legacyBackendBuckets?: ReadonlyArray<ResourceTelemetryHistoryBucket>; | |
| 65 | }; | |
| 66 | ||
| 67 | function summarizeProcesses( | |
| 68 | samples: ReadonlyArray<ProcessSample>, | |
| 69 | ): ReadonlyArray<ResourceTelemetryProcessSummary> { | |
| 70 | const groups = new Map<string, ProcessSample[]>(); | |
| 71 | for (const sample of samples) { | |
| 72 | const identityKey = processIdentityKey( | |
| 73 | sample.process.identity.pid, | |
| 74 | sample.process.identity.startTimeMs, | |
| 75 | ); | |
| 76 | const current = groups.get(identityKey) ?? []; | |
| 77 | current.push(sample); | |
| 78 | groups.set(identityKey, current); | |
| 79 | } | |
| 80 | ||
| 81 | return [...groups.values()] | |
| 82 | .map((processSamples): ResourceTelemetryProcessSummary => { | |
| 83 | const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); | |
| 84 | const first = sorted[0]!; | |
| 85 | const latest = sorted[sorted.length - 1]!; | |
| 86 | const cpuTotal = sorted.reduce((total, sample) => total + sample.process.cpuPercent, 0); | |
| 87 | return { | |
| 88 | identity: latest.process.identity, | |
| 89 | ppid: latest.process.ppid, | |
| 90 | depth: latest.process.depth, | |
| 91 | name: latest.process.name, | |
| 92 | command: latest.process.command, | |
| 93 | category: latest.process.category, | |
| 94 | firstSeenAt: first.process.firstSeenAt, | |
| 95 | lastSeenAt: latest.process.lastSeenAt, | |
| 96 | currentCpuPercent: latest.process.cpuPercent, | |
| 97 | avgCpuPercent: cpuTotal / sorted.length, | |
| 98 | maxCpuPercent: Math.max(...sorted.map((sample) => sample.process.cpuPercent)), | |
| 99 | cpuTimeMs: sorted.reduce((total, sample) => total + sample.cpuTimeMs, 0), | |
| 100 | currentRssBytes: latest.process.residentBytes, | |
| 101 | peakRssBytes: Math.max(...sorted.map((sample) => sample.process.residentBytes)), | |
| 102 | ioReadBytes: sorted.reduce((total, sample) => total + sample.ioReadBytes, 0), | |
| 103 | ioWriteBytes: sorted.reduce((total, sample) => total + sample.ioWriteBytes, 0), | |
| 104 | ioSemantics: latest.process.ioSemantics, | |
| 105 | sampleCount: sorted.length, | |
| 106 | }; | |
| 107 | }) | |
| 108 | .toSorted( | |
| 109 | (left, right) => right.cpuTimeMs - left.cpuTimeMs || right.peakRssBytes - left.peakRssBytes, | |
| 110 | ); | |
| 111 | } | |
| 112 | ||
| 113 | function buildBuckets(input: { | |
| 114 | readonly samples: ReadonlyArray<AggregateSample>; | |
| 115 | readonly nowMs: number; | |
| 116 | readonly windowMs: number; | |
| 117 | readonly bucketMs: number; | |
| 118 | }): ReadonlyArray<ResourceTelemetryHistoryBucket> { | |
| 119 | const windowStartMs = input.nowMs - input.windowMs; | |
| 120 | const buckets: ResourceTelemetryHistoryBucket[] = []; | |
| 121 | for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += input.bucketMs) { | |
| 122 | const endedAtMs = Math.min(input.nowMs, startedAtMs + input.bucketMs); | |
| 123 | const samples = input.samples.filter( | |
| 124 | (sample) => | |
| 125 | sample.sampledAtMs >= startedAtMs && | |
| 126 | (endedAtMs === input.nowMs | |
| 127 | ? sample.sampledAtMs <= endedAtMs | |
| 128 | : sample.sampledAtMs < endedAtMs), | |
| 129 | ); | |
| 130 | const cpuTotal = samples.reduce((total, sample) => total + sample.cpuPercent, 0); | |
| 131 | buckets.push({ | |
| 132 | startedAt: DateTime.makeUnsafe(startedAtMs), | |
| 133 | endedAt: DateTime.makeUnsafe(endedAtMs), | |
| 134 | avgCpuPercent: samples.length === 0 ? 0 : cpuTotal / samples.length, | |
| 135 | maxCpuPercent: | |
| 136 | samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.cpuPercent)), | |
| 137 | maxRssBytes: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.rssBytes)), | |
| 138 | ioReadBytes: samples.reduce((total, sample) => total + sample.ioReadBytes, 0), | |
| 139 | ioWriteBytes: samples.reduce((total, sample) => total + sample.ioWriteBytes, 0), | |
| 140 | maxProcessCount: | |
| 141 | samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.processCount)), | |
| 142 | }); | |
| 143 | } | |
| 144 | return buckets; | |
| 145 | } | |
| 146 | ||
| 147 | export function buildResourceTelemetryHistory( | |
| 148 | input: BuildResourceTelemetryHistoryInput, | |
| 149 | ): ResourceTelemetryHistoryWithLegacyBuckets & { | |
| 150 | readonly legacyBackendBuckets: ReadonlyArray<ResourceTelemetryHistoryBucket>; | |
| 151 | } { | |
| 152 | const readAtMs = DateTime.toEpochMillis(input.readAt); | |
| 153 | const { windowMs, bucketMs } = normalizeResourceTelemetryHistoryInput(input); | |
| 154 | const windowStartMs = readAtMs - windowMs; | |
| 155 | const eligibleSnapshots = input.snapshots | |
| 156 | .filter((snapshot) => snapshot.sampledAtUnixMs <= readAtMs) | |
| 157 | .toSorted((left, right) => left.sampledAtUnixMs - right.sampledAtUnixMs); | |
| 158 | const snapshotsInWindow = eligibleSnapshots.filter( | |
| 159 | (snapshot) => snapshot.sampledAtUnixMs >= windowStartMs, | |
| 160 | ); | |
| 161 | const precedingSnapshot = eligibleSnapshots.findLast( | |
| 162 | (snapshot) => snapshot.sampledAtUnixMs < windowStartMs, | |
| 163 | ); | |
| 164 | const snapshots = precedingSnapshot | |
| 165 | ? [precedingSnapshot, ...snapshotsInWindow] | |
| 166 | : snapshotsInWindow; | |
| 167 | const aggregateSamples: AggregateSample[] = []; | |
| 168 | const legacyBackendAggregateSamples: AggregateSample[] = []; | |
| 169 | const processSamples: ProcessSample[] = []; | |
| 170 | let previous: ReadonlyMap<string, ProcessState> = new Map(); | |
| 171 | let counters: TelemetryCounters = emptyTelemetryCounters(); | |
| 172 | let previousSnapshotAtMs: number | undefined; | |
| 173 | ||
| 174 | for (const snapshot of snapshots) { | |
| 175 | const deltaWindowFraction = | |
| 176 | previousSnapshotAtMs !== undefined && | |
| 177 | previousSnapshotAtMs < windowStartMs && | |
| 178 | snapshot.sampledAtUnixMs > previousSnapshotAtMs | |
| 179 | ? Math.max( | |
| 180 | 0, | |
| 181 | Math.min( | |
| 182 | 1, | |
| 183 | (snapshot.sampledAtUnixMs - windowStartMs) / | |
| 184 | (snapshot.sampledAtUnixMs - previousSnapshotAtMs), | |
| 185 | ), | |
| 186 | ) | |
| 187 | : 1; | |
| 188 | previousSnapshotAtMs = snapshot.sampledAtUnixMs; | |
| 189 | const recordedExternalProcesses = | |
| 190 | snapshot.externalProcesses ?? | |
| 191 | Option.match(input.desktopSnapshot, { | |
| 192 | onNone: () => [], | |
| 193 | onSome: (desktopSnapshot) => [ | |
| 194 | { | |
| 195 | pid: desktopSnapshot.electronPid, | |
| 196 | startTimeMs: desktopSnapshot.electronProcesses.find( | |
| 197 | (metric) => metric.pid === desktopSnapshot.electronPid, | |
| 198 | )?.creationTimeMs, | |
| 199 | }, | |
| 200 | ], | |
| 201 | }); | |
| 202 | const electronRootPids = new Set(recordedExternalProcesses.map((process) => process.pid)); | |
| 203 | const electronRootStartTimes = new Map( | |
| 204 | recordedExternalProcesses.flatMap((process) => | |
| 205 | process.startTimeMs === undefined ? [] : [[process.pid, process.startTimeMs] as const], | |
| 206 | ), | |
| 207 | ); | |
| 208 | const merged = mergeProcesses({ | |
| 209 | serverPid: input.serverPid, | |
| 210 | sidecarPid: input.sidecarPid, | |
| 211 | fallbackSampledAtMs: snapshot.sampledAtUnixMs, | |
| 212 | nativeSnapshot: Option.some(snapshot), | |
| 213 | desktopSnapshot: Option.none(), | |
| 214 | electronRootPids, | |
| 215 | electronRootStartTimes, | |
| 216 | previous, | |
| 217 | counters, | |
| 218 | updatePrevious: true, | |
| 219 | }); | |
| 220 | previous = new Map([...previous, ...merged.previous]); | |
| 221 | counters = merged.counters; | |
| 222 | if (snapshot.sampledAtUnixMs < windowStartMs) { | |
| 223 | continue; | |
| 224 | } | |
| 225 | const deltas = | |
| 226 | deltaWindowFraction === 1 | |
| 227 | ? merged.deltas | |
| 228 | : merged.deltas.map((delta) => ({ | |
| 229 | ...delta, | |
| 230 | cpuTimeMs: Math.round(delta.cpuTimeMs * deltaWindowFraction), | |
| 231 | ioReadBytes: Math.round(delta.ioReadBytes * deltaWindowFraction), | |
| 232 | ioWriteBytes: Math.round(delta.ioWriteBytes * deltaWindowFraction), | |
| 233 | })); | |
| 234 | const deltasByIdentity = new Map( | |
| 235 | deltas.map((processDelta) => [processDelta.identityKey, processDelta]), | |
| 236 | ); | |
| 237 | aggregateSamples.push({ | |
| 238 | sampledAtMs: snapshot.sampledAtUnixMs, | |
| 239 | cpuPercent: merged.groups.allT3.currentCpuPercent, | |
| 240 | rssBytes: merged.groups.allT3.currentRssBytes, | |
| 241 | processCount: merged.groups.allT3.processCount, | |
| 242 | ioReadBytes: deltas.reduce((total, process) => total + process.ioReadBytes, 0), | |
| 243 | ioWriteBytes: deltas.reduce((total, process) => total + process.ioWriteBytes, 0), | |
| 244 | }); | |
| 245 | const backendDeltas = deltas.filter( | |
| 246 | (processDelta) => | |
| 247 | processDelta.category === "server" || | |
| 248 | processDelta.category === "server-child" || | |
| 249 | processDelta.category === "provider-root" || | |
| 250 | processDelta.category === "terminal-root", | |
| 251 | ); | |
| 252 | legacyBackendAggregateSamples.push({ | |
| 253 | sampledAtMs: snapshot.sampledAtUnixMs, | |
| 254 | cpuPercent: merged.groups.backend.currentCpuPercent, | |
| 255 | rssBytes: merged.groups.backend.currentRssBytes, | |
| 256 | processCount: merged.groups.backend.processCount, | |
| 257 | ioReadBytes: backendDeltas.reduce((total, process) => total + process.ioReadBytes, 0), | |
| 258 | ioWriteBytes: backendDeltas.reduce((total, process) => total + process.ioWriteBytes, 0), | |
| 259 | }); | |
| 260 | for (const process of merged.processes) { | |
| 261 | const processDelta = deltasByIdentity.get( | |
| 262 | processIdentityKey(process.identity.pid, process.identity.startTimeMs), | |
| 263 | ); | |
| 264 | processSamples.push({ | |
| 265 | sampledAtMs: snapshot.sampledAtUnixMs, | |
| 266 | process, | |
| 267 | cpuTimeMs: processDelta?.cpuTimeMs ?? 0, | |
| 268 | ioReadBytes: processDelta?.ioReadBytes ?? 0, | |
| 269 | ioWriteBytes: processDelta?.ioWriteBytes ?? 0, | |
| 270 | }); | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | return { | |
| 275 | readAt: input.readAt, | |
| 276 | windowMs, | |
| 277 | bucketMs, | |
| 278 | sampleIntervalMs: input.sampleIntervalMs, | |
| 279 | retainedSampleCount: aggregateSamples.length + processSamples.length, | |
| 280 | buckets: buildBuckets({ samples: aggregateSamples, nowMs: readAtMs, windowMs, bucketMs }), | |
| 281 | legacyBackendBuckets: buildBuckets({ | |
| 282 | samples: legacyBackendAggregateSamples, | |
| 283 | nowMs: readAtMs, | |
| 284 | windowMs, | |
| 285 | bucketMs, | |
| 286 | }), | |
| 287 | topProcesses: summarizeProcesses(processSamples), | |
| 288 | health: input.health, | |
| 289 | }; | |
| 290 | } |
apps/server/src/server.test.ts+88| ... | ... | @@ -71,6 +71,7 @@ import { vi } from "vite-plus/test"; |
| 71 | 71 | |
| 72 | 72 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 73 | 73 | |
| 74 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | |
| 74 | 75 | import * as ServerConfig from "./config.ts"; |
| 75 | 76 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; |
| 76 | 77 | import { makeRoutesLayer } from "./server.ts"; |
| ... | ... | @@ -116,6 +117,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| 116 | 117 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 117 | 118 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 118 | 119 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 120 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; | |
| 121 | import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; | |
| 122 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; | |
| 123 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | |
| 119 | 124 | import * as Data from "effect/Data"; |
| 120 | 125 | |
| 121 | 126 | const defaultProjectId = ProjectId.make("project-default"); |
| ... | ... | @@ -355,6 +360,10 @@ const buildAppUnderTest = (options?: { |
| 355 | 360 | >; |
| 356 | 361 | relayClient?: Partial<RelayClient.RelayClient["Service"]>; |
| 357 | 362 | cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManager["Service"]>; |
| 363 | nativeTelemetryClient?: Partial<NativeTelemetryClient.NativeTelemetryClient["Service"]>; | |
| 364 | desktopTelemetryReceiver?: Partial< | |
| 365 | DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"] | |
| 366 | >; | |
| 358 | 367 | }; |
| 359 | 368 | }) => |
| 360 | 369 | Effect.gen(function* () { |
| ... | ... | @@ -531,6 +540,15 @@ const buildAppUnderTest = (options?: { |
| 531 | 540 | ...options.layers.vcsStatusBroadcaster, |
| 532 | 541 | }) |
| 533 | 542 | : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); |
| 543 | const resourceTelemetryLayer = ResourceTelemetry.layer.pipe( | |
| 544 | Layer.provide( | |
| 545 | Layer.mergeAll( | |
| 546 | NativeTelemetryClient.layerTest(options?.layers?.nativeTelemetryClient), | |
| 547 | DesktopTelemetryReceiver.layerTest(options?.layers?.desktopTelemetryReceiver), | |
| 548 | ResourceAttribution.layer, | |
| 549 | ), | |
| 550 | ), | |
| 551 | ); | |
| 534 | 552 | |
| 535 | 553 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { |
| 536 | 554 | disableListenLog: true, |
| ... | ... | @@ -745,6 +763,7 @@ const buildAppUnderTest = (options?: { |
| 745 | 763 | ); |
| 746 | 764 | |
| 747 | 765 | const appLayer = servedRoutesLayer.pipe( |
| 766 | Layer.provide(resourceTelemetryLayer), | |
| 748 | 767 | Layer.provide( |
| 749 | 768 | Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ |
| 750 | 769 | record: () => Effect.void, |
| ... | ... | @@ -767,6 +786,58 @@ const buildAppUnderTest = (options?: { |
| 767 | 786 | ...options?.layers?.serverRuntimeStartup, |
| 768 | 787 | }), |
| 769 | 788 | ), |
| 789 | Layer.provide( | |
| 790 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 791 | reportClientActivity: () => Effect.void, | |
| 792 | removeRpcClient: () => Effect.void, | |
| 793 | reportHostPowerState: () => Effect.void, | |
| 794 | snapshot: Effect.succeed({ | |
| 795 | hostPower: { | |
| 796 | source: "unknown", | |
| 797 | idle: "unknown", | |
| 798 | idleSeconds: null, | |
| 799 | locked: "unknown", | |
| 800 | suspended: false, | |
| 801 | onBattery: "unknown", | |
| 802 | lowPowerMode: "unknown", | |
| 803 | thermalState: "unknown", | |
| 804 | stale: true, | |
| 805 | updatedAt: TEST_EPOCH, | |
| 806 | }, | |
| 807 | leases: [], | |
| 808 | activeForegroundLeaseCount: 0, | |
| 809 | activeScopeKeys: [], | |
| 810 | shouldRunOpportunisticWork: false, | |
| 811 | updatedAt: TEST_EPOCH, | |
| 812 | }), | |
| 813 | streamChanges: Stream.empty, | |
| 814 | subscribe: Effect.succeed({ | |
| 815 | latest: { | |
| 816 | hostPower: { | |
| 817 | source: "unknown", | |
| 818 | idle: "unknown", | |
| 819 | idleSeconds: null, | |
| 820 | locked: "unknown", | |
| 821 | suspended: false, | |
| 822 | onBattery: "unknown", | |
| 823 | lowPowerMode: "unknown", | |
| 824 | thermalState: "unknown", | |
| 825 | stale: true, | |
| 826 | updatedAt: TEST_EPOCH, | |
| 827 | }, | |
| 828 | leases: [], | |
| 829 | activeForegroundLeaseCount: 0, | |
| 830 | activeScopeKeys: [], | |
| 831 | shouldRunOpportunisticWork: false, | |
| 832 | updatedAt: TEST_EPOCH, | |
| 833 | }, | |
| 834 | changes: Stream.empty, | |
| 835 | }), | |
| 836 | hasDemand: () => Effect.succeed(false), | |
| 837 | shouldRunScopeWork: () => Effect.succeed(false), | |
| 838 | shouldRunOpportunisticWork: Effect.succeed(false), | |
| 839 | }), | |
| 840 | ), | |
| 770 | 841 | Layer.provide( |
| 771 | 842 | Layer.mock(ServerEnvironment.ServerEnvironment)({ |
| 772 | 843 | getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), |
| ... | ... | @@ -4395,6 +4466,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4395 | 4466 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4396 | 4467 | ); |
| 4397 | 4468 | |
| 4469 | it.effect("routes websocket resource telemetry through the subscription", () => | |
| 4470 | Effect.gen(function* () { | |
| 4471 | yield* buildAppUnderTest(); | |
| 4472 | ||
| 4473 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 4474 | const snapshot = yield* Effect.scoped( | |
| 4475 | withWsRpcClient(wsUrl, (client) => | |
| 4476 | client[WS_METHODS.subscribeResourceTelemetry]({}).pipe(Stream.runHead), | |
| 4477 | ), | |
| 4478 | ); | |
| 4479 | ||
| 4480 | assertTrue(Option.isSome(snapshot)); | |
| 4481 | assert.equal(snapshot.value.processes.length, 0); | |
| 4482 | assert.equal(snapshot.value.groups.backend.processCount, 0); | |
| 4483 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 4484 | ); | |
| 4485 | ||
| 4398 | 4486 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => |
| 4399 | 4487 | Effect.gen(function* () { |
| 4400 | 4488 | const nextProviders = [ |
apps/server/src/server.ts+46-6| ... | ... | @@ -4,6 +4,8 @@ import * as Layer from "effect/Layer"; |
| 4 | 4 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 5 | 5 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 6 | 6 | |
| 7 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | |
| 8 | import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; | |
| 7 | 9 | import * as ServerConfig from "./config.ts"; |
| 8 | 10 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; |
| 9 | 11 | import { |
| ... | ... | @@ -90,6 +92,11 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 90 | 92 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 91 | 93 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 92 | 94 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 95 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; | |
| 96 | import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; | |
| 97 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; | |
| 98 | import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; | |
| 99 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | |
| 93 | 100 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 94 | 101 | import { |
| 95 | 102 | clearPersistedServerRuntimeState, |
| ... | ... | @@ -106,6 +113,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale" |
| 106 | 113 | // already closes the websocket gracefully. Do not add an artificial drain before |
| 107 | 114 | // those finalizers get a chance to run. |
| 108 | 115 | const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; |
| 116 | const ResourceAttributionLayerLive = ResourceAttribution.layer; | |
| 117 | const ApplicationObservabilityLive = ObservabilityLive.pipe( | |
| 118 | Layer.provideMerge(ResourceAttributionLayerLive), | |
| 119 | ); | |
| 109 | 120 | |
| 110 | 121 | const PtyAdapterLive = Layer.unwrap( |
| 111 | 122 | Effect.gen(function* () { |
| ... | ... | @@ -119,6 +130,35 @@ const PtyAdapterLive = Layer.unwrap( |
| 119 | 130 | }), |
| 120 | 131 | ); |
| 121 | 132 | |
| 133 | const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); | |
| 134 | ||
| 135 | const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( | |
| 136 | Layer.provide(ResourceMonitorBinary.layer), | |
| 137 | ); | |
| 138 | const DesktopTelemetryReceiverLayerLive = DesktopTelemetryReceiver.layer.pipe( | |
| 139 | Layer.provideMerge(ServerSettingsLayerLive), | |
| 140 | ); | |
| 141 | ||
| 142 | const ResourceTelemetryLayerLive = ResourceTelemetry.layer.pipe( | |
| 143 | Layer.provideMerge(NativeTelemetryLayerLive), | |
| 144 | Layer.provideMerge(DesktopTelemetryReceiverLayerLive), | |
| 145 | ); | |
| 146 | ||
| 147 | const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe( | |
| 148 | Layer.provide(DesktopTelemetryReceiverLayerLive), | |
| 149 | ); | |
| 150 | ||
| 151 | const BackgroundLayerLive = BackgroundPolicy.layer.pipe( | |
| 152 | Layer.provide(HostPowerMonitorLayerLive), | |
| 153 | Layer.provideMerge(ServerSettingsLayerLive), | |
| 154 | ); | |
| 155 | ||
| 156 | const ResourceDiagnosticsLayerLive = Layer.mergeAll( | |
| 157 | ResourceTelemetryLayerLive, | |
| 158 | ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), | |
| 159 | ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), | |
| 160 | ); | |
| 161 | ||
| 122 | 162 | const RelayClientLive = Layer.unwrap( |
| 123 | 163 | Effect.gen(function* () { |
| 124 | 164 | const config = yield* ServerConfig.ServerConfig; |
| ... | ... | @@ -184,7 +224,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( |
| 184 | 224 | // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter |
| 185 | 225 | // by looking up the default `ProviderInstance` per driver in the instance |
| 186 | 226 | // registry. Adapter construction itself moved inside each driver's |
| 187 | // `create()`; `ProviderEventLoggersLive` owns the shared native/canonical | |
| 227 | // `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical | |
| 188 | 228 | // NDJSON writers and is provided at the outer runtime layer so both |
| 189 | 229 | // `ProviderService` and the per-instance drivers read the same logger pair. |
| 190 | 230 | const ProviderLayerLive = ProviderServiceLive.pipe( |
| ... | ... | @@ -298,6 +338,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| 298 | 338 | |
| 299 | 339 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 300 | 340 | // Core Services |
| 341 | Layer.provideMerge(ServerSettingsLayerLive), | |
| 301 | 342 | Layer.provideMerge(CheckpointingLayerLive), |
| 302 | 343 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 303 | 344 | Layer.provideMerge(GitLayerLive), |
| ... | ... | @@ -318,14 +359,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 318 | 359 | // `ProviderService` (canonical stream, written after event normalization). |
| 319 | 360 | // Provided once at the runtime level so every consumer sees the same |
| 320 | 361 | // logger instances. |
| 321 | Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive), | |
| 362 | Layer.provideMerge(ProviderEventLoggers.layer), | |
| 322 | 363 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old |
| 323 | 364 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but |
| 324 | 365 | // the rewritten registry reads snapshots off the instance registry and |
| 325 | 366 | // no longer transitively provides it. Exposing it at the runtime level |
| 326 | 367 | // keeps a single Live for all opencode consumers. |
| 327 | 368 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 328 | Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), | |
| 329 | 369 | Layer.provideMerge(WorkspaceLayerLive), |
| 330 | 370 | Layer.provideMerge(ProjectFaviconResolverLayerLive), |
| 331 | 371 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| ... | ... | @@ -345,8 +385,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 345 | 385 | |
| 346 | 386 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( |
| 347 | 387 | // Misc. |
| 348 | Layer.provideMerge(ProcessDiagnostics.layer), | |
| 349 | Layer.provideMerge(ProcessResourceMonitor.layer), | |
| 388 | Layer.provideMerge(BackgroundLayerLive), | |
| 389 | Layer.provideMerge(ResourceDiagnosticsLayerLive), | |
| 350 | 390 | Layer.provideMerge(TraceDiagnostics.layer), |
| 351 | 391 | Layer.provideMerge(AnalyticsService.layer), |
| 352 | 392 | Layer.provideMerge(ExternalLauncher.layer), |
| ... | ... | @@ -522,7 +562,7 @@ export const makeServerLayer = Layer.unwrap( |
| 522 | 562 | Layer.provideMerge(serverRelayBrokerTracingLayer), |
| 523 | 563 | Layer.provideMerge(HttpResponseCompressionLive), |
| 524 | 564 | Layer.provideMerge(HttpServerLive), |
| 525 | Layer.provide(ObservabilityLive), | |
| 565 | Layer.provide(ApplicationObservabilityLive), | |
| 526 | 566 | Layer.provideMerge(FetchHttpClient.layer), |
| 527 | 567 | Layer.provideMerge(VcsProcess.layer), |
| 528 | 568 | Layer.provideMerge(PlatformServicesLive), |
apps/server/src/serverSettings.test.ts+33| ... | ... | @@ -12,8 +12,10 @@ import * as Effect from "effect/Effect"; |
| 12 | 12 | import * as Duration from "effect/Duration"; |
| 13 | 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; | |
| 15 | 16 | import * as PlatformError from "effect/PlatformError"; |
| 16 | 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Stream from "effect/Stream"; | |
| 17 | 19 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 18 | 20 | import * as ServerConfig from "./config.ts"; |
| 19 | 21 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| ... | ... | @@ -203,6 +205,29 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 203 | 205 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 204 | 206 | ); |
| 205 | 207 | |
| 208 | it.effect("buffers changes after a subscription is acquired but before it is consumed", () => | |
| 209 | Effect.scoped( | |
| 210 | Effect.gen(function* () { | |
| 211 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 212 | const changes = yield* serverSettings.subscribeChanges; | |
| 213 | ||
| 214 | yield* serverSettings.updateSettings({ | |
| 215 | providers: { | |
| 216 | codex: { | |
| 217 | binaryPath: "/usr/local/bin/codex-next", | |
| 218 | }, | |
| 219 | }, | |
| 220 | }); | |
| 221 | ||
| 222 | const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); | |
| 223 | assert.equal( | |
| 224 | Option.getOrUndefined(firstChange)?.providers.codex.binaryPath, | |
| 225 | "/usr/local/bin/codex-next", | |
| 226 | ); | |
| 227 | }), | |
| 228 | ).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 229 | ); | |
| 230 | ||
| 206 | 231 | it.effect("preserves model when switching providers via textGenerationModelSelection", () => |
| 207 | 232 | Effect.gen(function* () { |
| 208 | 233 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| ... | ... | @@ -590,6 +615,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 590 | 615 | serverPassword: "secret-password", |
| 591 | 616 | }, |
| 592 | 617 | }, |
| 618 | backgroundActivity: { | |
| 619 | schemaVersion: 1, | |
| 620 | profile: "custom", | |
| 621 | baseProfile: "balanced", | |
| 622 | overrides: { | |
| 623 | automaticGitFetchInterval: 10_000, | |
| 624 | }, | |
| 625 | }, | |
| 593 | 626 | automaticGitFetchInterval: 10_000, |
| 594 | 627 | }); |
| 595 | 628 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
apps/server/src/serverSettings.ts+37-15| ... | ... | @@ -131,6 +131,13 @@ export class ServerSettingsService extends Context.Service< |
| 131 | 131 | |
| 132 | 132 | /** Stream of settings change events. */ |
| 133 | 133 | readonly streamChanges: Stream.Stream<ServerSettings>; |
| 134 | ||
| 135 | /** | |
| 136 | * Acquire a settings change subscription synchronously in the current | |
| 137 | * fiber. Use this before reading a snapshot when changes between the | |
| 138 | * snapshot and a lazily started stream must not be lost. | |
| 139 | */ | |
| 140 | readonly subscribeChanges: Effect.Effect<Stream.Stream<ServerSettings>, never, Scope.Scope>; | |
| 134 | 141 | } |
| 135 | 142 | >()("t3/serverSettings/ServerSettingsService") { |
| 136 | 143 | /** @deprecated Import and use `layerTest` from this module. */ |
| ... | ... | @@ -139,13 +146,17 @@ export class ServerSettingsService extends Context.Service< |
| 139 | 146 | |
| 140 | 147 | const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 141 | 148 | Effect.gen(function* () { |
| 142 | const { automaticGitFetchInterval, ...overridesForMerge } = overrides; | |
| 149 | const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = | |
| 150 | overrides; | |
| 143 | 151 | const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); |
| 144 | 152 | const initialSettings = yield* normalizeServerSettings({ |
| 145 | 153 | ...merged, |
| 146 | 154 | ...(automaticGitFetchInterval !== undefined |
| 147 | 155 | ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } |
| 148 | 156 | : {}), |
| 157 | ...(providerHealthRefreshInterval !== undefined | |
| 158 | ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } | |
| 159 | : {}), | |
| 149 | 160 | }); |
| 150 | 161 | const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings); |
| 151 | 162 | |
| ... | ... | @@ -161,6 +172,7 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 161 | 172 | Effect.map(resolveTextGenerationProvider), |
| 162 | 173 | ), |
| 163 | 174 | streamChanges: Stream.empty, |
| 175 | subscribeChanges: Effect.succeed(Stream.empty), | |
| 164 | 176 | } satisfies ServerSettingsService["Service"]; |
| 165 | 177 | }); |
| 166 | 178 | |
| ... | ... | @@ -197,7 +209,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 197 | 209 | |
| 198 | 210 | // Values under these keys are compared as a whole — never stripped field-by-field. |
| 199 | 211 | const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ |
| 212 | "backgroundActivity", | |
| 200 | 213 | "automaticGitFetchInterval", |
| 214 | "providerHealthRefreshInterval", | |
| 201 | 215 | "sourceControlWriterModelSelection", |
| 202 | 216 | "textGenerationModelSelection", |
| 203 | 217 | ]); |
| ... | ... | @@ -344,6 +358,23 @@ const make = Effect.gen(function* () { |
| 344 | 358 | }; |
| 345 | 359 | }); |
| 346 | 360 | |
| 361 | const materializeChanges = (changes: Stream.Stream<ServerSettings>) => | |
| 362 | changes.pipe( | |
| 363 | Stream.mapEffect((settings) => | |
| 364 | materializeProviderEnvironmentSecrets(settings).pipe( | |
| 365 | Effect.catch((error: ServerSettingsError) => | |
| 366 | Effect.logWarning("failed to materialize provider environment secrets", { | |
| 367 | operation: error.operation, | |
| 368 | providerInstanceId: error.providerInstanceId, | |
| 369 | environmentVariable: error.environmentVariable, | |
| 370 | cause: error.cause, | |
| 371 | }).pipe(Effect.as(settings)), | |
| 372 | ), | |
| 373 | ), | |
| 374 | ), | |
| 375 | Stream.map(resolveTextGenerationProvider), | |
| 376 | ); | |
| 377 | ||
| 347 | 378 | const persistProviderEnvironmentSecrets = ( |
| 348 | 379 | current: ServerSettings, |
| 349 | 380 | next: ServerSettings, |
| ... | ... | @@ -561,20 +592,11 @@ const make = Effect.gen(function* () { |
| 561 | 592 | }), |
| 562 | 593 | ), |
| 563 | 594 | get streamChanges() { |
| 564 | return Stream.fromPubSub(changesPubSub).pipe( | |
| 565 | Stream.mapEffect((settings) => | |
| 566 | materializeProviderEnvironmentSecrets(settings).pipe( | |
| 567 | Effect.catch((error: ServerSettingsError) => | |
| 568 | Effect.logWarning("failed to materialize provider environment secrets", { | |
| 569 | operation: error.operation, | |
| 570 | providerInstanceId: error.providerInstanceId, | |
| 571 | environmentVariable: error.environmentVariable, | |
| 572 | cause: error.cause, | |
| 573 | }).pipe(Effect.as(settings)), | |
| 574 | ), | |
| 575 | ), | |
| 576 | ), | |
| 577 | Stream.map(resolveTextGenerationProvider), | |
| 595 | return materializeChanges(Stream.fromPubSub(changesPubSub)); | |
| 596 | }, | |
| 597 | get subscribeChanges() { | |
| 598 | return PubSub.subscribe(changesPubSub).pipe( | |
| 599 | Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))), | |
| 578 | 600 | ); |
| 579 | 601 | }, |
| 580 | 602 | } satisfies ServerSettingsService["Service"]; |
apps/server/src/utils/subscribeBeforeSnapshot.test.ts created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Deferred from "effect/Deferred"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as PubSub from "effect/PubSub"; | |
| 7 | import * as Ref from "effect/Ref"; | |
| 8 | import * as Semaphore from "effect/Semaphore"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | ||
| 11 | import { | |
| 12 | subscribeBeforeSnapshot, | |
| 13 | subscribeBeforeSnapshotWithoutMutex, | |
| 14 | } from "./subscribeBeforeSnapshot.ts"; | |
| 15 | ||
| 16 | describe("subscribeBeforeSnapshot", () => { | |
| 17 | it.effect("atomically reads the initial snapshot before receiving later changes", () => | |
| 18 | Effect.scoped( | |
| 19 | Effect.gen(function* () { | |
| 20 | const changes = yield* PubSub.sliding<number>(1); | |
| 21 | const latest = yield* Ref.make(1); | |
| 22 | const mutex = yield* Semaphore.make(1); | |
| 23 | const snapshotStarted = yield* Deferred.make<void>(); | |
| 24 | const finishSnapshot = yield* Deferred.make<void>(); | |
| 25 | const subscriptionFiber = yield* subscribeBeforeSnapshot( | |
| 26 | changes, | |
| 27 | Deferred.succeed(snapshotStarted, undefined).pipe( | |
| 28 | Effect.andThen(Deferred.await(finishSnapshot)), | |
| 29 | Effect.andThen(Ref.get(latest)), | |
| 30 | ), | |
| 31 | mutex, | |
| 32 | ).pipe(Effect.forkChild); | |
| 33 | ||
| 34 | yield* Deferred.await(snapshotStarted); | |
| 35 | const publishFiber = yield* mutex | |
| 36 | .withPermits(1)(Ref.set(latest, 2).pipe(Effect.andThen(PubSub.publish(changes, 2)))) | |
| 37 | .pipe(Effect.forkChild); | |
| 38 | yield* Deferred.succeed(finishSnapshot, undefined); | |
| 39 | ||
| 40 | const subscription = yield* Fiber.join(subscriptionFiber); | |
| 41 | yield* Fiber.join(publishFiber); | |
| 42 | const firstChange = yield* subscription.changes.pipe( | |
| 43 | Stream.runHead, | |
| 44 | Effect.timeout("1 second"), | |
| 45 | ); | |
| 46 | ||
| 47 | expect(subscription.latest).toBe(1); | |
| 48 | expect(firstChange).toEqual(Option.some(2)); | |
| 49 | }), | |
| 50 | ), | |
| 51 | ); | |
| 52 | ||
| 53 | it.effect("subscribes before an uncoordinated snapshot can change", () => | |
| 54 | Effect.scoped( | |
| 55 | Effect.gen(function* () { | |
| 56 | const changes = yield* PubSub.sliding<number>(1); | |
| 57 | const latest = yield* Ref.make(1); | |
| 58 | const snapshotStarted = yield* Deferred.make<void>(); | |
| 59 | const finishSnapshot = yield* Deferred.make<void>(); | |
| 60 | const subscriptionFiber = yield* subscribeBeforeSnapshotWithoutMutex( | |
| 61 | changes, | |
| 62 | Deferred.succeed(snapshotStarted, undefined).pipe( | |
| 63 | Effect.andThen(Deferred.await(finishSnapshot)), | |
| 64 | Effect.andThen(Ref.get(latest)), | |
| 65 | ), | |
| 66 | ).pipe(Effect.forkChild); | |
| 67 | ||
| 68 | yield* Deferred.await(snapshotStarted); | |
| 69 | yield* Ref.set(latest, 2); | |
| 70 | yield* PubSub.publish(changes, 2); | |
| 71 | yield* Deferred.succeed(finishSnapshot, undefined); | |
| 72 | ||
| 73 | const subscription = yield* Fiber.join(subscriptionFiber); | |
| 74 | const firstChange = yield* subscription.changes.pipe( | |
| 75 | Stream.runHead, | |
| 76 | Effect.timeout("1 second"), | |
| 77 | ); | |
| 78 | ||
| 79 | expect(subscription.latest).toBe(2); | |
| 80 | expect(firstChange).toEqual(Option.some(2)); | |
| 81 | }), | |
| 82 | ), | |
| 83 | ); | |
| 84 | }); |
apps/server/src/utils/subscribeBeforeSnapshot.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as PubSub from "effect/PubSub"; | |
| 3 | import * as Semaphore from "effect/Semaphore"; | |
| 4 | import * as Stream from "effect/Stream"; | |
| 5 | ||
| 6 | export interface SnapshotSubscription<A> { | |
| 7 | readonly latest: A; | |
| 8 | readonly changes: Stream.Stream<A>; | |
| 9 | } | |
| 10 | ||
| 11 | export const subscribeBeforeSnapshot = Effect.fn("subscribeBeforeSnapshot")(function* <A, E, R>( | |
| 12 | changes: PubSub.PubSub<A>, | |
| 13 | snapshot: Effect.Effect<A, E, R>, | |
| 14 | mutex: Semaphore.Semaphore, | |
| 15 | ) { | |
| 16 | return yield* mutex.withPermits(1)( | |
| 17 | Effect.gen(function* () { | |
| 18 | const latest = yield* snapshot; | |
| 19 | const subscription = yield* PubSub.subscribe(changes); | |
| 20 | return { | |
| 21 | latest, | |
| 22 | changes: Stream.fromSubscription(subscription), | |
| 23 | } satisfies SnapshotSubscription<A>; | |
| 24 | }), | |
| 25 | ); | |
| 26 | }); | |
| 27 | ||
| 28 | export const subscribeBeforeSnapshotWithoutMutex = Effect.fn("subscribeBeforeSnapshotWithoutMutex")( | |
| 29 | function* <A, E, R>(changes: PubSub.PubSub<A>, snapshot: Effect.Effect<A, E, R>) { | |
| 30 | const subscription = yield* PubSub.subscribe(changes); | |
| 31 | const latest = yield* snapshot; | |
| 32 | return { | |
| 33 | latest, | |
| 34 | changes: Stream.fromSubscription(subscription), | |
| 35 | } satisfies SnapshotSubscription<A>; | |
| 36 | }, | |
| 37 | ); |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+92| ... | ... | @@ -2,6 +2,7 @@ import { assert, it, describe } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as DateTime from "effect/DateTime"; | |
| 5 | 6 | import * as Duration from "effect/Duration"; |
| 6 | 7 | import * as Effect from "effect/Effect"; |
| 7 | 8 | import * as Exit from "effect/Exit"; |
| ... | ... | @@ -14,6 +15,7 @@ import * as Scope from "effect/Scope"; |
| 14 | 15 | import * as Stream from "effect/Stream"; |
| 15 | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 16 | 17 | import type { |
| 18 | BackgroundScope, | |
| 17 | 19 | VcsStatusLocalResult, |
| 18 | 20 | VcsStatusRemoteResult, |
| 19 | 21 | VcsStatusResult, |
| ... | ... | @@ -22,8 +24,11 @@ import type { |
| 22 | 24 | import { GitManagerError } from "@t3tools/contracts"; |
| 23 | 25 | |
| 24 | 26 | import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; |
| 27 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | |
| 25 | 28 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 26 | 29 | |
| 30 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | |
| 31 | ||
| 27 | 32 | const baseLocalStatus: VcsStatusLocalResult = { |
| 28 | 33 | isRepo: true, |
| 29 | 34 | sourceControlProvider: { |
| ... | ... | @@ -73,6 +78,7 @@ function makeTestLayer(state: { |
| 73 | 78 | }) { |
| 74 | 79 | return VcsStatusBroadcaster.layer.pipe( |
| 75 | 80 | Layer.provideMerge(NodeServices.layer), |
| 81 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | |
| 76 | 82 | Layer.provide( |
| 77 | 83 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 78 | 84 | localStatus: () => |
| ... | ... | @@ -104,6 +110,37 @@ function makeTestLayer(state: { |
| 104 | 110 | ); |
| 105 | 111 | } |
| 106 | 112 | |
| 113 | function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) => boolean) { | |
| 114 | return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 115 | reportClientActivity: () => Effect.void, | |
| 116 | removeRpcClient: () => Effect.void, | |
| 117 | reportHostPowerState: () => Effect.void, | |
| 118 | snapshot: Effect.succeed({ | |
| 119 | hostPower: { | |
| 120 | source: "unknown", | |
| 121 | idle: "unknown", | |
| 122 | idleSeconds: null, | |
| 123 | locked: "unknown", | |
| 124 | suspended: false, | |
| 125 | onBattery: "unknown", | |
| 126 | lowPowerMode: "unknown", | |
| 127 | thermalState: "unknown", | |
| 128 | stale: true, | |
| 129 | updatedAt: TEST_EPOCH, | |
| 130 | }, | |
| 131 | leases: [], | |
| 132 | activeForegroundLeaseCount: 0, | |
| 133 | activeScopeKeys: [], | |
| 134 | shouldRunOpportunisticWork: false, | |
| 135 | updatedAt: TEST_EPOCH, | |
| 136 | }), | |
| 137 | streamChanges: Stream.empty, | |
| 138 | hasDemand: () => Effect.succeed(true), | |
| 139 | shouldRunScopeWork: (scope) => Effect.sync(() => shouldRunScopeWork(scope)), | |
| 140 | shouldRunOpportunisticWork: Effect.succeed(true), | |
| 141 | }); | |
| 142 | } | |
| 143 | ||
| 107 | 144 | describe("VcsStatusBroadcaster", () => { |
| 108 | 145 | it.effect("reuses the cached VCS status across repeated reads", () => { |
| 109 | 146 | const state = { |
| ... | ... | @@ -183,6 +220,7 @@ describe("VcsStatusBroadcaster", () => { |
| 183 | 220 | }; |
| 184 | 221 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 185 | 222 | Layer.provideMerge(NodeServices.layer), |
| 223 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | |
| 186 | 224 | Layer.provide( |
| 187 | 225 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 188 | 226 | localStatus: () => |
| ... | ... | @@ -290,6 +328,7 @@ describe("VcsStatusBroadcaster", () => { |
| 290 | 328 | }; |
| 291 | 329 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 292 | 330 | Layer.provideMerge(NodeServices.layer), |
| 331 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | |
| 293 | 332 | Layer.provide( |
| 294 | 333 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 295 | 334 | localStatus: (input) => |
| ... | ... | @@ -453,6 +492,7 @@ describe("VcsStatusBroadcaster", () => { |
| 453 | 492 | let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null; |
| 454 | 493 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 455 | 494 | Layer.provideMerge(NodeServices.layer), |
| 495 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | |
| 456 | 496 | Layer.provide( |
| 457 | 497 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 458 | 498 | localStatus: () => |
| ... | ... | @@ -641,6 +681,57 @@ describe("VcsStatusBroadcaster", () => { |
| 641 | 681 | }); |
| 642 | 682 | }); |
| 643 | 683 | |
| 684 | it.effect("does not start automatic remote refreshes without foreground client demand", () => { | |
| 685 | const state = { | |
| 686 | currentLocalStatus: baseLocalStatus, | |
| 687 | currentRemoteStatus: baseRemoteStatus, | |
| 688 | localStatusCalls: 0, | |
| 689 | remoteStatusCalls: 0, | |
| 690 | localInvalidationCalls: 0, | |
| 691 | remoteInvalidationCalls: 0, | |
| 692 | }; | |
| 693 | const testLayer = VcsStatusBroadcaster.layer.pipe( | |
| 694 | Layer.provideMerge(NodeServices.layer), | |
| 695 | Layer.provide(makeBackgroundPolicyLayer(() => false)), | |
| 696 | Layer.provide( | |
| 697 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | |
| 698 | localStatus: () => | |
| 699 | Effect.sync(() => { | |
| 700 | state.localStatusCalls += 1; | |
| 701 | return state.currentLocalStatus; | |
| 702 | }), | |
| 703 | remoteStatus: () => | |
| 704 | Effect.sync(() => { | |
| 705 | state.remoteStatusCalls += 1; | |
| 706 | return state.currentRemoteStatus; | |
| 707 | }), | |
| 708 | invalidateLocalStatus: () => | |
| 709 | Effect.sync(() => { | |
| 710 | state.localInvalidationCalls += 1; | |
| 711 | }), | |
| 712 | invalidateRemoteStatus: () => | |
| 713 | Effect.sync(() => { | |
| 714 | state.remoteInvalidationCalls += 1; | |
| 715 | }), | |
| 716 | } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>), | |
| 717 | ), | |
| 718 | ); | |
| 719 | ||
| 720 | return Effect.gen(function* () { | |
| 721 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; | |
| 722 | const snapshot = yield* Stream.runHead( | |
| 723 | broadcaster.streamStatus( | |
| 724 | { cwd: "/repo" }, | |
| 725 | { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, | |
| 726 | ), | |
| 727 | ); | |
| 728 | ||
| 729 | assert.isTrue(Option.isSome(snapshot)); | |
| 730 | assert.equal(state.remoteStatusCalls, 0); | |
| 731 | assert.equal(state.remoteInvalidationCalls, 0); | |
| 732 | }).pipe(Effect.provide(testLayer)); | |
| 733 | }); | |
| 734 | ||
| 644 | 735 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { |
| 645 | 736 | const state = { |
| 646 | 737 | currentLocalStatus: baseLocalStatus, |
| ... | ... | @@ -654,6 +745,7 @@ describe("VcsStatusBroadcaster", () => { |
| 654 | 745 | let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; |
| 655 | 746 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 656 | 747 | Layer.provideMerge(NodeServices.layer), |
| 748 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | |
| 657 | 749 | Layer.provide( |
| 658 | 750 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 659 | 751 | localStatus: () => |
apps/server/src/vcs/VcsStatusBroadcaster.ts+82-28| ... | ... | @@ -22,6 +22,7 @@ import type { |
| 22 | 22 | } from "@t3tools/contracts"; |
| 23 | 23 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 24 | 24 | |
| 25 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | |
| 25 | 26 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 26 | 27 | |
| 27 | 28 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); |
| ... | ... | @@ -131,6 +132,7 @@ interface CachedVcsStatus { |
| 131 | 132 | interface ActiveRemotePoller { |
| 132 | 133 | readonly fiber: Fiber.Fiber<void, never>; |
| 133 | 134 | readonly subscriberCount: number; |
| 135 | readonly demandCwds: Ref.Ref<ReadonlyMap<string, number>>; | |
| 134 | 136 | } |
| 135 | 137 | |
| 136 | 138 | interface StreamStatusOptions { |
| ... | ... | @@ -180,6 +182,7 @@ const normalizeCwd = (cwd: string) => |
| 180 | 182 | |
| 181 | 183 | export const make = Effect.gen(function* () { |
| 182 | 184 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 185 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 183 | 186 | const fs = yield* FileSystem.FileSystem; |
| 184 | 187 | const changesPubSub = yield* Effect.acquireRelease( |
| 185 | 188 | PubSub.unbounded<VcsStatusChange>(), |
| ... | ... | @@ -378,6 +381,7 @@ export const make = Effect.gen(function* () { |
| 378 | 381 | |
| 379 | 382 | const makeRemoteRefreshLoop = ( |
| 380 | 383 | cwd: string, |
| 384 | demandCwdsRef: Ref.Ref<ReadonlyMap<string, number>>, | |
| 381 | 385 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, |
| 382 | 386 | refreshImmediately: boolean, |
| 383 | 387 | ) => { |
| ... | ... | @@ -394,6 +398,22 @@ export const make = Effect.gen(function* () { |
| 394 | 398 | return activeInterval; |
| 395 | 399 | } |
| 396 | 400 | |
| 401 | const demandCwds = yield* Ref.get(demandCwdsRef); | |
| 402 | const shouldRun = | |
| 403 | needsInitialRefresh || | |
| 404 | (yield* Effect.all( | |
| 405 | [...demandCwds.keys()].map((demandCwd) => | |
| 406 | backgroundPolicy.shouldRunScopeWork({ | |
| 407 | type: "vcs-status", | |
| 408 | cwd: demandCwd, | |
| 409 | }), | |
| 410 | ), | |
| 411 | { concurrency: "unbounded" }, | |
| 412 | )).some(Boolean); | |
| 413 | if (!shouldRun) { | |
| 414 | return activeInterval; | |
| 415 | } | |
| 416 | ||
| 397 | 417 | const exit = yield* refreshRemoteStatus(cwd, { |
| 398 | 418 | refreshUpstream: !Duration.isZero(configuredInterval), |
| 399 | 419 | }).pipe(Effect.exit); |
| ... | ... | @@ -444,55 +464,88 @@ export const make = Effect.gen(function* () { |
| 444 | 464 | |
| 445 | 465 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( |
| 446 | 466 | cwd: string, |
| 467 | demandCwd: string, | |
| 447 | 468 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, |
| 448 | 469 | refreshImmediately: boolean, |
| 449 | 470 | ) { |
| 450 | 471 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { |
| 451 | 472 | const existing = activePollers.get(cwd); |
| 452 | 473 | if (existing) { |
| 453 | const nextPollers = new Map(activePollers); | |
| 454 | nextPollers.set(cwd, { | |
| 455 | ...existing, | |
| 456 | subscriberCount: existing.subscriberCount + 1, | |
| 457 | }); | |
| 458 | return Effect.succeed([undefined, nextPollers] as const); | |
| 474 | return Ref.update(existing.demandCwds, (demandCwds) => { | |
| 475 | const next = new Map(demandCwds); | |
| 476 | next.set(demandCwd, (next.get(demandCwd) ?? 0) + 1); | |
| 477 | return next; | |
| 478 | }).pipe( | |
| 479 | Effect.map(() => { | |
| 480 | const nextPollers = new Map(activePollers); | |
| 481 | nextPollers.set(cwd, { | |
| 482 | ...existing, | |
| 483 | subscriberCount: existing.subscriberCount + 1, | |
| 484 | }); | |
| 485 | return [undefined, nextPollers] as const; | |
| 486 | }), | |
| 487 | ); | |
| 459 | 488 | } |
| 460 | 489 | |
| 461 | return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( | |
| 462 | Effect.forkIn(broadcasterScope), | |
| 463 | Effect.map((fiber) => { | |
| 464 | const nextPollers = new Map(activePollers); | |
| 465 | nextPollers.set(cwd, { | |
| 466 | fiber, | |
| 467 | subscriberCount: 1, | |
| 468 | }); | |
| 469 | return [undefined, nextPollers] as const; | |
| 470 | }), | |
| 490 | return Ref.make<ReadonlyMap<string, number>>(new Map([[demandCwd, 1]])).pipe( | |
| 491 | Effect.flatMap((demandCwds) => | |
| 492 | makeRemoteRefreshLoop( | |
| 493 | cwd, | |
| 494 | demandCwds, | |
| 495 | automaticRemoteRefreshInterval, | |
| 496 | refreshImmediately, | |
| 497 | ).pipe( | |
| 498 | Effect.forkIn(broadcasterScope), | |
| 499 | Effect.map((fiber) => { | |
| 500 | const nextPollers = new Map(activePollers); | |
| 501 | nextPollers.set(cwd, { | |
| 502 | fiber, | |
| 503 | subscriberCount: 1, | |
| 504 | demandCwds, | |
| 505 | }); | |
| 506 | return [undefined, nextPollers] as const; | |
| 507 | }), | |
| 508 | ), | |
| 509 | ), | |
| 471 | 510 | ); |
| 472 | 511 | }); |
| 473 | 512 | }); |
| 474 | 513 | |
| 475 | 514 | const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( |
| 476 | 515 | cwd: string, |
| 516 | demandCwd: string, | |
| 477 | 517 | ) { |
| 478 | const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { | |
| 518 | const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { | |
| 479 | 519 | const existing = activePollers.get(cwd); |
| 480 | 520 | if (!existing) { |
| 481 | return [null, activePollers] as const; | |
| 521 | return Effect.succeed([null, activePollers] as const); | |
| 482 | 522 | } |
| 483 | 523 | |
| 484 | 524 | if (existing.subscriberCount > 1) { |
| 485 | const nextPollers = new Map(activePollers); | |
| 486 | nextPollers.set(cwd, { | |
| 487 | ...existing, | |
| 488 | subscriberCount: existing.subscriberCount - 1, | |
| 489 | }); | |
| 490 | return [null, nextPollers] as const; | |
| 525 | return Ref.update(existing.demandCwds, (demandCwds) => { | |
| 526 | const nextDemandCwds = new Map(demandCwds); | |
| 527 | const count = nextDemandCwds.get(demandCwd) ?? 0; | |
| 528 | if (count <= 1) { | |
| 529 | nextDemandCwds.delete(demandCwd); | |
| 530 | } else { | |
| 531 | nextDemandCwds.set(demandCwd, count - 1); | |
| 532 | } | |
| 533 | return nextDemandCwds; | |
| 534 | }).pipe( | |
| 535 | Effect.as([ | |
| 536 | null, | |
| 537 | new Map(activePollers).set(cwd, { | |
| 538 | ...existing, | |
| 539 | subscriberCount: existing.subscriberCount - 1, | |
| 540 | }), | |
| 541 | ] as const), | |
| 542 | ); | |
| 491 | 543 | } |
| 492 | 544 | |
| 493 | const nextPollers = new Map(activePollers); | |
| 494 | nextPollers.delete(cwd); | |
| 495 | return [existing.fiber, nextPollers] as const; | |
| 545 | return Effect.succeed([ | |
| 546 | existing.fiber, | |
| 547 | new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)), | |
| 548 | ] as const); | |
| 496 | 549 | }); |
| 497 | 550 | |
| 498 | 551 | if (pollerToInterrupt) { |
| ... | ... | @@ -510,12 +563,13 @@ export const make = Effect.gen(function* () { |
| 510 | 563 | const initialRemote = cachedStatus?.remote?.value ?? null; |
| 511 | 564 | yield* retainRemotePoller( |
| 512 | 565 | cwd, |
| 566 | input.cwd, | |
| 513 | 567 | options?.automaticRemoteRefreshInterval ?? |
| 514 | 568 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), |
| 515 | 569 | cachedStatus?.remote === null || cachedStatus?.remote === undefined, |
| 516 | 570 | ); |
| 517 | 571 | |
| 518 | const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); | |
| 572 | const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); | |
| 519 | 573 | |
| 520 | 574 | return Stream.concat( |
| 521 | 575 | Stream.make({ |
apps/server/src/ws.ts+88-89| ... | ... | @@ -11,12 +11,6 @@ import * as Schema from "effect/Schema"; |
| 11 | 11 | import * as Stream from "effect/Stream"; |
| 12 | 12 | import { |
| 13 | 13 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, |
| 14 | AuthOrchestrationOperateScope, | |
| 15 | AuthOrchestrationReadScope, | |
| 16 | AuthReviewWriteScope, | |
| 17 | AuthRelayWriteScope, | |
| 18 | AuthTerminalOperateScope, | |
| 19 | AuthAccessReadScope, | |
| 20 | 14 | AuthAccessStreamError, |
| 21 | 15 | type AuthAccessStreamEvent, |
| 22 | 16 | type AuthEnvironmentScope, |
| ... | ... | @@ -50,6 +44,7 @@ import { |
| 50 | 44 | FilesystemBrowseError, |
| 51 | 45 | AssetWorkspaceContextNotFoundError, |
| 52 | 46 | AssetWorkspaceContextResolutionError, |
| 47 | RpcClientId, | |
| 53 | 48 | EnvironmentAuthorizationError, |
| 54 | 49 | ThreadId, |
| 55 | 50 | type TerminalAttachStreamEvent, |
| ... | ... | @@ -59,6 +54,7 @@ import { |
| 59 | 54 | WS_METHODS, |
| 60 | 55 | WsRpcGroup, |
| 61 | 56 | } from "@t3tools/contracts"; |
| 57 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 62 | 58 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; |
| 63 | 59 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 64 | 60 | |
| ... | ... | @@ -98,9 +94,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 98 | 94 | import * as ReviewService from "./review/ReviewService.ts"; |
| 99 | 95 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 100 | 96 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 97 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | |
| 101 | 98 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 99 | import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; | |
| 102 | 100 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 103 | 101 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 102 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | |
| 104 | 103 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 105 | 104 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 106 | 105 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| ... | ... | @@ -295,78 +294,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; |
| 295 | 294 | // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). |
| 296 | 295 | const SHELL_RESUME_MAX_GAP = 1_000; |
| 297 | 296 | |
| 298 | const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ | |
| 299 | [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], | |
| 300 | [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], | |
| 301 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], | |
| 302 | [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], | |
| 303 | [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], | |
| 304 | [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], | |
| 305 | [WS_METHODS.serverProbe, AuthOrchestrationReadScope], | |
| 306 | [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], | |
| 307 | [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], | |
| 308 | [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], | |
| 309 | [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], | |
| 310 | [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], | |
| 311 | [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], | |
| 312 | [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], | |
| 313 | [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope], | |
| 314 | [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope], | |
| 315 | [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], | |
| 316 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], | |
| 317 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], | |
| 318 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], | |
| 319 | [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], | |
| 320 | [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], | |
| 321 | [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], | |
| 322 | [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], | |
| 323 | [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], | |
| 324 | [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], | |
| 325 | [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], | |
| 326 | [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], | |
| 327 | [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], | |
| 328 | [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], | |
| 329 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], | |
| 330 | [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], | |
| 331 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], | |
| 332 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], | |
| 333 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], | |
| 334 | [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], | |
| 335 | [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], | |
| 336 | [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], | |
| 337 | [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], | |
| 338 | [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], | |
| 339 | [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], | |
| 340 | [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], | |
| 341 | [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], | |
| 342 | [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], | |
| 343 | [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], | |
| 344 | [WS_METHODS.terminalOpen, AuthTerminalOperateScope], | |
| 345 | [WS_METHODS.terminalAttach, AuthTerminalOperateScope], | |
| 346 | [WS_METHODS.terminalWrite, AuthTerminalOperateScope], | |
| 347 | [WS_METHODS.terminalResize, AuthTerminalOperateScope], | |
| 348 | [WS_METHODS.terminalClear, AuthTerminalOperateScope], | |
| 349 | [WS_METHODS.terminalRestart, AuthTerminalOperateScope], | |
| 350 | [WS_METHODS.terminalClose, AuthTerminalOperateScope], | |
| 351 | [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], | |
| 352 | [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], | |
| 353 | [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], | |
| 354 | [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], | |
| 355 | [WS_METHODS.previewResize, AuthOrchestrationOperateScope], | |
| 356 | [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], | |
| 357 | [WS_METHODS.previewClose, AuthOrchestrationOperateScope], | |
| 358 | [WS_METHODS.previewList, AuthOrchestrationReadScope], | |
| 359 | [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], | |
| 360 | [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], | |
| 361 | [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], | |
| 362 | [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope], | |
| 363 | [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], | |
| 364 | [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], | |
| 365 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], | |
| 366 | [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], | |
| 367 | [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], | |
| 368 | ]); | |
| 369 | ||
| 370 | 297 | function toAuthAccessStreamEvent( |
| 371 | 298 | change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, |
| 372 | 299 | revision: number, |
| ... | ... | @@ -438,10 +365,28 @@ const makeWsRpcLayer = ( |
| 438 | 365 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; |
| 439 | 366 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 440 | 367 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 368 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 369 | const rpcClientIds = yield* Ref.make(new Set<RpcClientId>()); | |
| 370 | yield* Effect.addFinalizer(() => | |
| 371 | Ref.get(rpcClientIds).pipe( | |
| 372 | Effect.flatMap((clientIds) => | |
| 373 | Effect.forEach( | |
| 374 | clientIds, | |
| 375 | (clientId) => backgroundPolicy.removeRpcClient(currentSessionId, clientId), | |
| 376 | { | |
| 377 | discard: true, | |
| 378 | }, | |
| 379 | ), | |
| 380 | ), | |
| 381 | Effect.ignore, | |
| 382 | ), | |
| 383 | ); | |
| 441 | 384 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 442 | 385 | const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; |
| 443 | 386 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( |
| 444 | Effect.map((settings) => settings.automaticGitFetchInterval), | |
| 387 | Effect.map( | |
| 388 | (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, | |
| 389 | ), | |
| 445 | 390 | Effect.catch((cause) => |
| 446 | 391 | Effect.logWarning("Failed to read automatic Git fetch interval setting", { |
| 447 | 392 | detail: cause.message, |
| ... | ... | @@ -454,6 +399,7 @@ const makeWsRpcLayer = ( |
| 454 | 399 | const sessions = yield* SessionStore.SessionStore; |
| 455 | 400 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 456 | 401 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 402 | const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; | |
| 457 | 403 | const relayClient = yield* RelayClient.RelayClient; |
| 458 | 404 | const authorizationError = (requiredScope: AuthEnvironmentScope) => |
| 459 | 405 | new EnvironmentAuthorizationError({ |
| ... | ... | @@ -474,13 +420,6 @@ const makeWsRpcLayer = ( |
| 474 | 420 | currentSession.scopes.includes(requiredScope) |
| 475 | 421 | ? stream |
| 476 | 422 | : Stream.fail(authorizationError(requiredScope)); |
| 477 | const requiredScopeForMethod = (method: string): AuthEnvironmentScope => { | |
| 478 | const requiredScope = RPC_REQUIRED_SCOPE.get(method); | |
| 479 | if (requiredScope === undefined) { | |
| 480 | throw new Error(`RPC method ${method} has no declared authorization scope.`); | |
| 481 | } | |
| 482 | return requiredScope; | |
| 483 | }; | |
| 484 | 423 | const observeRpcEffect = <A, E, R>( |
| 485 | 424 | method: string, |
| 486 | 425 | effect: Effect.Effect<A, E, R>, |
| ... | ... | @@ -488,7 +427,7 @@ const makeWsRpcLayer = ( |
| 488 | 427 | ) => |
| 489 | 428 | instrumentRpcEffect( |
| 490 | 429 | method, |
| 491 | authorizeEffect(requiredScopeForMethod(method), effect), | |
| 430 | authorizeEffect(requiredScopeForRpcMethod(method), effect), | |
| 492 | 431 | traceAttributes, |
| 493 | 432 | ); |
| 494 | 433 | const observeRpcStream = <A, E, R>( |
| ... | ... | @@ -498,7 +437,7 @@ const makeWsRpcLayer = ( |
| 498 | 437 | ) => |
| 499 | 438 | instrumentRpcStream( |
| 500 | 439 | method, |
| 501 | authorizeStream(requiredScopeForMethod(method), stream), | |
| 440 | authorizeStream(requiredScopeForRpcMethod(method), stream), | |
| 502 | 441 | traceAttributes, |
| 503 | 442 | ); |
| 504 | 443 | const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>( |
| ... | ... | @@ -512,7 +451,7 @@ const makeWsRpcLayer = ( |
| 512 | 451 | ) => |
| 513 | 452 | instrumentRpcStreamEffect( |
| 514 | 453 | method, |
| 515 | authorizeEffect(requiredScopeForMethod(method), effect), | |
| 454 | authorizeEffect(requiredScopeForRpcMethod(method), effect), | |
| 516 | 455 | traceAttributes, |
| 517 | 456 | ); |
| 518 | 457 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => |
| ... | ... | @@ -1494,10 +1433,50 @@ const makeWsRpcLayer = ( |
| 1494 | 1433 | "rpc.aggregate": "server", |
| 1495 | 1434 | }, |
| 1496 | 1435 | ), |
| 1436 | [WS_METHODS.serverGetResourceTelemetryHistory]: (input) => | |
| 1437 | observeRpcEffect( | |
| 1438 | WS_METHODS.serverGetResourceTelemetryHistory, | |
| 1439 | resourceTelemetry.readHistory(input), | |
| 1440 | { | |
| 1441 | "rpc.aggregate": "server", | |
| 1442 | }, | |
| 1443 | ), | |
| 1444 | [WS_METHODS.serverRetryResourceTelemetry]: (_input) => | |
| 1445 | observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { | |
| 1446 | "rpc.aggregate": "server", | |
| 1447 | }), | |
| 1497 | 1448 | [WS_METHODS.serverSignalProcess]: (input) => |
| 1498 | 1449 | observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { |
| 1499 | 1450 | "rpc.aggregate": "server", |
| 1500 | 1451 | }), |
| 1452 | [WS_METHODS.serverReportClientActivity]: (input, metadata) => | |
| 1453 | Ref.update(rpcClientIds, (clientIds) => { | |
| 1454 | const next = new Set(clientIds); | |
| 1455 | next.add(RpcClientId.make(metadata.client.id)); | |
| 1456 | return next; | |
| 1457 | }).pipe( | |
| 1458 | Effect.andThen( | |
| 1459 | observeRpcEffect( | |
| 1460 | WS_METHODS.serverReportClientActivity, | |
| 1461 | backgroundPolicy.reportClientActivity( | |
| 1462 | currentSessionId, | |
| 1463 | RpcClientId.make(metadata.client.id), | |
| 1464 | input, | |
| 1465 | ), | |
| 1466 | { "rpc.aggregate": "server" }, | |
| 1467 | ), | |
| 1468 | ), | |
| 1469 | ), | |
| 1470 | [WS_METHODS.serverReportHostPowerState]: (input) => | |
| 1471 | observeRpcEffect( | |
| 1472 | WS_METHODS.serverReportHostPowerState, | |
| 1473 | backgroundPolicy.reportHostPowerState(input), | |
| 1474 | { "rpc.aggregate": "server" }, | |
| 1475 | ), | |
| 1476 | [WS_METHODS.serverGetBackgroundPolicy]: (_input) => | |
| 1477 | observeRpcEffect(WS_METHODS.serverGetBackgroundPolicy, backgroundPolicy.snapshot, { | |
| 1478 | "rpc.aggregate": "server", | |
| 1479 | }), | |
| 1501 | 1480 | [WS_METHODS.cloudGetRelayClientStatus]: (_input) => |
| 1502 | 1481 | observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { |
| 1503 | 1482 | "rpc.aggregate": "cloud", |
| ... | ... | @@ -2021,6 +2000,26 @@ const makeWsRpcLayer = ( |
| 2021 | 2000 | }), |
| 2022 | 2001 | { "rpc.aggregate": "auth" }, |
| 2023 | 2002 | ), |
| 2003 | [WS_METHODS.subscribeBackgroundPolicy]: (_input) => | |
| 2004 | observeRpcStream( | |
| 2005 | WS_METHODS.subscribeBackgroundPolicy, | |
| 2006 | Stream.unwrap( | |
| 2007 | Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) => | |
| 2008 | Stream.concat(Stream.make(latest), changes), | |
| 2009 | ), | |
| 2010 | ), | |
| 2011 | { "rpc.aggregate": "server" }, | |
| 2012 | ), | |
| 2013 | [WS_METHODS.subscribeResourceTelemetry]: (_input) => | |
| 2014 | observeRpcStream( | |
| 2015 | WS_METHODS.subscribeResourceTelemetry, | |
| 2016 | Stream.unwrap( | |
| 2017 | Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) => | |
| 2018 | Stream.concat(Stream.make(latest), changes), | |
| 2019 | ), | |
| 2020 | ), | |
| 2021 | { "rpc.aggregate": "server" }, | |
| 2022 | ), | |
| 2024 | 2023 | }); |
| 2025 | 2024 | }), |
| 2026 | 2025 | ); |
apps/web/src/components/settings/DiagnosticsSettings.tsx+10-3| ... | ... | @@ -37,6 +37,7 @@ import { Button } from "../ui/button"; |
| 37 | 37 | import { ScrollArea } from "../ui/scroll-area"; |
| 38 | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 39 | 39 | import { toastManager } from "../ui/toast"; |
| 40 | import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; | |
| 40 | 41 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; |
| 41 | 42 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 42 | 43 | |
| ... | ... | @@ -904,12 +905,16 @@ export function DiagnosticsSettingsPanel() { |
| 904 | 905 | if (environmentId === null) { |
| 905 | 906 | return; |
| 906 | 907 | } |
| 908 | const process = processData?.processes.find((entry) => entry.pid === pid); | |
| 909 | if (process === undefined) { | |
| 910 | return; | |
| 911 | } | |
| 907 | 912 | |
| 908 | 913 | setSignalingPid(pid); |
| 909 | 914 | void (async () => { |
| 910 | 915 | const result = await signalServerProcess({ |
| 911 | 916 | environmentId, |
| 912 | input: { pid, signal }, | |
| 917 | input: { pid, startTimeMs: process.startTimeMs, signal }, | |
| 913 | 918 | }); |
| 914 | 919 | setSignalingPid(null); |
| 915 | 920 | if (result._tag === "Failure") { |
| ... | ... | @@ -946,7 +951,7 @@ export function DiagnosticsSettingsPanel() { |
| 946 | 951 | refreshProcesses(); |
| 947 | 952 | })(); |
| 948 | 953 | }, |
| 949 | [environmentId, refreshProcesses, signalServerProcess], | |
| 954 | [environmentId, processData?.processes, refreshProcesses, signalServerProcess], | |
| 950 | 955 | ); |
| 951 | 956 | |
| 952 | 957 | const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; |
| ... | ... | @@ -957,7 +962,9 @@ export function DiagnosticsSettingsPanel() { |
| 957 | 962 | : false; |
| 958 | 963 | |
| 959 | 964 | return ( |
| 960 | <SettingsPageContainer> | |
| 965 | <SettingsPageContainer className="max-w-6xl gap-10"> | |
| 966 | <ResourceTelemetryDiagnostics /> | |
| 967 | ||
| 961 | 968 | <SettingsSection |
| 962 | 969 | title="Live Processes" |
| 963 | 970 | headerAction={ |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts created+90| ... | ... | @@ -0,0 +1,90 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import type { ResourceTelemetryProcess } from "@t3tools/contracts"; | |
| 4 | import * as DateTime from "effect/DateTime"; | |
| 5 | ||
| 6 | import { | |
| 7 | resourceHistoryBarHeight, | |
| 8 | resourceHistoryCpuScaleMax, | |
| 9 | shouldShowResourceMonitorRetry, | |
| 10 | visibleResourceTelemetryProcesses, | |
| 11 | } from "./ResourceTelemetryDiagnostics.logic"; | |
| 12 | ||
| 13 | const process = (pid: number, ppid: number, depth: number): ResourceTelemetryProcess => ({ | |
| 14 | identity: { pid, startTimeMs: pid * 1_000 }, | |
| 15 | ppid, | |
| 16 | childPids: [], | |
| 17 | depth, | |
| 18 | name: `process-${pid}`, | |
| 19 | command: `process-${pid}`, | |
| 20 | status: "Running", | |
| 21 | category: pid === 1 ? "server" : "server-child", | |
| 22 | cpuPercent: 0, | |
| 23 | cpuTimeMs: 0, | |
| 24 | residentBytes: 0, | |
| 25 | peakResidentBytes: 0, | |
| 26 | virtualBytes: 0, | |
| 27 | ioReadBytes: 0, | |
| 28 | ioWriteBytes: 0, | |
| 29 | ioReadBytesPerSecond: 0, | |
| 30 | ioWriteBytesPerSecond: 0, | |
| 31 | ioSemantics: "storage", | |
| 32 | runTimeMs: 0, | |
| 33 | firstSeenAt: DateTime.makeUnsafe(0), | |
| 34 | lastSeenAt: DateTime.makeUnsafe(0), | |
| 35 | }); | |
| 36 | ||
| 37 | describe("shouldShowResourceMonitorRetry", () => { | |
| 38 | it("allows retry when the initial telemetry request fails before a snapshot", () => { | |
| 39 | expect( | |
| 40 | shouldShowResourceMonitorRetry({ | |
| 41 | nativeStatus: null, | |
| 42 | error: "Resource monitor is unavailable.", | |
| 43 | }), | |
| 44 | ).toBe(true); | |
| 45 | }); | |
| 46 | ||
| 47 | it("does not show retry for an initial load without an error or a healthy snapshot", () => { | |
| 48 | expect(shouldShowResourceMonitorRetry({ nativeStatus: null, error: null })).toBe(false); | |
| 49 | expect(shouldShowResourceMonitorRetry({ nativeStatus: "healthy", error: null })).toBe(false); | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | describe("resourceHistoryBarHeight", () => { | |
| 54 | it("renders an exactly zero sample with no visible bar", () => { | |
| 55 | expect(resourceHistoryBarHeight({ value: 0, max: 100, minimumVisiblePercent: 2 })).toBe(0); | |
| 56 | }); | |
| 57 | ||
| 58 | it("keeps nonzero samples visible without changing proportional heights", () => { | |
| 59 | expect(resourceHistoryBarHeight({ value: 0.5, max: 100, minimumVisiblePercent: 2 })).toBe(2); | |
| 60 | expect(resourceHistoryBarHeight({ value: 50, max: 100, minimumVisiblePercent: 2 })).toBe(50); | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | describe("resourceHistoryCpuScaleMax", () => { | |
| 65 | it("scales average bars independently of brief peak spikes", () => { | |
| 66 | const buckets = [ | |
| 67 | { avgCpuPercent: 1, maxCpuPercent: 100 }, | |
| 68 | { avgCpuPercent: 8, maxCpuPercent: 8 }, | |
| 69 | ]; | |
| 70 | expect(resourceHistoryCpuScaleMax(buckets)).toBe(8); | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("visibleResourceTelemetryProcesses", () => { | |
| 75 | it("hides descendants by parentage even when rows are not depth-first", () => { | |
| 76 | const processes = [ | |
| 77 | process(1, 0, 0), | |
| 78 | process(10, 0, 0), | |
| 79 | process(2, 1, 1), | |
| 80 | process(11, 10, 1), | |
| 81 | process(3, 2, 2), | |
| 82 | ]; | |
| 83 | ||
| 84 | expect(visibleResourceTelemetryProcesses(processes, new Set(["1:1000"]))).toEqual([ | |
| 85 | processes[0], | |
| 86 | processes[1], | |
| 87 | processes[3], | |
| 88 | ]); | |
| 89 | }); | |
| 90 | }); |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | import type { ResourceTelemetryProcess, ResourceTelemetrySourceStatus } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | function processIdentityKey(process: ResourceTelemetryProcess): string { | |
| 4 | return `${process.identity.pid}:${process.identity.startTimeMs}`; | |
| 5 | } | |
| 6 | ||
| 7 | export function visibleResourceTelemetryProcesses( | |
| 8 | processes: ReadonlyArray<ResourceTelemetryProcess>, | |
| 9 | collapsed: ReadonlySet<string>, | |
| 10 | ): ReadonlyArray<ResourceTelemetryProcess> { | |
| 11 | const childrenByParent = new Map<number, ResourceTelemetryProcess[]>(); | |
| 12 | for (const process of processes) { | |
| 13 | const children = childrenByParent.get(process.ppid) ?? []; | |
| 14 | children.push(process); | |
| 15 | childrenByParent.set(process.ppid, children); | |
| 16 | } | |
| 17 | ||
| 18 | const hidden = new Set<string>(); | |
| 19 | const hideDescendants = (pid: number): void => { | |
| 20 | for (const child of childrenByParent.get(pid) ?? []) { | |
| 21 | const key = processIdentityKey(child); | |
| 22 | if (hidden.has(key)) continue; | |
| 23 | hidden.add(key); | |
| 24 | hideDescendants(child.identity.pid); | |
| 25 | } | |
| 26 | }; | |
| 27 | for (const process of processes) { | |
| 28 | if (collapsed.has(processIdentityKey(process))) { | |
| 29 | hideDescendants(process.identity.pid); | |
| 30 | } | |
| 31 | } | |
| 32 | return processes.filter((process) => !hidden.has(processIdentityKey(process))); | |
| 33 | } | |
| 34 | ||
| 35 | export function shouldShowResourceMonitorRetry(input: { | |
| 36 | readonly nativeStatus: ResourceTelemetrySourceStatus | null; | |
| 37 | readonly error: string | null; | |
| 38 | }): boolean { | |
| 39 | return ( | |
| 40 | (input.nativeStatus === null && input.error !== null) || | |
| 41 | input.nativeStatus === "degraded" || | |
| 42 | input.nativeStatus === "unavailable" || | |
| 43 | input.nativeStatus === "stopped" | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | export function resourceHistoryBarHeight(input: { | |
| 48 | readonly value: number; | |
| 49 | readonly max: number; | |
| 50 | readonly minimumVisiblePercent: number; | |
| 51 | }): number { | |
| 52 | if (input.value <= 0) return 0; | |
| 53 | return Math.max(input.minimumVisiblePercent, (input.value / Math.max(1, input.max)) * 100); | |
| 54 | } | |
| 55 | ||
| 56 | export function resourceHistoryCpuScaleMax( | |
| 57 | buckets: ReadonlyArray<{ readonly avgCpuPercent: number }>, | |
| 58 | ): number { | |
| 59 | return Math.max(1, ...buckets.map((bucket) => bucket.avgCpuPercent)); | |
| 60 | } |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx created+1268| ... | ... | @@ -0,0 +1,1268 @@ |
| 1 | import { | |
| 2 | ActivityIcon, | |
| 3 | AlertTriangleIcon, | |
| 4 | BatteryIcon, | |
| 5 | ChevronDownIcon, | |
| 6 | ChevronRightIcon, | |
| 7 | CpuIcon, | |
| 8 | DatabaseIcon, | |
| 9 | GaugeIcon, | |
| 10 | HardDriveIcon, | |
| 11 | MemoryStickIcon, | |
| 12 | RefreshCwIcon, | |
| 13 | RotateCcwIcon, | |
| 14 | } from "lucide-react"; | |
| 15 | import type { | |
| 16 | BackgroundBooleanState, | |
| 17 | ResourceAttributionEntry, | |
| 18 | ResourceTelemetryAggregate, | |
| 19 | ResourceTelemetryHistoryBucket, | |
| 20 | ResourceTelemetryIoSemantics, | |
| 21 | ResourceTelemetryProcess, | |
| 22 | ResourceTelemetryProcessCategory, | |
| 23 | ResourceTelemetryProcessSummary, | |
| 24 | ResourceTelemetrySourceHealth, | |
| 25 | ResourceTelemetrySourceStatus, | |
| 26 | ServerProcessSignal, | |
| 27 | } from "@t3tools/contracts"; | |
| 28 | import * as DateTime from "effect/DateTime"; | |
| 29 | import * as Option from "effect/Option"; | |
| 30 | import { useCallback, useMemo, useState, type ReactNode } from "react"; | |
| 31 | import { | |
| 32 | isAtomCommandInterrupted, | |
| 33 | squashAtomCommandFailure, | |
| 34 | } from "@t3tools/client-runtime/state/runtime"; | |
| 35 | ||
| 36 | import { | |
| 37 | useResourceTelemetry, | |
| 38 | useResourceTelemetryHistory, | |
| 39 | } from "../../lib/resourceTelemetryState"; | |
| 40 | import { cn } from "../../lib/utils"; | |
| 41 | import { usePrimaryEnvironment } from "../../state/environments"; | |
| 42 | import { serverEnvironment } from "../../state/server"; | |
| 43 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 44 | import { formatRelativeTime } from "../../timestampFormat"; | |
| 45 | import { Button } from "../ui/button"; | |
| 46 | import { ScrollArea } from "../ui/scroll-area"; | |
| 47 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 48 | import { toastManager } from "../ui/toast"; | |
| 49 | import { | |
| 50 | resourceHistoryBarHeight, | |
| 51 | resourceHistoryCpuScaleMax, | |
| 52 | shouldShowResourceMonitorRetry, | |
| 53 | visibleResourceTelemetryProcesses, | |
| 54 | } from "./ResourceTelemetryDiagnostics.logic"; | |
| 55 | import { SettingsSection, useRelativeTimeTick } from "./settingsLayout"; | |
| 56 | ||
| 57 | const HISTORY_WINDOWS = [ | |
| 58 | { label: "5m", windowMs: 5 * 60_000, bucketMs: 15_000 }, | |
| 59 | { label: "15m", windowMs: 15 * 60_000, bucketMs: 30_000 }, | |
| 60 | { label: "30m", windowMs: 30 * 60_000, bucketMs: 60_000 }, | |
| 61 | { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 }, | |
| 62 | ] as const; | |
| 63 | ||
| 64 | function formatBytes(value: number): string { | |
| 65 | if (value < 1_024) return `${Math.round(value)} B`; | |
| 66 | const units = ["KB", "MB", "GB", "TB"] as const; | |
| 67 | let next = value; | |
| 68 | let unitIndex = -1; | |
| 69 | do { | |
| 70 | next /= 1_024; | |
| 71 | unitIndex += 1; | |
| 72 | } while (next >= 1_024 && unitIndex < units.length - 1); | |
| 73 | return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; | |
| 74 | } | |
| 75 | ||
| 76 | function formatRate(value: number): string { | |
| 77 | return `${formatBytes(value)}/s`; | |
| 78 | } | |
| 79 | ||
| 80 | function formatCpuTime(valueMs: number): string { | |
| 81 | const seconds = valueMs / 1_000; | |
| 82 | if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`; | |
| 83 | const minutes = seconds / 60; | |
| 84 | if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; | |
| 85 | return `${(minutes / 60).toFixed(2)}h`; | |
| 86 | } | |
| 87 | ||
| 88 | function formatDurationMicros(value: number): string { | |
| 89 | if (value < 1_000) return `${Math.round(value)} µs`; | |
| 90 | if (value < 1_000_000) return `${(value / 1_000).toFixed(2)} ms`; | |
| 91 | return `${(value / 1_000_000).toFixed(2)} s`; | |
| 92 | } | |
| 93 | ||
| 94 | function formatSampleInterval(valueMs: number): string { | |
| 95 | if (valueMs < 1_000) return `${Math.max(0, Math.round(valueMs))} ms`; | |
| 96 | const seconds = valueMs / 1_000; | |
| 97 | return `${seconds.toLocaleString(undefined, { maximumFractionDigits: 1 })} ${ | |
| 98 | seconds === 1 ? "second" : "seconds" | |
| 99 | }`; | |
| 100 | } | |
| 101 | ||
| 102 | function processIdentityKey(process: ResourceTelemetryProcess): string { | |
| 103 | return `${process.identity.pid}:${process.identity.startTimeMs}`; | |
| 104 | } | |
| 105 | ||
| 106 | function processSummaryIdentityKey(process: ResourceTelemetryProcessSummary): string { | |
| 107 | return `${process.identity.pid}:${process.identity.startTimeMs}`; | |
| 108 | } | |
| 109 | ||
| 110 | function formatProcessName(process: Pick<ResourceTelemetryProcess, "command" | "name">): string { | |
| 111 | if (process.name.trim()) return process.name; | |
| 112 | const firstToken = process.command.trim().split(/\s+/)[0] ?? process.command; | |
| 113 | const normalized = firstToken.replace(/^['"]|['"]$/g, ""); | |
| 114 | return normalized.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? normalized; | |
| 115 | } | |
| 116 | ||
| 117 | function categoryLabel(category: ResourceTelemetryProcessCategory): string { | |
| 118 | switch (category) { | |
| 119 | case "server": | |
| 120 | return "Server"; | |
| 121 | case "server-child": | |
| 122 | return "Backend child"; | |
| 123 | case "provider-root": | |
| 124 | return "Provider"; | |
| 125 | case "terminal-root": | |
| 126 | return "Terminal"; | |
| 127 | case "electron-main": | |
| 128 | return "Electron main"; | |
| 129 | case "electron-renderer": | |
| 130 | return "Renderer"; | |
| 131 | case "electron-gpu": | |
| 132 | return "GPU"; | |
| 133 | case "electron-utility": | |
| 134 | return "Electron utility"; | |
| 135 | case "resource-monitor": | |
| 136 | return "Monitor"; | |
| 137 | case "unknown-t3": | |
| 138 | return "T3 process"; | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | function categoryDotClass(category: ResourceTelemetryProcessCategory): string { | |
| 143 | if (category === "resource-monitor") return "bg-amber-500"; | |
| 144 | if (category.startsWith("electron-")) return "bg-sky-500"; | |
| 145 | if (category === "server") return "bg-violet-500"; | |
| 146 | return "bg-emerald-500"; | |
| 147 | } | |
| 148 | ||
| 149 | function ioSemanticsLabel(semantics: ResourceTelemetryIoSemantics): string { | |
| 150 | switch (semantics) { | |
| 151 | case "storage": | |
| 152 | return "Storage bytes"; | |
| 153 | case "logical": | |
| 154 | return "Logical bytes"; | |
| 155 | case "all-io": | |
| 156 | return "All I/O bytes"; | |
| 157 | case "unavailable": | |
| 158 | return "Unavailable"; | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | function booleanStateLabel( | |
| 163 | value: BackgroundBooleanState, | |
| 164 | labels: { readonly true: string; readonly false: string }, | |
| 165 | ): string { | |
| 166 | if (value === "true") return labels.true; | |
| 167 | if (value === "false") return labels.false; | |
| 168 | return "Unknown"; | |
| 169 | } | |
| 170 | ||
| 171 | function sourceStatusTone(status: ResourceTelemetrySourceStatus): "default" | "warning" | "danger" { | |
| 172 | if (status === "healthy") return "default"; | |
| 173 | if (status === "starting" || status === "degraded") return "warning"; | |
| 174 | return "danger"; | |
| 175 | } | |
| 176 | ||
| 177 | function SourceStatusBadge({ | |
| 178 | label, | |
| 179 | status, | |
| 180 | presentation, | |
| 181 | }: { | |
| 182 | label: string; | |
| 183 | status: ResourceTelemetrySourceStatus; | |
| 184 | presentation?: | |
| 185 | | { | |
| 186 | readonly label: string; | |
| 187 | readonly tone: "neutral"; | |
| 188 | } | |
| 189 | | undefined; | |
| 190 | }) { | |
| 191 | const tone = presentation?.tone ?? sourceStatusTone(status); | |
| 192 | return ( | |
| 193 | <span | |
| 194 | className={cn( | |
| 195 | "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.08em]", | |
| 196 | tone === "neutral" && "border-border/70 bg-muted/45 text-muted-foreground", | |
| 197 | tone === "default" && | |
| 198 | "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", | |
| 199 | tone === "warning" && | |
| 200 | "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", | |
| 201 | tone === "danger" && "border-destructive/30 bg-destructive/10 text-destructive", | |
| 202 | )} | |
| 203 | > | |
| 204 | <span | |
| 205 | className={cn( | |
| 206 | "size-1.5 rounded-full", | |
| 207 | tone === "neutral" && "bg-muted-foreground/55", | |
| 208 | tone === "default" && "bg-emerald-500", | |
| 209 | tone === "warning" && "bg-amber-500", | |
| 210 | tone === "danger" && "bg-destructive", | |
| 211 | )} | |
| 212 | /> | |
| 213 | {label} {presentation?.label ?? status} | |
| 214 | </span> | |
| 215 | ); | |
| 216 | } | |
| 217 | ||
| 218 | function LastSampleLabel({ sampledAt }: { sampledAt: DateTime.Utc | null }) { | |
| 219 | useRelativeTimeTick(); | |
| 220 | if (!sampledAt) { | |
| 221 | return <span className="text-[11px] text-muted-foreground/55">Waiting for sample</span>; | |
| 222 | } | |
| 223 | const relative = formatRelativeTime(DateTime.formatIso(sampledAt)); | |
| 224 | if (!relative) { | |
| 225 | return <span className="text-[11px] text-muted-foreground/55">Waiting for sample</span>; | |
| 226 | } | |
| 227 | return ( | |
| 228 | <span className="text-[11px] text-muted-foreground/60"> | |
| 229 | Updated <span className="font-mono tabular-nums">{relative.value}</span> | |
| 230 | {relative.suffix ? ` ${relative.suffix}` : ""} | |
| 231 | </span> | |
| 232 | ); | |
| 233 | } | |
| 234 | ||
| 235 | function IconStat({ | |
| 236 | icon, | |
| 237 | label, | |
| 238 | value, | |
| 239 | detail, | |
| 240 | tone = "default", | |
| 241 | }: { | |
| 242 | icon: ReactNode; | |
| 243 | label: string; | |
| 244 | value: string; | |
| 245 | detail?: string | undefined; | |
| 246 | tone?: "default" | "warning" | "danger"; | |
| 247 | }) { | |
| 248 | return ( | |
| 249 | <div className="group min-w-0 px-4 py-4 sm:px-5"> | |
| 250 | <div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.11em] text-muted-foreground/70"> | |
| 251 | <span className="text-muted-foreground/55 transition-colors group-hover:text-foreground/65"> | |
| 252 | {icon} | |
| 253 | </span> | |
| 254 | <span className="truncate">{label}</span> | |
| 255 | </div> | |
| 256 | <div | |
| 257 | className={cn( | |
| 258 | "mt-2.5 truncate font-mono text-2xl font-semibold tracking-[-0.05em] tabular-nums text-foreground", | |
| 259 | tone === "warning" && "text-amber-600 dark:text-amber-300", | |
| 260 | tone === "danger" && "text-destructive", | |
| 261 | )} | |
| 262 | > | |
| 263 | {value} | |
| 264 | </div> | |
| 265 | {detail ? ( | |
| 266 | <div className="mt-1.5 truncate text-[10px] text-muted-foreground/60">{detail}</div> | |
| 267 | ) : null} | |
| 268 | </div> | |
| 269 | ); | |
| 270 | } | |
| 271 | ||
| 272 | function AggregateCard({ | |
| 273 | label, | |
| 274 | accentClass, | |
| 275 | aggregate, | |
| 276 | }: { | |
| 277 | label: string; | |
| 278 | accentClass: string; | |
| 279 | aggregate: ResourceTelemetryAggregate; | |
| 280 | }) { | |
| 281 | return ( | |
| 282 | <div className="relative overflow-hidden border-t border-border/60 px-4 py-4 first:border-t-0 md:border-t-0 md:border-l md:first:border-l-0 sm:px-5"> | |
| 283 | <span className={cn("absolute inset-x-5 top-0 h-0.5 rounded-full opacity-75", accentClass)} /> | |
| 284 | <div className="flex items-center justify-between gap-3"> | |
| 285 | <div className="text-[10px] font-semibold uppercase tracking-[0.11em] text-muted-foreground/75"> | |
| 286 | {label} | |
| 287 | </div> | |
| 288 | <div className="rounded-md bg-muted/55 px-1.5 py-0.5 font-mono text-[9px] tabular-nums text-muted-foreground/70"> | |
| 289 | {aggregate.processCount} {aggregate.processCount === 1 ? "process" : "processes"} | |
| 290 | </div> | |
| 291 | </div> | |
| 292 | <div className="mt-3.5 grid grid-cols-2 gap-x-4 gap-y-2.5"> | |
| 293 | <MetricPair label="CPU" value={`${aggregate.currentCpuPercent.toFixed(1)}%`} /> | |
| 294 | <MetricPair label="Memory" value={formatBytes(aggregate.currentRssBytes)} /> | |
| 295 | <MetricPair label="Read" value={formatRate(aggregate.ioReadBytesPerSecond)} /> | |
| 296 | <MetricPair label="Write" value={formatRate(aggregate.ioWriteBytesPerSecond)} /> | |
| 297 | </div> | |
| 298 | </div> | |
| 299 | ); | |
| 300 | } | |
| 301 | ||
| 302 | function MetricPair({ label, value }: { label: string; value: string }) { | |
| 303 | return ( | |
| 304 | <div className="min-w-0"> | |
| 305 | <div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/45"> | |
| 306 | {label} | |
| 307 | </div> | |
| 308 | <div className="truncate font-mono text-xs font-medium tabular-nums text-foreground/90"> | |
| 309 | {value} | |
| 310 | </div> | |
| 311 | </div> | |
| 312 | ); | |
| 313 | } | |
| 314 | ||
| 315 | function HealthSource({ label, health }: { label: string; health: ResourceTelemetrySourceHealth }) { | |
| 316 | const expectedInBrowser = | |
| 317 | health.status === "unavailable" && | |
| 318 | Option.exists(health.lastError, (error) => error.includes("'web' mode")); | |
| 319 | return ( | |
| 320 | <div className="flex items-start justify-between gap-4 border-t border-border/50 py-3 first:border-t-0"> | |
| 321 | <div className="min-w-0"> | |
| 322 | <div className="text-[13px] font-medium text-foreground">{label}</div> | |
| 323 | <div className="mt-1 text-[11px] leading-relaxed text-muted-foreground/65"> | |
| 324 | {expectedInBrowser | |
| 325 | ? "Available when this page runs inside the desktop app." | |
| 326 | : Option.match(health.lastError, { | |
| 327 | onNone: () => "No reported errors", | |
| 328 | onSome: (error) => error, | |
| 329 | })} | |
| 330 | </div> | |
| 331 | </div> | |
| 332 | <SourceStatusBadge | |
| 333 | label="" | |
| 334 | status={health.status} | |
| 335 | presentation={ | |
| 336 | expectedInBrowser | |
| 337 | ? { | |
| 338 | label: "Desktop only", | |
| 339 | tone: "neutral", | |
| 340 | } | |
| 341 | : undefined | |
| 342 | } | |
| 343 | /> | |
| 344 | </div> | |
| 345 | ); | |
| 346 | } | |
| 347 | ||
| 348 | function DetailRow({ | |
| 349 | label, | |
| 350 | value, | |
| 351 | valueClassName, | |
| 352 | }: { | |
| 353 | label: string; | |
| 354 | value: ReactNode; | |
| 355 | valueClassName?: string | undefined; | |
| 356 | }) { | |
| 357 | return ( | |
| 358 | <div className="flex items-center justify-between gap-4 border-t border-border/50 py-2.5 first:border-t-0"> | |
| 359 | <span className="text-[11px] text-muted-foreground/75">{label}</span> | |
| 360 | <span | |
| 361 | className={cn( | |
| 362 | "min-w-0 truncate text-right font-mono text-[11px] tabular-nums text-foreground/85", | |
| 363 | valueClassName, | |
| 364 | )} | |
| 365 | > | |
| 366 | {value} | |
| 367 | </span> | |
| 368 | </div> | |
| 369 | ); | |
| 370 | } | |
| 371 | ||
| 372 | function HistoryWindowSelector({ | |
| 373 | selectedWindowMs, | |
| 374 | onSelect, | |
| 375 | }: { | |
| 376 | selectedWindowMs: number; | |
| 377 | onSelect: (windowMs: number) => void; | |
| 378 | }) { | |
| 379 | return ( | |
| 380 | <div className="flex items-center rounded-md border border-border/60 p-0.5"> | |
| 381 | {HISTORY_WINDOWS.map((option) => ( | |
| 382 | <button | |
| 383 | key={option.windowMs} | |
| 384 | type="button" | |
| 385 | className={cn( | |
| 386 | "h-6 rounded-sm px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground", | |
| 387 | selectedWindowMs === option.windowMs && "bg-muted text-foreground", | |
| 388 | )} | |
| 389 | onClick={() => onSelect(option.windowMs)} | |
| 390 | > | |
| 391 | {option.label} | |
| 392 | </button> | |
| 393 | ))} | |
| 394 | </div> | |
| 395 | ); | |
| 396 | } | |
| 397 | ||
| 398 | function ResourceHistoryChart({ | |
| 399 | buckets, | |
| 400 | }: { | |
| 401 | buckets: ReadonlyArray<ResourceTelemetryHistoryBucket>; | |
| 402 | }) { | |
| 403 | const maxCpu = resourceHistoryCpuScaleMax(buckets); | |
| 404 | const maxIo = Math.max(1, ...buckets.map((bucket) => bucket.ioReadBytes + bucket.ioWriteBytes)); | |
| 405 | ||
| 406 | return ( | |
| 407 | <div className="border-t border-border/60 px-4 py-4 sm:px-5"> | |
| 408 | <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground/65"> | |
| 409 | <span className="inline-flex items-center gap-1.5"> | |
| 410 | <span className="h-1.5 w-3 rounded-full bg-foreground/70" /> CPU average | |
| 411 | </span> | |
| 412 | <span className="inline-flex items-center gap-1.5"> | |
| 413 | <span className="h-1.5 w-3 rounded-full bg-sky-500/70" /> I/O reads | |
| 414 | </span> | |
| 415 | <span className="inline-flex items-center gap-1.5"> | |
| 416 | <span className="h-1.5 w-3 rounded-full bg-amber-500/80" /> I/O writes | |
| 417 | </span> | |
| 418 | </div> | |
| 419 | <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"> | |
| 420 | {buckets.map((bucket) => { | |
| 421 | const cpuHeight = resourceHistoryBarHeight({ | |
| 422 | value: bucket.avgCpuPercent, | |
| 423 | max: maxCpu, | |
| 424 | minimumVisiblePercent: 2, | |
| 425 | }); | |
| 426 | const readHeight = resourceHistoryBarHeight({ | |
| 427 | value: bucket.ioReadBytes, | |
| 428 | max: maxIo, | |
| 429 | minimumVisiblePercent: 1, | |
| 430 | }); | |
| 431 | const writeHeight = resourceHistoryBarHeight({ | |
| 432 | value: bucket.ioWriteBytes, | |
| 433 | max: maxIo, | |
| 434 | minimumVisiblePercent: 1, | |
| 435 | }); | |
| 436 | return ( | |
| 437 | <Tooltip key={DateTime.formatIso(bucket.startedAt)}> | |
| 438 | <TooltipTrigger | |
| 439 | render={ | |
| 440 | <div className="grid h-full min-w-1 flex-1 grid-cols-3 items-end gap-px"> | |
| 441 | <span | |
| 442 | className="block rounded-t-sm bg-foreground/65" | |
| 443 | style={{ height: `${cpuHeight}%` }} | |
| 444 | /> | |
| 445 | <span | |
| 446 | className="block rounded-t-sm bg-sky-500/70" | |
| 447 | style={{ height: `${readHeight}%` }} | |
| 448 | /> | |
| 449 | <span | |
| 450 | className="block rounded-t-sm bg-amber-500/80" | |
| 451 | style={{ height: `${writeHeight}%` }} | |
| 452 | /> | |
| 453 | </div> | |
| 454 | } | |
| 455 | /> | |
| 456 | <TooltipPopup side="top" className="space-y-0.5 text-left"> | |
| 457 | <div>CPU avg {bucket.avgCpuPercent.toFixed(1)}%</div> | |
| 458 | <div>CPU peak {bucket.maxCpuPercent.toFixed(1)}%</div> | |
| 459 | <div>Read {formatBytes(bucket.ioReadBytes)}</div> | |
| 460 | <div>Write {formatBytes(bucket.ioWriteBytes)}</div> | |
| 461 | </TooltipPopup> | |
| 462 | </Tooltip> | |
| 463 | ); | |
| 464 | })} | |
| 465 | </div> | |
| 466 | </div> | |
| 467 | ); | |
| 468 | } | |
| 469 | ||
| 470 | function ProcessTreeName({ | |
| 471 | process, | |
| 472 | collapsed, | |
| 473 | onToggle, | |
| 474 | }: { | |
| 475 | process: ResourceTelemetryProcess; | |
| 476 | collapsed: boolean; | |
| 477 | onToggle: (process: ResourceTelemetryProcess) => void; | |
| 478 | }) { | |
| 479 | const name = formatProcessName(process); | |
| 480 | const hasChildren = process.childPids.length > 0; | |
| 481 | const ChevronIcon = collapsed ? ChevronRightIcon : ChevronDownIcon; | |
| 482 | return ( | |
| 483 | <div | |
| 484 | className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2" | |
| 485 | style={{ paddingLeft: `${Math.min(process.depth, 7) * 10}px` }} | |
| 486 | > | |
| 487 | {hasChildren ? ( | |
| 488 | <button | |
| 489 | type="button" | |
| 490 | className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" | |
| 491 | onClick={() => onToggle(process)} | |
| 492 | aria-label={collapsed ? `Expand ${name}` : `Collapse ${name}`} | |
| 493 | > | |
| 494 | <ChevronIcon className="size-3.5" /> | |
| 495 | </button> | |
| 496 | ) : ( | |
| 497 | <span className="size-5" aria-hidden /> | |
| 498 | )} | |
| 499 | <span className={cn("size-1.5 rounded-full", categoryDotClass(process.category))} /> | |
| 500 | <Tooltip> | |
| 501 | <TooltipTrigger | |
| 502 | render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>} | |
| 503 | /> | |
| 504 | <TooltipPopup | |
| 505 | side="top" | |
| 506 | className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]" | |
| 507 | > | |
| 508 | {process.command || process.name} | |
| 509 | </TooltipPopup> | |
| 510 | </Tooltip> | |
| 511 | </div> | |
| 512 | ); | |
| 513 | } | |
| 514 | ||
| 515 | function canSignalProcess(process: ResourceTelemetryProcess): boolean { | |
| 516 | return ( | |
| 517 | process.category === "server-child" || | |
| 518 | process.category === "provider-root" || | |
| 519 | process.category === "terminal-root" | |
| 520 | ); | |
| 521 | } | |
| 522 | ||
| 523 | function ProcessActions({ | |
| 524 | process, | |
| 525 | signalingKeys, | |
| 526 | onSignal, | |
| 527 | }: { | |
| 528 | process: ResourceTelemetryProcess; | |
| 529 | signalingKeys: ReadonlySet<string>; | |
| 530 | onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; | |
| 531 | }) { | |
| 532 | if (!canSignalProcess(process)) { | |
| 533 | return <span className="text-[10px] text-muted-foreground/35">—</span>; | |
| 534 | } | |
| 535 | const isSignaling = signalingKeys.has(processIdentityKey(process)); | |
| 536 | return ( | |
| 537 | <div className="flex items-center justify-end gap-1.5"> | |
| 538 | <button | |
| 539 | type="button" | |
| 540 | disabled={isSignaling} | |
| 541 | className="text-[10px] font-semibold text-muted-foreground hover:text-foreground disabled:opacity-50" | |
| 542 | onClick={() => onSignal(process, "SIGINT")} | |
| 543 | > | |
| 544 | INT | |
| 545 | </button> | |
| 546 | <button | |
| 547 | type="button" | |
| 548 | disabled={isSignaling} | |
| 549 | className="text-[10px] font-semibold text-destructive hover:underline disabled:opacity-50" | |
| 550 | onClick={() => onSignal(process, "SIGKILL")} | |
| 551 | > | |
| 552 | KILL | |
| 553 | </button> | |
| 554 | </div> | |
| 555 | ); | |
| 556 | } | |
| 557 | ||
| 558 | function ProcessTable({ | |
| 559 | processes, | |
| 560 | signalingKeys, | |
| 561 | onSignal, | |
| 562 | }: { | |
| 563 | processes: ReadonlyArray<ResourceTelemetryProcess>; | |
| 564 | signalingKeys: ReadonlySet<string>; | |
| 565 | onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; | |
| 566 | }) { | |
| 567 | const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set()); | |
| 568 | const visible = useMemo( | |
| 569 | () => visibleResourceTelemetryProcesses(processes, collapsed), | |
| 570 | [collapsed, processes], | |
| 571 | ); | |
| 572 | const toggle = useCallback((process: ResourceTelemetryProcess) => { | |
| 573 | const identityKey = processIdentityKey(process); | |
| 574 | setCollapsed((current) => { | |
| 575 | const next = new Set(current); | |
| 576 | if (next.has(identityKey)) { | |
| 577 | next.delete(identityKey); | |
| 578 | } else { | |
| 579 | next.add(identityKey); | |
| 580 | } | |
| 581 | return next; | |
| 582 | }); | |
| 583 | }, []); | |
| 584 | ||
| 585 | return ( | |
| 586 | <ScrollArea | |
| 587 | chainVerticalScroll | |
| 588 | scrollFade | |
| 589 | hideScrollbars | |
| 590 | className="max-h-[min(68vh,48rem)] w-full max-w-full border-t border-border/60" | |
| 591 | > | |
| 592 | <table className="w-full min-w-[1320px] table-fixed text-left text-xs"> | |
| 593 | <colgroup> | |
| 594 | <col className="w-[20%]" /> | |
| 595 | <col className="w-[10%]" /> | |
| 596 | <col className="w-[7%]" /> | |
| 597 | <col className="w-[8%]" /> | |
| 598 | <col className="w-[9%]" /> | |
| 599 | <col className="w-[9%]" /> | |
| 600 | <col className="w-[9%]" /> | |
| 601 | <col className="w-[10%]" /> | |
| 602 | <col className="w-[8%]" /> | |
| 603 | <col className="w-[6%]" /> | |
| 604 | <col className="w-[4%]" /> | |
| 605 | </colgroup> | |
| 606 | <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65"> | |
| 607 | <tr> | |
| 608 | <th className="px-4 py-2 font-semibold sm:pl-5">Process</th> | |
| 609 | <th className="px-3 py-2 font-semibold">Category</th> | |
| 610 | <th className="px-3 py-2 text-right font-semibold">CPU</th> | |
| 611 | <th className="px-3 py-2 text-right font-semibold">CPU Time</th> | |
| 612 | <th className="px-3 py-2 text-right font-semibold">Memory</th> | |
| 613 | <th className="px-3 py-2 text-right font-semibold">Read/s</th> | |
| 614 | <th className="px-3 py-2 text-right font-semibold">Write/s</th> | |
| 615 | <th className="px-3 py-2 text-right font-semibold">Read Total</th> | |
| 616 | <th className="px-3 py-2 text-right font-semibold">Write Total</th> | |
| 617 | <th className="px-3 py-2 text-right font-semibold">PID</th> | |
| 618 | <th className="px-2 py-2 text-right font-semibold sm:pr-4">Kill</th> | |
| 619 | </tr> | |
| 620 | </thead> | |
| 621 | <tbody className="divide-y divide-border/50"> | |
| 622 | {visible.length === 0 ? ( | |
| 623 | <tr> | |
| 624 | <td colSpan={11} className="px-4 py-5 text-xs text-muted-foreground sm:px-5"> | |
| 625 | Waiting for the native process monitor. | |
| 626 | </td> | |
| 627 | </tr> | |
| 628 | ) : null} | |
| 629 | {visible.map((process) => ( | |
| 630 | <tr key={processIdentityKey(process)} className="hover:bg-muted/20"> | |
| 631 | <td className="px-4 py-2 sm:pl-5"> | |
| 632 | <ProcessTreeName | |
| 633 | process={process} | |
| 634 | collapsed={collapsed.has(processIdentityKey(process))} | |
| 635 | onToggle={toggle} | |
| 636 | /> | |
| 637 | </td> | |
| 638 | <td className="truncate px-3 py-2 text-[11px] text-muted-foreground"> | |
| 639 | {categoryLabel(process.category)} | |
| 640 | </td> | |
| 641 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | |
| 642 | {process.cpuPercent.toFixed(1)}% | |
| 643 | </td> | |
| 644 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | |
| 645 | {formatCpuTime(process.cpuTimeMs)} | |
| 646 | </td> | |
| 647 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | |
| 648 | {formatBytes(process.residentBytes)} | |
| 649 | </td> | |
| 650 | <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300"> | |
| 651 | {formatRate(process.ioReadBytesPerSecond)} | |
| 652 | </td> | |
| 653 | <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300"> | |
| 654 | {formatRate(process.ioWriteBytesPerSecond)} | |
| 655 | </td> | |
| 656 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | |
| 657 | {formatBytes(process.ioReadBytes)} | |
| 658 | </td> | |
| 659 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | |
| 660 | <Tooltip> | |
| 661 | <TooltipTrigger render={<span>{formatBytes(process.ioWriteBytes)}</span>} /> | |
| 662 | <TooltipPopup side="top">{ioSemanticsLabel(process.ioSemantics)}</TooltipPopup> | |
| 663 | </Tooltip> | |
| 664 | </td> | |
| 665 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | |
| 666 | {process.identity.pid} | |
| 667 | </td> | |
| 668 | <td className="px-2 py-2 text-right sm:pr-4"> | |
| 669 | <ProcessActions | |
| 670 | process={process} | |
| 671 | signalingKeys={signalingKeys} | |
| 672 | onSignal={onSignal} | |
| 673 | /> | |
| 674 | </td> | |
| 675 | </tr> | |
| 676 | ))} | |
| 677 | </tbody> | |
| 678 | </table> | |
| 679 | </ScrollArea> | |
| 680 | ); | |
| 681 | } | |
| 682 | ||
| 683 | function HistoryProcessTable({ | |
| 684 | processes, | |
| 685 | }: { | |
| 686 | processes: ReadonlyArray<ResourceTelemetryProcessSummary>; | |
| 687 | }) { | |
| 688 | return ( | |
| 689 | <ScrollArea | |
| 690 | chainVerticalScroll | |
| 691 | scrollFade | |
| 692 | hideScrollbars | |
| 693 | className="max-h-[28rem] w-full max-w-full border-t border-border/60" | |
| 694 | > | |
| 695 | <table className="w-full min-w-[1020px] table-fixed text-left text-xs"> | |
| 696 | <colgroup> | |
| 697 | <col className="w-[24%]" /> | |
| 698 | <col className="w-[11%]" /> | |
| 699 | <col className="w-[10%]" /> | |
| 700 | <col className="w-[10%]" /> | |
| 701 | <col className="w-[11%]" /> | |
| 702 | <col className="w-[11%]" /> | |
| 703 | <col className="w-[11%]" /> | |
| 704 | <col className="w-[7%]" /> | |
| 705 | <col className="w-[5%]" /> | |
| 706 | </colgroup> | |
| 707 | <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65"> | |
| 708 | <tr> | |
| 709 | <th className="px-4 py-2 font-semibold sm:pl-5">Process</th> | |
| 710 | <th className="px-3 py-2 font-semibold">Category</th> | |
| 711 | <th className="px-3 py-2 text-right font-semibold">CPU Time</th> | |
| 712 | <th className="px-3 py-2 text-right font-semibold">Peak CPU</th> | |
| 713 | <th className="px-3 py-2 text-right font-semibold">Peak Mem</th> | |
| 714 | <th className="px-3 py-2 text-right font-semibold">Read</th> | |
| 715 | <th className="px-3 py-2 text-right font-semibold">Write</th> | |
| 716 | <th className="px-3 py-2 text-right font-semibold">Samples</th> | |
| 717 | <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th> | |
| 718 | </tr> | |
| 719 | </thead> | |
| 720 | <tbody className="divide-y divide-border/50"> | |
| 721 | {processes.length === 0 ? ( | |
| 722 | <tr> | |
| 723 | <td colSpan={9} className="px-4 py-5 text-xs text-muted-foreground sm:px-5"> | |
| 724 | No retained process samples in this window. | |
| 725 | </td> | |
| 726 | </tr> | |
| 727 | ) : null} | |
| 728 | {processes.map((process) => ( | |
| 729 | <tr key={processSummaryIdentityKey(process)} className="hover:bg-muted/20"> | |
| 730 | <td className="px-4 py-2 sm:pl-5"> | |
| 731 | <Tooltip> | |
| 732 | <TooltipTrigger | |
| 733 | render={ | |
| 734 | <span className="block truncate font-medium text-foreground"> | |
| 735 | {process.name || process.command} | |
| 736 | </span> | |
| 737 | } | |
| 738 | /> | |
| 739 | <TooltipPopup | |
| 740 | side="top" | |
| 741 | className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]" | |
| 742 | > | |
| 743 | {process.command || process.name} | |
| 744 | </TooltipPopup> | |
| 745 | </Tooltip> | |
| 746 | </td> | |
| 747 | <td className="truncate px-3 py-2 text-[11px] text-muted-foreground"> | |
| 748 | {categoryLabel(process.category)} | |
| 749 | </td> | |
| 750 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | |
| 751 | {formatCpuTime(process.cpuTimeMs)} | |
| 752 | </td> | |
| 753 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | |
| 754 | {process.maxCpuPercent.toFixed(1)}% | |
| 755 | </td> | |
| 756 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | |
| 757 | {formatBytes(process.peakRssBytes)} | |
| 758 | </td> | |
| 759 | <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300"> | |
| 760 | {formatBytes(process.ioReadBytes)} | |
| 761 | </td> | |
| 762 | <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300"> | |
| 763 | {formatBytes(process.ioWriteBytes)} | |
| 764 | </td> | |
| 765 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | |
| 766 | {process.sampleCount} | |
| 767 | </td> | |
| 768 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5"> | |
| 769 | {process.identity.pid} | |
| 770 | </td> | |
| 771 | </tr> | |
| 772 | ))} | |
| 773 | </tbody> | |
| 774 | </table> | |
| 775 | </ScrollArea> | |
| 776 | ); | |
| 777 | } | |
| 778 | ||
| 779 | function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttributionEntry> }) { | |
| 780 | return ( | |
| 781 | <div className="overflow-x-auto border-t border-border/60"> | |
| 782 | <table className="w-full min-w-[720px] table-fixed text-left text-xs"> | |
| 783 | <colgroup> | |
| 784 | <col className="w-[22%]" /> | |
| 785 | <col className="w-[28%]" /> | |
| 786 | <col className="w-[14%]" /> | |
| 787 | <col className="w-[14%]" /> | |
| 788 | <col className="w-[10%]" /> | |
| 789 | <col className="w-[12%]" /> | |
| 790 | </colgroup> | |
| 791 | <thead className="border-b border-border/60 text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65"> | |
| 792 | <tr> | |
| 793 | <th className="px-4 py-2 font-semibold sm:pl-5">Component</th> | |
| 794 | <th className="px-3 py-2 font-semibold">Operation</th> | |
| 795 | <th className="px-3 py-2 text-right font-semibold">Logical Read</th> | |
| 796 | <th className="px-3 py-2 text-right font-semibold">Logical Write</th> | |
| 797 | <th className="px-3 py-2 text-right font-semibold">Count</th> | |
| 798 | <th className="px-3 py-2 text-right font-semibold sm:pr-5">Time</th> | |
| 799 | </tr> | |
| 800 | </thead> | |
| 801 | <tbody className="divide-y divide-border/50"> | |
| 802 | {entries.length === 0 ? ( | |
| 803 | <tr> | |
| 804 | <td colSpan={6} className="px-4 py-5 text-xs text-muted-foreground sm:px-5"> | |
| 805 | No instrumented application I/O has been recorded yet. | |
| 806 | </td> | |
| 807 | </tr> | |
| 808 | ) : null} | |
| 809 | {entries.map((entry) => ( | |
| 810 | <tr key={`${entry.component}:${entry.operation}`} className="hover:bg-muted/20"> | |
| 811 | <td className="truncate px-4 py-2 font-medium text-foreground sm:pl-5"> | |
| 812 | {entry.component} | |
| 813 | </td> | |
| 814 | <td className="truncate px-3 py-2 text-muted-foreground">{entry.operation}</td> | |
| 815 | <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300"> | |
| 816 | {formatBytes(entry.logicalReadBytes)} | |
| 817 | </td> | |
| 818 | <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300"> | |
| 819 | {formatBytes(entry.logicalWriteBytes)} | |
| 820 | </td> | |
| 821 | <td className="px-3 py-2 text-right font-mono tabular-nums">{entry.count}</td> | |
| 822 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5"> | |
| 823 | {(entry.durationMs / 1_000).toFixed(2)}s | |
| 824 | </td> | |
| 825 | </tr> | |
| 826 | ))} | |
| 827 | </tbody> | |
| 828 | </table> | |
| 829 | </div> | |
| 830 | ); | |
| 831 | } | |
| 832 | ||
| 833 | export function ResourceTelemetryDiagnostics() { | |
| 834 | const [windowMs, setWindowMs] = useState(15 * 60_000); | |
| 835 | const selectedWindow = | |
| 836 | HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; | |
| 837 | const telemetry = useResourceTelemetry(); | |
| 838 | const retryTelemetry = telemetry.retry; | |
| 839 | const history = useResourceTelemetryHistory({ | |
| 840 | windowMs: selectedWindow.windowMs, | |
| 841 | bucketMs: selectedWindow.bucketMs, | |
| 842 | }); | |
| 843 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 844 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { | |
| 845 | reportFailure: false, | |
| 846 | }); | |
| 847 | const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set()); | |
| 848 | const [isRetrying, setIsRetrying] = useState(false); | |
| 849 | const snapshot = telemetry.data; | |
| 850 | const allT3 = snapshot?.groups.allT3; | |
| 851 | ||
| 852 | const signalProcess = useCallback( | |
| 853 | (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { | |
| 854 | if ( | |
| 855 | signal === "SIGKILL" && | |
| 856 | !window.confirm( | |
| 857 | `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`, | |
| 858 | ) | |
| 859 | ) { | |
| 860 | return; | |
| 861 | } | |
| 862 | const identityKey = processIdentityKey(process); | |
| 863 | const environmentId = primaryEnvironment?.environmentId; | |
| 864 | if (environmentId === undefined) { | |
| 865 | return; | |
| 866 | } | |
| 867 | setSignalingKeys((current) => new Set(current).add(identityKey)); | |
| 868 | void signalServerProcess({ | |
| 869 | environmentId, | |
| 870 | input: { | |
| 871 | pid: process.identity.pid, | |
| 872 | startTimeMs: process.identity.startTimeMs, | |
| 873 | signal, | |
| 874 | }, | |
| 875 | }) | |
| 876 | .then((result) => { | |
| 877 | if (result._tag === "Failure") { | |
| 878 | if (isAtomCommandInterrupted(result)) return; | |
| 879 | throw squashAtomCommandFailure(result); | |
| 880 | } | |
| 881 | if (result.value.signaled) return; | |
| 882 | toastManager.add({ | |
| 883 | type: "error", | |
| 884 | title: `Could not send ${signal}`, | |
| 885 | description: Option.getOrElse( | |
| 886 | result.value.message, | |
| 887 | () => `Failed to send ${signal} to process ${process.identity.pid}.`, | |
| 888 | ), | |
| 889 | }); | |
| 890 | }) | |
| 891 | .catch((error: unknown) => { | |
| 892 | toastManager.add({ | |
| 893 | type: "error", | |
| 894 | title: `Could not send ${signal}`, | |
| 895 | description: error instanceof Error ? error.message : `Failed to send ${signal}.`, | |
| 896 | }); | |
| 897 | }) | |
| 898 | .finally(() => { | |
| 899 | setSignalingKeys((current) => { | |
| 900 | if (!current.has(identityKey)) return current; | |
| 901 | const next = new Set(current); | |
| 902 | next.delete(identityKey); | |
| 903 | return next; | |
| 904 | }); | |
| 905 | }); | |
| 906 | }, | |
| 907 | [primaryEnvironment?.environmentId, signalServerProcess], | |
| 908 | ); | |
| 909 | ||
| 910 | const retryCollector = useCallback(() => { | |
| 911 | setIsRetrying(true); | |
| 912 | void retryTelemetry() | |
| 913 | .catch((error: unknown) => { | |
| 914 | toastManager.add({ | |
| 915 | type: "error", | |
| 916 | title: "Could not restart resource monitor", | |
| 917 | description: | |
| 918 | error instanceof Error ? error.message : "The resource monitor retry failed.", | |
| 919 | }); | |
| 920 | }) | |
| 921 | .finally(() => { | |
| 922 | setIsRetrying(false); | |
| 923 | }); | |
| 924 | }, [retryTelemetry]); | |
| 925 | ||
| 926 | const speedLimit = snapshot ? Option.getOrNull(snapshot.speedLimitPercent) : null; | |
| 927 | const collectorNeedsRetry = shouldShowResourceMonitorRetry({ | |
| 928 | nativeStatus: snapshot?.health.native.status ?? null, | |
| 929 | error: telemetry.error, | |
| 930 | }); | |
| 931 | const hasHostPowerSignal = | |
| 932 | snapshot !== null && | |
| 933 | (snapshot.power.onBattery !== "unknown" || | |
| 934 | snapshot.power.lowPowerMode !== "unknown" || | |
| 935 | snapshot.power.idle !== "unknown" || | |
| 936 | snapshot.power.locked !== "unknown" || | |
| 937 | snapshot.power.thermalState !== "unknown"); | |
| 938 | ||
| 939 | return ( | |
| 940 | <> | |
| 941 | <SettingsSection | |
| 942 | title="Resource monitor" | |
| 943 | icon={<ActivityIcon className="size-4 text-muted-foreground" />} | |
| 944 | headerAction={ | |
| 945 | <div className="flex items-center gap-2"> | |
| 946 | {snapshot ? ( | |
| 947 | <SourceStatusBadge label="Native" status={snapshot.health.native.status} /> | |
| 948 | ) : null} | |
| 949 | <LastSampleLabel sampledAt={snapshot?.readAt ?? null} /> | |
| 950 | <Tooltip> | |
| 951 | <TooltipTrigger | |
| 952 | render={ | |
| 953 | <Button | |
| 954 | size="icon-xs" | |
| 955 | variant="ghost" | |
| 956 | className="size-5 rounded-sm p-0" | |
| 957 | disabled={telemetry.isPending} | |
| 958 | onClick={telemetry.refresh} | |
| 959 | aria-label="Refresh resource telemetry" | |
| 960 | > | |
| 961 | <RefreshCwIcon | |
| 962 | className={cn("size-3", telemetry.isPending && "animate-spin")} | |
| 963 | /> | |
| 964 | </Button> | |
| 965 | } | |
| 966 | /> | |
| 967 | <TooltipPopup side="top">Refresh telemetry snapshot</TooltipPopup> | |
| 968 | </Tooltip> | |
| 969 | </div> | |
| 970 | } | |
| 971 | > | |
| 972 | <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03),0_8px_30px_rgb(0_0_0/0.035)]"> | |
| 973 | <div className="flex flex-col gap-3 border-b border-border/60 bg-linear-to-r from-muted/45 via-muted/20 to-transparent px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5"> | |
| 974 | <div> | |
| 975 | <div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70"> | |
| 976 | T3 system footprint | |
| 977 | </div> | |
| 978 | <p className="mt-1 max-w-xl text-xs leading-relaxed text-muted-foreground"> | |
| 979 | Live native counters for the server, providers, terminals, desktop processes, and | |
| 980 | the monitor itself. | |
| 981 | </p> | |
| 982 | </div> | |
| 983 | <div className="flex items-center gap-2 text-[10px] text-muted-foreground/65"> | |
| 984 | <span className="size-1.5 rounded-full bg-emerald-500" /> | |
| 985 | Sampling every {snapshot ? formatSampleInterval(snapshot.sampleIntervalMs) : "..."} | |
| 986 | </div> | |
| 987 | </div> | |
| 988 | <div className="grid grid-cols-2 divide-x divide-y divide-border/55 md:grid-cols-3"> | |
| 989 | <IconStat | |
| 990 | icon={<CpuIcon className="size-3.5" />} | |
| 991 | label="Current CPU" | |
| 992 | value={allT3 ? `${allT3.currentCpuPercent.toFixed(1)}%` : "..."} | |
| 993 | detail={allT3 ? `${formatCpuTime(allT3.cpuTimeMs)} observed CPU time` : undefined} | |
| 994 | /> | |
| 995 | <IconStat | |
| 996 | icon={<MemoryStickIcon className="size-3.5" />} | |
| 997 | label="Resident memory" | |
| 998 | value={allT3 ? formatBytes(allT3.currentRssBytes) : "..."} | |
| 999 | detail={ | |
| 1000 | allT3 ? `${formatBytes(allT3.peakRssBytes)} combined process peaks` : undefined | |
| 1001 | } | |
| 1002 | /> | |
| 1003 | <IconStat | |
| 1004 | icon={<ActivityIcon className="size-3.5" />} | |
| 1005 | label="Process count" | |
| 1006 | value={allT3 ? String(allT3.processCount) : "..."} | |
| 1007 | detail={ | |
| 1008 | allT3 ? `${allT3.processStarts} starts · ${allT3.processExits} exits` : undefined | |
| 1009 | } | |
| 1010 | /> | |
| 1011 | <IconStat | |
| 1012 | icon={<HardDriveIcon className="size-3.5" />} | |
| 1013 | label="Read throughput" | |
| 1014 | value={allT3 ? formatRate(allT3.ioReadBytesPerSecond) : "..."} | |
| 1015 | detail={allT3 ? `${formatBytes(allT3.ioReadBytes)} observed` : undefined} | |
| 1016 | /> | |
| 1017 | <IconStat | |
| 1018 | icon={<DatabaseIcon className="size-3.5" />} | |
| 1019 | label="Write throughput" | |
| 1020 | value={allT3 ? formatRate(allT3.ioWriteBytesPerSecond) : "..."} | |
| 1021 | detail={allT3 ? `${formatBytes(allT3.ioWriteBytes)} observed` : undefined} | |
| 1022 | tone={ | |
| 1023 | allT3 && allT3.ioWriteBytesPerSecond >= 10 * 1_024 * 1_024 | |
| 1024 | ? "danger" | |
| 1025 | : allT3 && allT3.ioWriteBytesPerSecond >= 1_024 * 1_024 | |
| 1026 | ? "warning" | |
| 1027 | : "default" | |
| 1028 | } | |
| 1029 | /> | |
| 1030 | <IconStat | |
| 1031 | icon={<GaugeIcon className="size-3.5" />} | |
| 1032 | label="CPU speed limit" | |
| 1033 | value={ | |
| 1034 | snapshot ? (speedLimit === null ? "Unknown" : `${speedLimit.toFixed(0)}%`) : "..." | |
| 1035 | } | |
| 1036 | detail={snapshot ? `${snapshot.power.thermalState} thermal state` : undefined} | |
| 1037 | tone={speedLimit !== null && speedLimit < 80 ? "warning" : "default"} | |
| 1038 | /> | |
| 1039 | </div> | |
| 1040 | {telemetry.error ? ( | |
| 1041 | <div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-4 py-3 text-xs text-destructive sm:px-5"> | |
| 1042 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | |
| 1043 | <span>{telemetry.error}</span> | |
| 1044 | </div> | |
| 1045 | ) : null} | |
| 1046 | {snapshot ? ( | |
| 1047 | <div className="grid border-t border-border/60 bg-muted/10 md:grid-cols-3"> | |
| 1048 | <AggregateCard | |
| 1049 | label="Backend + agents" | |
| 1050 | accentClass="bg-emerald-500/80" | |
| 1051 | aggregate={snapshot.groups.backend} | |
| 1052 | /> | |
| 1053 | <AggregateCard | |
| 1054 | label="Desktop" | |
| 1055 | accentClass="bg-sky-500/80" | |
| 1056 | aggregate={snapshot.groups.electron} | |
| 1057 | /> | |
| 1058 | <AggregateCard | |
| 1059 | label="Monitor overhead" | |
| 1060 | accentClass="bg-amber-500/80" | |
| 1061 | aggregate={snapshot.groups.monitor} | |
| 1062 | /> | |
| 1063 | </div> | |
| 1064 | ) : null} | |
| 1065 | </div> | |
| 1066 | </SettingsSection> | |
| 1067 | ||
| 1068 | <SettingsSection | |
| 1069 | title="Host & collection" | |
| 1070 | icon={<GaugeIcon className="size-4 text-muted-foreground" />} | |
| 1071 | headerAction={ | |
| 1072 | collectorNeedsRetry ? ( | |
| 1073 | <Button size="xs" variant="outline" disabled={isRetrying} onClick={retryCollector}> | |
| 1074 | <RotateCcwIcon className={cn("size-3", isRetrying && "animate-spin")} /> | |
| 1075 | Retry monitor | |
| 1076 | </Button> | |
| 1077 | ) : null | |
| 1078 | } | |
| 1079 | > | |
| 1080 | <div className="grid overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)] md:grid-cols-2 md:divide-x md:divide-border/60"> | |
| 1081 | <div className="px-4 py-4 sm:px-5"> | |
| 1082 | <div className="mb-3 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70"> | |
| 1083 | <span className="flex size-6 items-center justify-center rounded-md bg-muted/60"> | |
| 1084 | <BatteryIcon className="size-3.5" /> | |
| 1085 | </span> | |
| 1086 | Host state | |
| 1087 | </div> | |
| 1088 | {hasHostPowerSignal && snapshot ? ( | |
| 1089 | <> | |
| 1090 | <DetailRow | |
| 1091 | label="Power source" | |
| 1092 | value={booleanStateLabel(snapshot.power.onBattery, { | |
| 1093 | true: "Battery", | |
| 1094 | false: "External power", | |
| 1095 | })} | |
| 1096 | /> | |
| 1097 | <DetailRow | |
| 1098 | label="Low power mode" | |
| 1099 | value={booleanStateLabel(snapshot.power.lowPowerMode, { | |
| 1100 | true: "Enabled", | |
| 1101 | false: "Disabled", | |
| 1102 | })} | |
| 1103 | /> | |
| 1104 | <DetailRow | |
| 1105 | label="Idle" | |
| 1106 | value={`${booleanStateLabel(snapshot.power.idle, { | |
| 1107 | true: "Idle", | |
| 1108 | false: "Active", | |
| 1109 | })}${ | |
| 1110 | snapshot.power.idleSeconds === null | |
| 1111 | ? "" | |
| 1112 | : ` · ${Math.round(snapshot.power.idleSeconds)}s` | |
| 1113 | }`} | |
| 1114 | /> | |
| 1115 | <DetailRow | |
| 1116 | label="Session" | |
| 1117 | value={ | |
| 1118 | snapshot.power.suspended | |
| 1119 | ? "Suspended" | |
| 1120 | : booleanStateLabel(snapshot.power.locked, { | |
| 1121 | true: "Locked", | |
| 1122 | false: "Unlocked", | |
| 1123 | }) | |
| 1124 | } | |
| 1125 | /> | |
| 1126 | <DetailRow | |
| 1127 | label="Thermal" | |
| 1128 | value={snapshot.power.thermalState} | |
| 1129 | valueClassName={ | |
| 1130 | snapshot.power.thermalState === "serious" || | |
| 1131 | snapshot.power.thermalState === "critical" | |
| 1132 | ? "text-destructive" | |
| 1133 | : undefined | |
| 1134 | } | |
| 1135 | /> | |
| 1136 | </> | |
| 1137 | ) : ( | |
| 1138 | <div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-4 py-5"> | |
| 1139 | <div className="text-[13px] font-medium text-foreground"> | |
| 1140 | Desktop host signals not connected | |
| 1141 | </div> | |
| 1142 | <p className="mt-1.5 max-w-sm text-[11px] leading-relaxed text-muted-foreground/70"> | |
| 1143 | Power, idle, lock, and thermal state are supplied by the desktop host. Process | |
| 1144 | telemetry remains fully active in this browser session. | |
| 1145 | </p> | |
| 1146 | </div> | |
| 1147 | )} | |
| 1148 | </div> | |
| 1149 | <div className="border-t border-border/60 px-4 py-4 md:border-t-0 sm:px-5"> | |
| 1150 | <div className="mb-3 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70"> | |
| 1151 | <span className="flex size-6 items-center justify-center rounded-md bg-muted/60"> | |
| 1152 | <GaugeIcon className="size-3.5" /> | |
| 1153 | </span> | |
| 1154 | Collection health | |
| 1155 | </div> | |
| 1156 | {snapshot ? ( | |
| 1157 | <> | |
| 1158 | <HealthSource label="Native process monitor" health={snapshot.health.native} /> | |
| 1159 | <HealthSource label="Electron main process" health={snapshot.health.desktop} /> | |
| 1160 | <DetailRow | |
| 1161 | label="Collection time" | |
| 1162 | value={formatDurationMicros(snapshot.health.collectionDurationMicros)} | |
| 1163 | /> | |
| 1164 | <DetailRow | |
| 1165 | label="Process scan" | |
| 1166 | value={`${snapshot.health.retainedProcessCount}/${snapshot.health.scannedProcessCount} retained`} | |
| 1167 | /> | |
| 1168 | <DetailRow | |
| 1169 | label="Inaccessible" | |
| 1170 | value={String(snapshot.health.inaccessibleProcessCount)} | |
| 1171 | valueClassName={ | |
| 1172 | snapshot.health.inaccessibleProcessCount > 0 | |
| 1173 | ? "text-amber-600 dark:text-amber-300" | |
| 1174 | : undefined | |
| 1175 | } | |
| 1176 | /> | |
| 1177 | <DetailRow | |
| 1178 | label="Sidecar" | |
| 1179 | value={Option.match(snapshot.health.sidecarVersion, { | |
| 1180 | onNone: () => "Unavailable", | |
| 1181 | onSome: (version) => | |
| 1182 | `${version}${Option.match(snapshot.health.sidecarPid, { | |
| 1183 | onNone: () => "", | |
| 1184 | onSome: (pid) => ` · PID ${pid}`, | |
| 1185 | })}`, | |
| 1186 | })} | |
| 1187 | /> | |
| 1188 | <DetailRow label="Restarts" value={String(snapshot.health.restartCount)} /> | |
| 1189 | </> | |
| 1190 | ) : ( | |
| 1191 | <div className="py-4 text-xs text-muted-foreground"> | |
| 1192 | Waiting for collector health. | |
| 1193 | </div> | |
| 1194 | )} | |
| 1195 | </div> | |
| 1196 | </div> | |
| 1197 | </SettingsSection> | |
| 1198 | ||
| 1199 | <SettingsSection | |
| 1200 | title="Resource timeline" | |
| 1201 | icon={<HardDriveIcon className="size-4 text-muted-foreground" />} | |
| 1202 | headerAction={ | |
| 1203 | <div className="flex items-center gap-2"> | |
| 1204 | <HistoryWindowSelector selectedWindowMs={windowMs} onSelect={setWindowMs} /> | |
| 1205 | <Button | |
| 1206 | size="icon-xs" | |
| 1207 | variant="ghost" | |
| 1208 | className="size-5 rounded-sm p-0" | |
| 1209 | disabled={history.isPending} | |
| 1210 | onClick={history.refresh} | |
| 1211 | aria-label="Refresh resource history" | |
| 1212 | > | |
| 1213 | <RefreshCwIcon className={cn("size-3", history.isPending && "animate-spin")} /> | |
| 1214 | </Button> | |
| 1215 | </div> | |
| 1216 | } | |
| 1217 | > | |
| 1218 | <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)]"> | |
| 1219 | {history.error ? ( | |
| 1220 | <div className="flex items-start gap-2 border-b border-destructive/20 bg-destructive/5 px-4 py-3 text-xs text-destructive sm:px-5"> | |
| 1221 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | |
| 1222 | <span>{history.error}</span> | |
| 1223 | </div> | |
| 1224 | ) : null} | |
| 1225 | <ResourceHistoryChart buckets={history.data?.buckets ?? []} /> | |
| 1226 | <HistoryProcessTable processes={history.data?.topProcesses ?? []} /> | |
| 1227 | </div> | |
| 1228 | </SettingsSection> | |
| 1229 | ||
| 1230 | <SettingsSection | |
| 1231 | title="Live process tree" | |
| 1232 | icon={<CpuIcon className="size-4 text-muted-foreground" />} | |
| 1233 | headerAction={ | |
| 1234 | snapshot ? ( | |
| 1235 | <span className="text-[10px] text-muted-foreground/55"> | |
| 1236 | Identity: <span className="font-mono">PID + start time</span> | |
| 1237 | </span> | |
| 1238 | ) : null | |
| 1239 | } | |
| 1240 | > | |
| 1241 | <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)]"> | |
| 1242 | <ProcessTable | |
| 1243 | processes={snapshot?.processes ?? []} | |
| 1244 | signalingKeys={signalingKeys} | |
| 1245 | onSignal={signalProcess} | |
| 1246 | /> | |
| 1247 | </div> | |
| 1248 | </SettingsSection> | |
| 1249 | ||
| 1250 | <SettingsSection | |
| 1251 | title="Instrumented application I/O" | |
| 1252 | icon={<DatabaseIcon className="size-4 text-muted-foreground" />} | |
| 1253 | headerAction={ | |
| 1254 | <span className="text-[10px] text-muted-foreground/55">Logical bytes by operation</span> | |
| 1255 | } | |
| 1256 | > | |
| 1257 | <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)]"> | |
| 1258 | <div className="bg-muted/15 px-4 py-3 text-[11px] leading-relaxed text-muted-foreground sm:px-5"> | |
| 1259 | Native counters identify which process is reading or writing. These application-level | |
| 1260 | counters identify known T3 operations so process spikes can be correlated with specific | |
| 1261 | persistence and logging paths. | |
| 1262 | </div> | |
| 1263 | <AttributionTable entries={snapshot?.attribution.entries ?? []} /> | |
| 1264 | </div> | |
| 1265 | </SettingsSection> | |
| 1266 | </> | |
| 1267 | ); | |
| 1268 | } |
apps/web/src/components/settings/SettingsPanels.logic.test.ts+109| ... | ... | @@ -1,17 +1,126 @@ |
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | DEFAULT_UNIFIED_SETTINGS, | |
| 3 | 4 | ProviderDriverKind, |
| 4 | 5 | ProviderInstanceId, |
| 5 | 6 | type ProviderInstanceConfig, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 8 | import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 7 | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 11 | import { |
| 12 | backgroundActivitySharedPolicySettings, | |
| 9 | 13 | buildProviderInstanceUpdatePatch, |
| 10 | 14 | formatDiagnosticsDescription, |
| 15 | hasChangedBackgroundActivitySettings, | |
| 11 | 16 | isProjectGroupingEnabled, |
| 12 | 17 | projectGroupingModeFromToggle, |
| 18 | resolveBackgroundActivityProfileOption, | |
| 13 | 19 | } from "./SettingsPanels.logic"; |
| 14 | 20 | |
| 21 | describe("background activity settings restore", () => { | |
| 22 | it("detects legacy interval values even when the structured setting is at its default", () => { | |
| 23 | expect( | |
| 24 | hasChangedBackgroundActivitySettings({ | |
| 25 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | |
| 26 | backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, | |
| 27 | automaticGitFetchInterval: Duration.seconds(45), | |
| 28 | providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, | |
| 29 | }), | |
| 30 | ).toBe(true); | |
| 31 | expect( | |
| 32 | hasChangedBackgroundActivitySettings({ | |
| 33 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | |
| 34 | backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, | |
| 35 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | |
| 36 | providerHealthRefreshInterval: Duration.minutes(7), | |
| 37 | }), | |
| 38 | ).toBe(true); | |
| 39 | expect(hasChangedBackgroundActivitySettings(DEFAULT_UNIFIED_SETTINGS)).toBe(false); | |
| 40 | }); | |
| 41 | ||
| 42 | it("detects a legacy profile override so restoring defaults clears it", () => { | |
| 43 | expect( | |
| 44 | hasChangedBackgroundActivitySettings({ | |
| 45 | ...DEFAULT_UNIFIED_SETTINGS, | |
| 46 | backgroundActivityProfile: "performance", | |
| 47 | }), | |
| 48 | ).toBe(true); | |
| 49 | }); | |
| 50 | ||
| 51 | it("shows the effective legacy preset and marks custom legacy intervals as advanced", () => { | |
| 52 | const performance = getBackgroundActivityPresetSettings("performance"); | |
| 53 | expect( | |
| 54 | resolveBackgroundActivityProfileOption({ | |
| 55 | ...DEFAULT_UNIFIED_SETTINGS, | |
| 56 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | |
| 57 | backgroundActivityProfile: "performance", | |
| 58 | automaticGitFetchInterval: performance.automaticGitFetchInterval, | |
| 59 | providerHealthRefreshInterval: performance.providerHealthRefreshInterval, | |
| 60 | }), | |
| 61 | ).toBe("performance"); | |
| 62 | ||
| 63 | expect( | |
| 64 | resolveBackgroundActivityProfileOption({ | |
| 65 | ...DEFAULT_UNIFIED_SETTINGS, | |
| 66 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | |
| 67 | backgroundActivityProfile: "performance", | |
| 68 | automaticGitFetchInterval: Duration.seconds(45), | |
| 69 | providerHealthRefreshInterval: Duration.minutes(7), | |
| 70 | }), | |
| 71 | ).toBe("advanced"); | |
| 72 | }); | |
| 73 | ||
| 74 | it("preserves advanced overrides when the shared policy changes", () => { | |
| 75 | const automaticGitFetchInterval = Duration.seconds(42); | |
| 76 | expect( | |
| 77 | backgroundActivitySharedPolicySettings( | |
| 78 | { | |
| 79 | ...DEFAULT_UNIFIED_SETTINGS, | |
| 80 | backgroundActivity: { | |
| 81 | schemaVersion: 1, | |
| 82 | profile: "custom", | |
| 83 | baseProfile: "balanced", | |
| 84 | overrides: { | |
| 85 | automaticGitFetchInterval, | |
| 86 | pauseWhenOnBattery: true, | |
| 87 | }, | |
| 88 | }, | |
| 89 | }, | |
| 90 | "performance", | |
| 91 | ), | |
| 92 | ).toEqual({ | |
| 93 | schemaVersion: 1, | |
| 94 | profile: "custom", | |
| 95 | baseProfile: "performance", | |
| 96 | overrides: { | |
| 97 | automaticGitFetchInterval, | |
| 98 | pauseWhenOnBattery: true, | |
| 99 | }, | |
| 100 | }); | |
| 101 | }); | |
| 102 | ||
| 103 | it("materializes legacy advanced overrides before changing the shared policy", () => { | |
| 104 | const automaticGitFetchInterval = Duration.seconds(42); | |
| 105 | expect( | |
| 106 | backgroundActivitySharedPolicySettings( | |
| 107 | { | |
| 108 | ...DEFAULT_UNIFIED_SETTINGS, | |
| 109 | automaticGitFetchInterval, | |
| 110 | }, | |
| 111 | "battery-saver", | |
| 112 | ), | |
| 113 | ).toEqual({ | |
| 114 | schemaVersion: 1, | |
| 115 | profile: "custom", | |
| 116 | baseProfile: "battery-saver", | |
| 117 | overrides: { | |
| 118 | automaticGitFetchInterval, | |
| 119 | }, | |
| 120 | }); | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 15 | 124 | describe("project grouping toggle", () => { |
| 16 | 125 | it("enables repository grouping and disables into separate projects", () => { |
| 17 | 126 | expect(isProjectGroupingEnabled("repository")).toBe(true); |
apps/web/src/components/settings/SettingsPanels.logic.ts+67| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import type { |
| 2 | BackgroundActivityProfile, | |
| 3 | BackgroundActivitySettings, | |
| 2 | 4 | ProviderDriverKind, |
| 3 | 5 | ProviderInstanceConfig, |
| 4 | 6 | ProviderInstanceId, |
| ... | ... | @@ -7,6 +9,12 @@ import type { |
| 7 | 9 | UnifiedSettings, |
| 8 | 10 | } from "@t3tools/contracts"; |
| 9 | 11 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 12 | import { | |
| 13 | normalizeBackgroundActivitySettings, | |
| 14 | normalizeServerBackgroundActivitySettings, | |
| 15 | resolveServerBackgroundActivitySettings, | |
| 16 | } from "@t3tools/shared/backgroundActivitySettings"; | |
| 17 | import * as Equal from "effect/Equal"; | |
| 10 | 18 | |
| 11 | 19 | export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { |
| 12 | 20 | return mode !== "separate"; |
| ... | ... | @@ -41,6 +49,65 @@ export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingM |
| 41 | 49 | } |
| 42 | 50 | } |
| 43 | 51 | |
| 52 | export function hasChangedBackgroundActivitySettings( | |
| 53 | settings: Pick< | |
| 54 | UnifiedSettings, | |
| 55 | | "backgroundActivity" | |
| 56 | | "backgroundActivityProfile" | |
| 57 | | "automaticGitFetchInterval" | |
| 58 | | "providerHealthRefreshInterval" | |
| 59 | >, | |
| 60 | ): boolean { | |
| 61 | return ( | |
| 62 | !Equal.equals(settings.backgroundActivity, DEFAULT_UNIFIED_SETTINGS.backgroundActivity) || | |
| 63 | settings.backgroundActivityProfile !== DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile || | |
| 64 | !Equal.equals( | |
| 65 | settings.automaticGitFetchInterval, | |
| 66 | DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | |
| 67 | ) || | |
| 68 | !Equal.equals( | |
| 69 | settings.providerHealthRefreshInterval, | |
| 70 | DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, | |
| 71 | ) | |
| 72 | ); | |
| 73 | } | |
| 74 | ||
| 75 | export function resolveBackgroundActivityProfileOption( | |
| 76 | settings: ServerSettings, | |
| 77 | ): BackgroundActivityProfile | "advanced" { | |
| 78 | const resolved = resolveServerBackgroundActivitySettings(settings); | |
| 79 | const normalized = normalizeBackgroundActivitySettings({ | |
| 80 | schemaVersion: 1, | |
| 81 | profile: "custom", | |
| 82 | baseProfile: resolved.profile, | |
| 83 | overrides: { | |
| 84 | automaticGitFetchInterval: resolved.automaticGitFetchInterval, | |
| 85 | providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, | |
| 86 | hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, | |
| 87 | hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, | |
| 88 | idleClientTtl: resolved.idleClientTtl, | |
| 89 | pauseWhenHostLocked: resolved.pauseWhenHostLocked, | |
| 90 | pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, | |
| 91 | pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, | |
| 92 | pauseWhenOnBattery: resolved.pauseWhenOnBattery, | |
| 93 | }, | |
| 94 | }); | |
| 95 | return normalized.profile === "custom" ? "advanced" : normalized.profile; | |
| 96 | } | |
| 97 | ||
| 98 | export function backgroundActivitySharedPolicySettings( | |
| 99 | settings: ServerSettings, | |
| 100 | profile: BackgroundActivityProfile, | |
| 101 | ): BackgroundActivitySettings { | |
| 102 | const normalized = normalizeServerBackgroundActivitySettings(settings); | |
| 103 | return { | |
| 104 | schemaVersion: 1, | |
| 105 | profile: "custom", | |
| 106 | baseProfile: profile, | |
| 107 | overrides: normalized.profile === "custom" ? normalized.overrides : {}, | |
| 108 | }; | |
| 109 | } | |
| 110 | ||
| 44 | 111 | function collapseOtelSignalsUrl(input: { |
| 45 | 112 | readonly tracesUrl: string; |
| 46 | 113 | readonly metricsUrl: string; |
apps/web/src/components/settings/SettingsPanels.tsx+596-6| ... | ... | @@ -1,10 +1,20 @@ |
| 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; | |
| 1 | import { | |
| 2 | ArchiveIcon, | |
| 3 | ArchiveX, | |
| 4 | InfoIcon, | |
| 5 | LoaderIcon, | |
| 6 | PlusIcon, | |
| 7 | RefreshCwIcon, | |
| 8 | SettingsIcon, | |
| 9 | } from "lucide-react"; | |
| 2 | 10 | import { Link } from "@tanstack/react-router"; |
| 3 | 11 | import type { CSSProperties } from "react"; |
| 4 | 12 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 5 | 13 | import { useAtomValue } from "@effect/atom-react"; |
| 6 | 14 | import { |
| 7 | 15 | defaultInstanceIdForDriver, |
| 16 | type BackgroundActivityProfile, | |
| 17 | type BackgroundActivitySettings, | |
| 8 | 18 | type DesktopUpdateChannel, |
| 9 | 19 | PROVIDER_DISPLAY_NAMES, |
| 10 | 20 | ProviderDriverKind, |
| ... | ... | @@ -27,6 +37,11 @@ import { |
| 27 | 37 | MAX_GLASS_OPACITY, |
| 28 | 38 | MIN_GLASS_OPACITY, |
| 29 | 39 | } from "@t3tools/contracts/settings"; |
| 40 | import { | |
| 41 | getBackgroundActivityBaseProfile, | |
| 42 | getBackgroundActivityPresetSettings, | |
| 43 | resolveServerBackgroundActivitySettings, | |
| 44 | } from "@t3tools/shared/backgroundActivitySettings"; | |
| 30 | 45 | import { createModelSelection } from "@t3tools/shared/model"; |
| 31 | 46 | import * as Arr from "effect/Array"; |
| 32 | 47 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -72,7 +87,23 @@ import { useProjects } from "../../state/entities"; |
| 72 | 87 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 73 | 88 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 74 | 89 | import { Button } from "../ui/button"; |
| 90 | import { | |
| 91 | Dialog, | |
| 92 | DialogDescription, | |
| 93 | DialogFooter, | |
| 94 | DialogHeader, | |
| 95 | DialogPanel, | |
| 96 | DialogPopup, | |
| 97 | DialogTitle, | |
| 98 | } from "../ui/dialog"; | |
| 75 | 99 | import { DraftInput } from "../ui/draft-input"; |
| 100 | import { | |
| 101 | NumberField, | |
| 102 | NumberFieldDecrement, | |
| 103 | NumberFieldGroup, | |
| 104 | NumberFieldIncrement, | |
| 105 | NumberFieldInput, | |
| 106 | } from "../ui/number-field"; | |
| 76 | 107 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 77 | 108 | import { Switch } from "../ui/switch"; |
| 78 | 109 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| ... | ... | @@ -88,12 +119,15 @@ import { |
| 88 | 119 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 89 | 120 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 90 | 121 | import { |
| 122 | backgroundActivitySharedPolicySettings, | |
| 91 | 123 | buildProviderInstanceUpdatePatch, |
| 92 | 124 | formatDiagnosticsDescription, |
| 125 | hasChangedBackgroundActivitySettings, | |
| 93 | 126 | isProjectGroupingEnabled, |
| 94 | 127 | projectGroupingModeFromToggle, |
| 95 | 128 | readLastEnabledProjectGroupingMode, |
| 96 | 129 | rememberEnabledProjectGroupingMode, |
| 130 | resolveBackgroundActivityProfileOption, | |
| 97 | 131 | } from "./SettingsPanels.logic"; |
| 98 | 132 | import { |
| 99 | 133 | SettingResetButton, |
| ... | ... | @@ -132,7 +166,129 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 132 | 166 | "24-hour": "24-hour", |
| 133 | 167 | } as const; |
| 134 | 168 | |
| 169 | const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = { | |
| 170 | balanced: "Balanced", | |
| 171 | performance: "Performance", | |
| 172 | "battery-saver": "Battery saver", | |
| 173 | }; | |
| 174 | ||
| 175 | type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; | |
| 176 | type BackgroundActivityOverridePatch = Partial<{ | |
| 177 | [K in keyof BackgroundActivitySettings["overrides"]]: | |
| 178 | | BackgroundActivitySettings["overrides"][K] | |
| 179 | | undefined; | |
| 180 | }>; | |
| 181 | ||
| 182 | const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = { | |
| 183 | ...BACKGROUND_ACTIVITY_PROFILE_LABELS, | |
| 184 | advanced: "Advanced", | |
| 185 | }; | |
| 186 | ||
| 187 | const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile, string> = { | |
| 188 | balanced: | |
| 189 | "Pauses background probes when clients are idle, the host is locked, or low power mode is active.", | |
| 190 | performance: "Allows scoped background probes while any subscribed client remains connected.", | |
| 191 | "battery-saver": "Also pauses background probes when the host or client is on battery.", | |
| 192 | }; | |
| 193 | ||
| 194 | const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = | |
| 195 | "Uses custom background intervals with the selected shared power policy."; | |
| 196 | ||
| 197 | const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; | |
| 135 | 198 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 199 | const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ | |
| 200 | readonly key: | |
| 201 | | "pauseWhenHostLocked" | |
| 202 | | "pauseWhenHostLowPower" | |
| 203 | | "pauseWhenClientLowPower" | |
| 204 | | "pauseWhenOnBattery"; | |
| 205 | readonly label: string; | |
| 206 | }> = [ | |
| 207 | { key: "pauseWhenHostLocked", label: "Pause when host is locked" }, | |
| 208 | { key: "pauseWhenHostLowPower", label: "Pause on host low power" }, | |
| 209 | { key: "pauseWhenClientLowPower", label: "Pause on client low power" }, | |
| 210 | { key: "pauseWhenOnBattery", label: "Pause on battery" }, | |
| 211 | ]; | |
| 212 | ||
| 213 | function durationToSeconds(duration: Duration.Duration): number { | |
| 214 | return Math.round(Duration.toMillis(duration) / 1_000); | |
| 215 | } | |
| 216 | ||
| 217 | function normalizeIntervalSeconds(value: number | null, minimum = 0): number { | |
| 218 | if (value === null || !Number.isFinite(value)) { | |
| 219 | return minimum; | |
| 220 | } | |
| 221 | return Math.max(minimum, Math.round(value)); | |
| 222 | } | |
| 223 | ||
| 224 | function resetBackgroundActivitySettings() { | |
| 225 | return { | |
| 226 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | |
| 227 | }; | |
| 228 | } | |
| 229 | ||
| 230 | function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { | |
| 231 | return { | |
| 232 | backgroundActivity: { | |
| 233 | schemaVersion: 1 as const, | |
| 234 | profile, | |
| 235 | overrides: {}, | |
| 236 | }, | |
| 237 | }; | |
| 238 | } | |
| 239 | ||
| 240 | function backgroundActivityOverrideSettings( | |
| 241 | current: BackgroundActivitySettings, | |
| 242 | resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>, | |
| 243 | overrides: BackgroundActivityOverridePatch, | |
| 244 | ) { | |
| 245 | const nextOverrides: BackgroundActivityOverridePatch = { | |
| 246 | automaticGitFetchInterval: resolved.automaticGitFetchInterval, | |
| 247 | providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, | |
| 248 | hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, | |
| 249 | hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, | |
| 250 | idleClientTtl: resolved.idleClientTtl, | |
| 251 | pauseWhenHostLocked: resolved.pauseWhenHostLocked, | |
| 252 | pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, | |
| 253 | pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, | |
| 254 | pauseWhenOnBattery: resolved.pauseWhenOnBattery, | |
| 255 | ...overrides, | |
| 256 | }; | |
| 257 | for (const [key, value] of Object.entries(nextOverrides)) { | |
| 258 | if (value === undefined) { | |
| 259 | delete nextOverrides[key as keyof typeof nextOverrides]; | |
| 260 | } | |
| 261 | } | |
| 262 | return { | |
| 263 | backgroundActivity: { | |
| 264 | schemaVersion: 1 as const, | |
| 265 | profile: "custom" as const, | |
| 266 | baseProfile: getBackgroundActivityBaseProfile(current), | |
| 267 | overrides: nextOverrides as BackgroundActivitySettings["overrides"], | |
| 268 | }, | |
| 269 | }; | |
| 270 | } | |
| 271 | ||
| 272 | function PolicyTooltip({ children }: { readonly children: string }) { | |
| 273 | return ( | |
| 274 | <Tooltip> | |
| 275 | <TooltipTrigger | |
| 276 | render={ | |
| 277 | <button | |
| 278 | type="button" | |
| 279 | className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground" | |
| 280 | aria-label="Background policy details" | |
| 281 | > | |
| 282 | <InfoIcon className="size-3.5" /> | |
| 283 | </button> | |
| 284 | } | |
| 285 | /> | |
| 286 | <TooltipPopup side="top" className="max-w-72"> | |
| 287 | {children} | |
| 288 | </TooltipPopup> | |
| 289 | </Tooltip> | |
| 290 | ); | |
| 291 | } | |
| 136 | 292 | |
| 137 | 293 | function withoutProviderInstanceKey<V>( |
| 138 | 294 | record: Readonly<Record<ProviderInstanceId, V>> | undefined, |
| ... | ... | @@ -408,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 408 | 564 | settings.textGenerationModelSelection ?? null, |
| 409 | 565 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 410 | 566 | ); |
| 567 | const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); | |
| 411 | 568 | |
| 412 | 569 | const changedSettingLabels = useMemo( |
| 413 | 570 | () => [ |
| ... | ... | @@ -441,10 +598,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 441 | 598 | DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks |
| 442 | 599 | ? ["Provider update checks"] |
| 443 | 600 | : []), |
| 444 | ...(Duration.toMillis(settings.automaticGitFetchInterval) !== | |
| 445 | Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) | |
| 446 | ? ["Automatic Git fetch interval"] | |
| 447 | : []), | |
| 601 | ...(isBackgroundActivityDirty ? ["Background activity"] : []), | |
| 448 | 602 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode |
| 449 | 603 | ? ["New thread mode"] |
| 450 | 604 | : []), |
| ... | ... | @@ -465,6 +619,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 465 | 619 | ], |
| 466 | 620 | [ |
| 467 | 621 | isTextGenerationModelDirty, |
| 622 | isBackgroundActivityDirty, | |
| 468 | 623 | settings.autoOpenPlanSidebar, |
| 469 | 624 | settings.confirmThreadArchive, |
| 470 | 625 | settings.confirmThreadDelete, |
| ... | ... | @@ -474,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 474 | 629 | settings.diffIgnoreWhitespace, |
| 475 | 630 | settings.environmentIdentificationMode, |
| 476 | 631 | settings.glassOpacity, |
| 477 | settings.automaticGitFetchInterval, | |
| 478 | 632 | settings.enableAssistantStreaming, |
| 479 | 633 | settings.enableProviderUpdateChecks, |
| 480 | 634 | settings.sidebarProjectGroupingMode, |
| ... | ... | @@ -507,7 +661,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 507 | 661 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, |
| 508 | 662 | enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, |
| 509 | 663 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 664 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | |
| 665 | backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, | |
| 510 | 666 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, |
| 667 | providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, | |
| 511 | 668 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, |
| 512 | 669 | newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, |
| 513 | 670 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| ... | ... | @@ -524,6 +681,272 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 524 | 681 | }; |
| 525 | 682 | } |
| 526 | 683 | |
| 684 | function BackgroundActivityAdvancedDialog({ | |
| 685 | open, | |
| 686 | onOpenChange, | |
| 687 | }: { | |
| 688 | readonly open: boolean; | |
| 689 | readonly onOpenChange: (open: boolean) => void; | |
| 690 | }) { | |
| 691 | const settings = usePrimarySettings(); | |
| 692 | const updateSettings = useUpdatePrimarySettings(); | |
| 693 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | |
| 694 | const activeProfile = resolvedBackgroundActivity.profile; | |
| 695 | const automaticGitFetchIntervalSeconds = durationToSeconds( | |
| 696 | resolvedBackgroundActivity.automaticGitFetchInterval, | |
| 697 | ); | |
| 698 | const providerHealthRefreshIntervalSeconds = durationToSeconds( | |
| 699 | resolvedBackgroundActivity.providerHealthRefreshInterval, | |
| 700 | ); | |
| 701 | const hostPowerMonitorActiveIntervalSeconds = durationToSeconds( | |
| 702 | resolvedBackgroundActivity.hostPowerMonitorActiveInterval, | |
| 703 | ); | |
| 704 | const hostPowerMonitorIdleIntervalSeconds = durationToSeconds( | |
| 705 | resolvedBackgroundActivity.hostPowerMonitorIdleInterval, | |
| 706 | ); | |
| 707 | ||
| 708 | return ( | |
| 709 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 710 | <DialogPopup className="max-w-xl"> | |
| 711 | <DialogHeader> | |
| 712 | <DialogTitle>Background Activity</DialogTitle> | |
| 713 | <DialogDescription> | |
| 714 | Tune the shared power policy and the background intervals that feed it. | |
| 715 | </DialogDescription> | |
| 716 | </DialogHeader> | |
| 717 | <DialogPanel className="space-y-0 px-6 pb-5"> | |
| 718 | <div className="overflow-hidden rounded-xl border bg-card text-card-foreground"> | |
| 719 | <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 720 | <div className="min-w-0 space-y-1"> | |
| 721 | <div className="text-sm font-medium">Shared policy</div> | |
| 722 | <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 723 | Controls whether background work may run after a subscribed interval fires. | |
| 724 | </p> | |
| 725 | </div> | |
| 726 | <Select | |
| 727 | value={activeProfile} | |
| 728 | onValueChange={(value) => { | |
| 729 | if ( | |
| 730 | value === "balanced" || | |
| 731 | value === "performance" || | |
| 732 | value === "battery-saver" | |
| 733 | ) { | |
| 734 | updateSettings({ | |
| 735 | backgroundActivity: backgroundActivitySharedPolicySettings(settings, value), | |
| 736 | }); | |
| 737 | } | |
| 738 | }} | |
| 739 | > | |
| 740 | <SelectTrigger className="w-full sm:w-40" aria-label="Shared background policy"> | |
| 741 | <SelectValue>{BACKGROUND_ACTIVITY_PROFILE_LABELS[activeProfile]}</SelectValue> | |
| 742 | </SelectTrigger> | |
| 743 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 744 | <SelectItem hideIndicator value="balanced"> | |
| 745 | {BACKGROUND_ACTIVITY_PROFILE_LABELS.balanced} | |
| 746 | </SelectItem> | |
| 747 | <SelectItem hideIndicator value="performance"> | |
| 748 | {BACKGROUND_ACTIVITY_PROFILE_LABELS.performance} | |
| 749 | </SelectItem> | |
| 750 | <SelectItem hideIndicator value="battery-saver"> | |
| 751 | {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]} | |
| 752 | </SelectItem> | |
| 753 | </SelectPopup> | |
| 754 | </Select> | |
| 755 | </div> | |
| 756 | ||
| 757 | <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 758 | <div className="min-w-0 space-y-1"> | |
| 759 | <div className="text-sm font-medium">Git fetch interval</div> | |
| 760 | <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 761 | Refresh remote branch status in the background. | |
| 762 | </p> | |
| 763 | </div> | |
| 764 | <div className="flex shrink-0 items-center gap-2"> | |
| 765 | <NumberField | |
| 766 | value={automaticGitFetchIntervalSeconds} | |
| 767 | min={0} | |
| 768 | step={5} | |
| 769 | size="sm" | |
| 770 | className="w-32" | |
| 771 | onValueChange={(value) => | |
| 772 | updateSettings( | |
| 773 | backgroundActivityOverrideSettings( | |
| 774 | settings.backgroundActivity, | |
| 775 | resolvedBackgroundActivity, | |
| 776 | { | |
| 777 | automaticGitFetchInterval: Duration.seconds( | |
| 778 | normalizeIntervalSeconds(value), | |
| 779 | ), | |
| 780 | }, | |
| 781 | ), | |
| 782 | ) | |
| 783 | } | |
| 784 | > | |
| 785 | <NumberFieldGroup> | |
| 786 | <NumberFieldDecrement aria-label="Decrease Git fetch interval" /> | |
| 787 | <NumberFieldInput aria-label="Git fetch interval in seconds" /> | |
| 788 | <NumberFieldIncrement aria-label="Increase Git fetch interval" /> | |
| 789 | </NumberFieldGroup> | |
| 790 | </NumberField> | |
| 791 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 792 | </div> | |
| 793 | </div> | |
| 794 | ||
| 795 | <div className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 796 | <div className="min-w-0 space-y-1"> | |
| 797 | <div className="text-sm font-medium">Provider health interval</div> | |
| 798 | <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 799 | Refresh provider availability, versions, auth state, and model metadata. | |
| 800 | </p> | |
| 801 | </div> | |
| 802 | <div className="flex shrink-0 items-center gap-2"> | |
| 803 | <NumberField | |
| 804 | value={providerHealthRefreshIntervalSeconds} | |
| 805 | min={0} | |
| 806 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 807 | size="sm" | |
| 808 | className="w-32" | |
| 809 | onValueChange={(value) => | |
| 810 | updateSettings( | |
| 811 | backgroundActivityOverrideSettings( | |
| 812 | settings.backgroundActivity, | |
| 813 | resolvedBackgroundActivity, | |
| 814 | { | |
| 815 | providerHealthRefreshInterval: Duration.seconds( | |
| 816 | normalizeIntervalSeconds(value), | |
| 817 | ), | |
| 818 | }, | |
| 819 | ), | |
| 820 | ) | |
| 821 | } | |
| 822 | > | |
| 823 | <NumberFieldGroup> | |
| 824 | <NumberFieldDecrement aria-label="Decrease provider health interval" /> | |
| 825 | <NumberFieldInput aria-label="Provider health interval in seconds" /> | |
| 826 | <NumberFieldIncrement aria-label="Increase provider health interval" /> | |
| 827 | </NumberFieldGroup> | |
| 828 | </NumberField> | |
| 829 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 830 | </div> | |
| 831 | </div> | |
| 832 | ||
| 833 | <div className="flex flex-col gap-3 border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 834 | <div className="min-w-0 space-y-1"> | |
| 835 | <div className="text-sm font-medium">Host power monitor</div> | |
| 836 | <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 837 | Poll host power state while clients are active. | |
| 838 | </p> | |
| 839 | </div> | |
| 840 | <div className="flex shrink-0 items-center gap-2"> | |
| 841 | <NumberField | |
| 842 | value={hostPowerMonitorActiveIntervalSeconds} | |
| 843 | min={5} | |
| 844 | step={5} | |
| 845 | size="sm" | |
| 846 | className="w-32" | |
| 847 | onValueChange={(value) => | |
| 848 | updateSettings( | |
| 849 | backgroundActivityOverrideSettings( | |
| 850 | settings.backgroundActivity, | |
| 851 | resolvedBackgroundActivity, | |
| 852 | { | |
| 853 | hostPowerMonitorActiveInterval: Duration.seconds( | |
| 854 | normalizeIntervalSeconds(value, 5), | |
| 855 | ), | |
| 856 | }, | |
| 857 | ), | |
| 858 | ) | |
| 859 | } | |
| 860 | > | |
| 861 | <NumberFieldGroup> | |
| 862 | <NumberFieldDecrement aria-label="Decrease active host power interval" /> | |
| 863 | <NumberFieldInput aria-label="Active host power interval in seconds" /> | |
| 864 | <NumberFieldIncrement aria-label="Increase active host power interval" /> | |
| 865 | </NumberFieldGroup> | |
| 866 | </NumberField> | |
| 867 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 868 | </div> | |
| 869 | </div> | |
| 870 | ||
| 871 | <div className="flex flex-col gap-3 border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 872 | <div className="min-w-0 space-y-1"> | |
| 873 | <div className="text-sm font-medium">Idle host monitor</div> | |
| 874 | <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 875 | Poll host power state when no foreground client is active. | |
| 876 | </p> | |
| 877 | </div> | |
| 878 | <div className="flex shrink-0 items-center gap-2"> | |
| 879 | <NumberField | |
| 880 | value={hostPowerMonitorIdleIntervalSeconds} | |
| 881 | min={5} | |
| 882 | step={30} | |
| 883 | size="sm" | |
| 884 | className="w-32" | |
| 885 | onValueChange={(value) => | |
| 886 | updateSettings( | |
| 887 | backgroundActivityOverrideSettings( | |
| 888 | settings.backgroundActivity, | |
| 889 | resolvedBackgroundActivity, | |
| 890 | { | |
| 891 | hostPowerMonitorIdleInterval: Duration.seconds( | |
| 892 | normalizeIntervalSeconds(value, 5), | |
| 893 | ), | |
| 894 | }, | |
| 895 | ), | |
| 896 | ) | |
| 897 | } | |
| 898 | > | |
| 899 | <NumberFieldGroup> | |
| 900 | <NumberFieldDecrement aria-label="Decrease idle host power interval" /> | |
| 901 | <NumberFieldInput aria-label="Idle host power interval in seconds" /> | |
| 902 | <NumberFieldIncrement aria-label="Increase idle host power interval" /> | |
| 903 | </NumberFieldGroup> | |
| 904 | </NumberField> | |
| 905 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 906 | </div> | |
| 907 | </div> | |
| 908 | ||
| 909 | <div className="grid gap-0 border-t sm:grid-cols-2"> | |
| 910 | {BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES.map(({ key, label }) => ( | |
| 911 | <label | |
| 912 | key={key} | |
| 913 | className="flex items-center justify-between gap-3 border-b px-4 py-3 last:border-b-0 sm:border-r sm:even:border-r-0" | |
| 914 | > | |
| 915 | <span className="text-sm font-medium">{label}</span> | |
| 916 | <Switch | |
| 917 | checked={resolvedBackgroundActivity[key]} | |
| 918 | onCheckedChange={(checked) => | |
| 919 | updateSettings( | |
| 920 | backgroundActivityOverrideSettings( | |
| 921 | settings.backgroundActivity, | |
| 922 | resolvedBackgroundActivity, | |
| 923 | { | |
| 924 | [key]: Boolean(checked), | |
| 925 | }, | |
| 926 | ), | |
| 927 | ) | |
| 928 | } | |
| 929 | aria-label={label} | |
| 930 | /> | |
| 931 | </label> | |
| 932 | ))} | |
| 933 | </div> | |
| 934 | </div> | |
| 935 | </DialogPanel> | |
| 936 | <DialogFooter> | |
| 937 | <Button | |
| 938 | variant="outline" | |
| 939 | onClick={() => updateSettings(resetBackgroundActivitySettings())} | |
| 940 | > | |
| 941 | Reset all | |
| 942 | </Button> | |
| 943 | <Button onClick={() => onOpenChange(false)}>Done</Button> | |
| 944 | </DialogFooter> | |
| 945 | </DialogPopup> | |
| 946 | </Dialog> | |
| 947 | ); | |
| 948 | } | |
| 949 | ||
| 527 | 950 | export function AppearanceSettingsPanel() { |
| 528 | 951 | const { theme, setTheme } = useTheme(); |
| 529 | 952 | const settings = usePrimarySettings(); |
| ... | ... | @@ -693,6 +1116,7 @@ export function AppearanceSettingsPanel() { |
| 693 | 1116 | export function GeneralSettingsPanel() { |
| 694 | 1117 | const settings = usePrimarySettings(); |
| 695 | 1118 | const updateSettings = useUpdatePrimarySettings(); |
| 1119 | const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); | |
| 696 | 1120 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( |
| 697 | 1121 | readLastEnabledProjectGroupingMode(), |
| 698 | 1122 | ); |
| ... | ... | @@ -728,6 +1152,19 @@ export function GeneralSettingsPanel() { |
| 728 | 1152 | settings.textGenerationModelSelection ?? null, |
| 729 | 1153 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 730 | 1154 | ); |
| 1155 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | |
| 1156 | const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; | |
| 1157 | const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); | |
| 1158 | const backgroundActivityDescription = | |
| 1159 | backgroundActivityProfileOption === "advanced" | |
| 1160 | ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Current shared policy: ${ | |
| 1161 | BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] | |
| 1162 | }.` | |
| 1163 | : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; | |
| 1164 | const canResetBackgroundActivity = !Equal.equals( | |
| 1165 | settings.backgroundActivity, | |
| 1166 | DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | |
| 1167 | ); | |
| 731 | 1168 | |
| 732 | 1169 | return ( |
| 733 | 1170 | <SettingsPageContainer> |
| ... | ... | @@ -890,6 +1327,88 @@ export function GeneralSettingsPanel() { |
| 890 | 1327 | } |
| 891 | 1328 | /> |
| 892 | 1329 | |
| 1330 | <SettingsRow | |
| 1331 | title={ | |
| 1332 | <span className="inline-flex items-center gap-1.5"> | |
| 1333 | Background activity | |
| 1334 | <PolicyTooltip> | |
| 1335 | This shared policy gates background work such as Git refreshes and provider health | |
| 1336 | probes after their individual intervals elapse. | |
| 1337 | </PolicyTooltip> | |
| 1338 | </span> | |
| 1339 | } | |
| 1340 | description={backgroundActivityDescription} | |
| 1341 | resetAction={ | |
| 1342 | canResetBackgroundActivity ? ( | |
| 1343 | <SettingResetButton | |
| 1344 | label="background activity" | |
| 1345 | onClick={() => updateSettings(resetBackgroundActivitySettings())} | |
| 1346 | /> | |
| 1347 | ) : null | |
| 1348 | } | |
| 1349 | control={ | |
| 1350 | <> | |
| 1351 | <Select | |
| 1352 | value={backgroundActivityProfileOption} | |
| 1353 | onValueChange={(value) => { | |
| 1354 | if (value === "advanced") { | |
| 1355 | setBackgroundActivityDialogOpen(true); | |
| 1356 | return; | |
| 1357 | } | |
| 1358 | if ( | |
| 1359 | value === "balanced" || | |
| 1360 | value === "performance" || | |
| 1361 | value === "battery-saver" | |
| 1362 | ) { | |
| 1363 | updateSettings(backgroundActivityProfileSettings(value)); | |
| 1364 | } | |
| 1365 | }} | |
| 1366 | > | |
| 1367 | <SelectTrigger className="w-full sm:w-40" aria-label="Background activity profile"> | |
| 1368 | <SelectValue> | |
| 1369 | {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[backgroundActivityProfileOption]} | |
| 1370 | </SelectValue> | |
| 1371 | </SelectTrigger> | |
| 1372 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 1373 | <SelectItem hideIndicator value="balanced"> | |
| 1374 | {BACKGROUND_ACTIVITY_PROFILE_LABELS.balanced} | |
| 1375 | </SelectItem> | |
| 1376 | <SelectItem hideIndicator value="performance"> | |
| 1377 | {BACKGROUND_ACTIVITY_PROFILE_LABELS.performance} | |
| 1378 | </SelectItem> | |
| 1379 | <SelectItem hideIndicator value="battery-saver"> | |
| 1380 | {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]} | |
| 1381 | </SelectItem> | |
| 1382 | <SelectItem hideIndicator value="advanced"> | |
| 1383 | {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} | |
| 1384 | </SelectItem> | |
| 1385 | </SelectPopup> | |
| 1386 | </Select> | |
| 1387 | {backgroundActivityProfileOption === "advanced" ? ( | |
| 1388 | <Tooltip> | |
| 1389 | <TooltipTrigger | |
| 1390 | render={ | |
| 1391 | <Button | |
| 1392 | size="icon-sm" | |
| 1393 | variant="outline" | |
| 1394 | aria-label="Configure advanced background activity" | |
| 1395 | onClick={() => setBackgroundActivityDialogOpen(true)} | |
| 1396 | > | |
| 1397 | <SettingsIcon className="size-4" /> | |
| 1398 | </Button> | |
| 1399 | } | |
| 1400 | /> | |
| 1401 | <TooltipPopup side="top">Configure background activity</TooltipPopup> | |
| 1402 | </Tooltip> | |
| 1403 | ) : null} | |
| 1404 | <BackgroundActivityAdvancedDialog | |
| 1405 | open={backgroundActivityDialogOpen} | |
| 1406 | onOpenChange={setBackgroundActivityDialogOpen} | |
| 1407 | /> | |
| 1408 | </> | |
| 1409 | } | |
| 1410 | /> | |
| 1411 | ||
| 893 | 1412 | <SettingsRow |
| 894 | 1413 | title="Auto-open task panel" |
| 895 | 1414 | description="Open the right-side plan and task panel automatically when steps appear." |
| ... | ... | @@ -1207,6 +1726,14 @@ export function ProviderSettingsPanel() { |
| 1207 | 1726 | ); |
| 1208 | 1727 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); |
| 1209 | 1728 | const textGenInstanceId = textGenerationModelSelection.instanceId; |
| 1729 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | |
| 1730 | const providerHealthPreset = getBackgroundActivityPresetSettings( | |
| 1731 | resolvedBackgroundActivity.profile, | |
| 1732 | ).providerHealthRefreshInterval; | |
| 1733 | const providerHealthRefreshIntervalSeconds = durationToSeconds( | |
| 1734 | resolvedBackgroundActivity.providerHealthRefreshInterval, | |
| 1735 | ); | |
| 1736 | const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); | |
| 1210 | 1737 | const lastCheckedAt = |
| 1211 | 1738 | serverProviders.length > 0 |
| 1212 | 1739 | ? serverProviders.reduce( |
| ... | ... | @@ -1508,6 +2035,69 @@ export function ProviderSettingsPanel() { |
| 1508 | 2035 | </div> |
| 1509 | 2036 | } |
| 1510 | 2037 | > |
| 2038 | <SettingsRow | |
| 2039 | title={ | |
| 2040 | <span className="inline-flex items-center gap-1.5"> | |
| 2041 | Health check interval | |
| 2042 | <PolicyTooltip> | |
| 2043 | This interval is configured here, then the shared Background activity policy decides | |
| 2044 | whether provider probes may run when the timer fires. Custom intervals appear as | |
| 2045 | Advanced in General settings. | |
| 2046 | </PolicyTooltip> | |
| 2047 | </span> | |
| 2048 | } | |
| 2049 | description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." | |
| 2050 | resetAction={ | |
| 2051 | providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( | |
| 2052 | <SettingResetButton | |
| 2053 | label="provider health check interval" | |
| 2054 | onClick={() => | |
| 2055 | updateSettings( | |
| 2056 | backgroundActivityOverrideSettings( | |
| 2057 | settings.backgroundActivity, | |
| 2058 | resolvedBackgroundActivity, | |
| 2059 | { | |
| 2060 | providerHealthRefreshInterval: undefined, | |
| 2061 | }, | |
| 2062 | ), | |
| 2063 | ) | |
| 2064 | } | |
| 2065 | /> | |
| 2066 | ) : null | |
| 2067 | } | |
| 2068 | control={ | |
| 2069 | <div className="flex shrink-0 items-center gap-2"> | |
| 2070 | <NumberField | |
| 2071 | value={providerHealthRefreshIntervalSeconds} | |
| 2072 | min={0} | |
| 2073 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 2074 | size="sm" | |
| 2075 | className="w-32" | |
| 2076 | onValueChange={(value) => | |
| 2077 | updateSettings( | |
| 2078 | backgroundActivityOverrideSettings( | |
| 2079 | settings.backgroundActivity, | |
| 2080 | resolvedBackgroundActivity, | |
| 2081 | { | |
| 2082 | providerHealthRefreshInterval: Duration.seconds( | |
| 2083 | normalizeIntervalSeconds(value), | |
| 2084 | ), | |
| 2085 | }, | |
| 2086 | ), | |
| 2087 | ) | |
| 2088 | } | |
| 2089 | > | |
| 2090 | <NumberFieldGroup> | |
| 2091 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | |
| 2092 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | |
| 2093 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | |
| 2094 | </NumberFieldGroup> | |
| 2095 | </NumberField> | |
| 2096 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 2097 | </div> | |
| 2098 | } | |
| 2099 | /> | |
| 2100 | ||
| 1511 | 2101 | {rows.map((row) => { |
| 1512 | 2102 | const driverOption = getDriverOption(row.driver); |
| 1513 | 2103 | const liveProvider = serverProviders.find( |
apps/web/src/components/settings/SourceControlSettings.tsx+79-13| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; | |
| 1 | import { ChevronDownIcon, GitPullRequestIcon, InfoIcon, RefreshCwIcon } from "lucide-react"; | |
| 2 | 2 | import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Option from "effect/Option"; |
| 4 | 4 | import { useState, type ReactNode } from "react"; |
| 5 | 5 | import type { |
| 6 | BackgroundActivitySettings, | |
| 6 | 7 | SourceControlProviderKind, |
| 7 | 8 | SourceControlDiscoveryResult, |
| 8 | 9 | SourceControlProviderAuth, |
| ... | ... | @@ -10,7 +11,11 @@ import type { |
| 10 | 11 | VcsDriverKind, |
| 11 | 12 | VcsDiscoveryItem, |
| 12 | 13 | } from "@t3tools/contracts"; |
| 13 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | |
| 14 | import { | |
| 15 | getBackgroundActivityBaseProfile, | |
| 16 | getBackgroundActivityPresetSettings, | |
| 17 | resolveServerBackgroundActivitySettings, | |
| 18 | } from "@t3tools/shared/backgroundActivitySettings"; | |
| 14 | 19 | |
| 15 | 20 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 16 | 21 | import { cn } from "../../lib/utils"; |
| ... | ... | @@ -70,6 +75,11 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
| 70 | 75 | |
| 71 | 76 | const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; |
| 72 | 77 | const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; |
| 78 | type BackgroundActivityOverridePatch = Partial<{ | |
| 79 | [K in keyof BackgroundActivitySettings["overrides"]]: | |
| 80 | | BackgroundActivitySettings["overrides"][K] | |
| 81 | | undefined; | |
| 82 | }>; | |
| 73 | 83 | |
| 74 | 84 | function durationToSeconds(duration: Duration.Duration): number { |
| 75 | 85 | return Math.round(Duration.toMillis(duration) / 1_000); |
| ... | ... | @@ -82,6 +92,50 @@ function normalizeFetchIntervalSeconds(value: number | null): number { |
| 82 | 92 | return Math.max(0, Math.round(value)); |
| 83 | 93 | } |
| 84 | 94 | |
| 95 | function backgroundActivityOverrideSettings( | |
| 96 | current: BackgroundActivitySettings, | |
| 97 | overrides: BackgroundActivityOverridePatch, | |
| 98 | ) { | |
| 99 | const nextOverrides: BackgroundActivityOverridePatch = { | |
| 100 | ...current.overrides, | |
| 101 | ...overrides, | |
| 102 | }; | |
| 103 | for (const [key, value] of Object.entries(nextOverrides)) { | |
| 104 | if (value === undefined) { | |
| 105 | delete nextOverrides[key as keyof typeof nextOverrides]; | |
| 106 | } | |
| 107 | } | |
| 108 | return { | |
| 109 | backgroundActivity: { | |
| 110 | schemaVersion: 1 as const, | |
| 111 | profile: "custom" as const, | |
| 112 | baseProfile: getBackgroundActivityBaseProfile(current), | |
| 113 | overrides: nextOverrides as BackgroundActivitySettings["overrides"], | |
| 114 | }, | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | function BackgroundPolicyTooltip({ children }: { readonly children: string }) { | |
| 119 | return ( | |
| 120 | <Tooltip> | |
| 121 | <TooltipTrigger | |
| 122 | render={ | |
| 123 | <button | |
| 124 | type="button" | |
| 125 | className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground" | |
| 126 | aria-label="Background policy details" | |
| 127 | > | |
| 128 | <InfoIcon className="size-3.5" /> | |
| 129 | </button> | |
| 130 | } | |
| 131 | /> | |
| 132 | <TooltipPopup side="top" className="max-w-72"> | |
| 133 | {children} | |
| 134 | </TooltipPopup> | |
| 135 | </Tooltip> | |
| 136 | ); | |
| 137 | } | |
| 138 | ||
| 85 | 139 | function optionLabel(value: Option.Option<string>): string | null { |
| 86 | 140 | return Option.getOrNull(value); |
| 87 | 141 | } |
| ... | ... | @@ -292,13 +346,16 @@ function DiscoveryItemRow({ |
| 292 | 346 | } |
| 293 | 347 | |
| 294 | 348 | function GitFetchIntervalSettings() { |
| 295 | const automaticGitFetchInterval = usePrimarySettings( | |
| 296 | (settings) => settings.automaticGitFetchInterval, | |
| 297 | ); | |
| 349 | const settings = usePrimarySettings(); | |
| 298 | 350 | const updateSettings = useUpdatePrimarySettings(); |
| 299 | const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); | |
| 351 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | |
| 352 | const automaticGitFetchIntervalSeconds = durationToSeconds( | |
| 353 | resolvedBackgroundActivity.automaticGitFetchInterval, | |
| 354 | ); | |
| 300 | 355 | const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( |
| 301 | DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | |
| 356 | getBackgroundActivityPresetSettings( | |
| 357 | getBackgroundActivityBaseProfile(settings.backgroundActivity), | |
| 358 | ).automaticGitFetchInterval, | |
| 302 | 359 | ); |
| 303 | 360 | const canResetFetchInterval = |
| 304 | 361 | automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; |
| ... | ... | @@ -309,6 +366,11 @@ function GitFetchIntervalSettings() { |
| 309 | 366 | <div className="min-w-0 space-y-1"> |
| 310 | 367 | <div className="flex min-w-0 items-center gap-1"> |
| 311 | 368 | <span className="text-xs font-medium text-foreground">Fetch interval</span> |
| 369 | <BackgroundPolicyTooltip> | |
| 370 | This interval is configured for Git only. The shared Background activity policy still | |
| 371 | decides whether Git refreshes may run when the timer fires. Custom intervals appear as | |
| 372 | Advanced in General settings. | |
| 373 | </BackgroundPolicyTooltip> | |
| 312 | 374 | <span |
| 313 | 375 | className={cn( |
| 314 | 376 | "inline-flex size-5 shrink-0 items-center justify-center transition-opacity", |
| ... | ... | @@ -320,9 +382,11 @@ function GitFetchIntervalSettings() { |
| 320 | 382 | <SettingResetButton |
| 321 | 383 | label="fetch interval" |
| 322 | 384 | onClick={() => |
| 323 | updateSettings({ | |
| 324 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | |
| 325 | }) | |
| 385 | updateSettings( | |
| 386 | backgroundActivityOverrideSettings(settings.backgroundActivity, { | |
| 387 | automaticGitFetchInterval: undefined, | |
| 388 | }), | |
| 389 | ) | |
| 326 | 390 | } |
| 327 | 391 | /> |
| 328 | 392 | ) : null} |
| ... | ... | @@ -341,9 +405,11 @@ function GitFetchIntervalSettings() { |
| 341 | 405 | size="sm" |
| 342 | 406 | className="w-32" |
| 343 | 407 | onValueChange={(value) => |
| 344 | updateSettings({ | |
| 345 | automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), | |
| 346 | }) | |
| 408 | updateSettings( | |
| 409 | backgroundActivityOverrideSettings(settings.backgroundActivity, { | |
| 410 | automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), | |
| 411 | }), | |
| 412 | ) | |
| 347 | 413 | } |
| 348 | 414 | > |
| 349 | 415 | <NumberFieldGroup> |
apps/web/src/connection/runtime.ts+19-3| ... | ... | @@ -5,6 +5,10 @@ import * as Layer from "effect/Layer"; |
| 5 | 5 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 6 | |
| 7 | 7 | import { runtimeContextLayer } from "../lib/runtime"; |
| 8 | import { | |
| 9 | backgroundActivityObserverLayer, | |
| 10 | backgroundActivityReporterLayer, | |
| 11 | } from "../lib/backgroundActivityReporter"; | |
| 8 | 12 | import { connectionPlatformLayer } from "./platform"; |
| 9 | 13 | |
| 10 | 14 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( |
| ... | ... | @@ -17,10 +21,22 @@ type ConnectionLayerSource = |
| 17 | 21 | | typeof Connection.layer |
| 18 | 22 | | typeof snapshotLoaderLayer |
| 19 | 23 | | typeof runtimeContextLayer |
| 20 | | typeof connectionPlatformLayer; | |
| 24 | | typeof connectionPlatformLayer | |
| 25 | | typeof backgroundActivityObserverLayer | |
| 26 | | typeof backgroundActivityReporterLayer; | |
| 21 | 27 | |
| 22 | const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( | |
| 23 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), | |
| 28 | const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( | |
| 29 | Layer.provideMerge( | |
| 30 | Layer.mergeAll( | |
| 31 | runtimeContextLayer, | |
| 32 | providedConnectionPlatformLayer, | |
| 33 | backgroundActivityObserverLayer, | |
| 34 | ), | |
| 35 | ), | |
| 36 | ); | |
| 37 | ||
| 38 | const connectionLayer = backgroundActivityReporterLayer.pipe( | |
| 39 | Layer.provideMerge(providedClientConnectionLayer), | |
| 24 | 40 | ); |
| 25 | 41 | |
| 26 | 42 | export const connectionAtomRuntime: Atom.AtomRuntime< |
apps/web/src/env.ts+2-4| ... | ... | @@ -1,8 +1,6 @@ |
| 1 | 1 | /** |
| 2 | 2 | * True when running inside the Electron preload bridge, false in a regular browser. |
| 3 | * The preload script sets window.nativeApi via contextBridge before any web-app | |
| 3 | * The preload script sets window.desktopBridge via contextBridge before any web-app | |
| 4 | 4 | * code executes, so this is reliable at module load time. |
| 5 | 5 | */ |
| 6 | export const isElectron = | |
| 7 | typeof window !== "undefined" && | |
| 8 | (window.desktopBridge !== undefined || window.nativeApi !== undefined); | |
| 6 | export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined; |
apps/web/src/environments/primary/httpLayer.ts-1| ... | ... | @@ -10,7 +10,6 @@ function isSameOriginBrowserPrimary(): boolean { |
| 10 | 10 | if ( |
| 11 | 11 | typeof window === "undefined" || |
| 12 | 12 | window.desktopBridge !== undefined || |
| 13 | window.nativeApi !== undefined || | |
| 14 | 13 | !window.location.origin.startsWith("http") |
| 15 | 14 | ) { |
| 16 | 15 | return false; |
apps/web/src/hooks/useSettings.ts-1| ... | ... | @@ -302,7 +302,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 302 | 302 | }); |
| 303 | 303 | } |
| 304 | 304 | } |
| 305 | ||
| 306 | 305 | if (Object.keys(clientPatch).length > 0) { |
| 307 | 306 | persistClientSettings({ |
| 308 | 307 | ...getClientSettingsSnapshot(), |
apps/web/src/lib/backgroundActivityReporter.test.ts created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import { EnvironmentId, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | import { | |
| 6 | observeBackgroundActivitySubscription, | |
| 7 | retainedBackgroundScopes, | |
| 8 | wasRecentlyInteracted, | |
| 9 | } from "./backgroundActivityReporter.ts"; | |
| 10 | ||
| 11 | describe("wasRecentlyInteracted", () => { | |
| 12 | it("expires interaction independently of window focus", () => { | |
| 13 | expect(wasRecentlyInteracted(10_000, 55_000)).toBe(true); | |
| 14 | expect(wasRecentlyInteracted(10_000, 55_001)).toBe(false); | |
| 15 | }); | |
| 16 | ||
| 17 | it("rejects future timestamps", () => { | |
| 18 | expect(wasRecentlyInteracted(10_001, 10_000)).toBe(false); | |
| 19 | }); | |
| 20 | ||
| 21 | it.effect("retains an observed subscription until its returned finalizer runs", () => | |
| 22 | Effect.gen(function* () { | |
| 23 | const environmentId = EnvironmentId.make("environment-observation-test"); | |
| 24 | const scope = { type: "vcs-status" as const, cwd: "/repo" }; | |
| 25 | const release = yield* observeBackgroundActivitySubscription({ | |
| 26 | environmentId, | |
| 27 | method: WS_METHODS.subscribeVcsStatus, | |
| 28 | input: { cwd: scope.cwd }, | |
| 29 | }); | |
| 30 | ||
| 31 | expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); | |
| 32 | ||
| 33 | yield* release; | |
| 34 | expect(retainedBackgroundScopes(environmentId)).toEqual([]); | |
| 35 | }), | |
| 36 | ); | |
| 37 | ||
| 38 | it.effect("keeps delimiter-containing environment and scope values distinct", () => | |
| 39 | Effect.gen(function* () { | |
| 40 | const firstEnvironmentId = EnvironmentId.make("a"); | |
| 41 | const secondEnvironmentId = EnvironmentId.make("a:vcs-status:b"); | |
| 42 | const releaseFirst = yield* observeBackgroundActivitySubscription({ | |
| 43 | environmentId: firstEnvironmentId, | |
| 44 | method: WS_METHODS.subscribeVcsStatus, | |
| 45 | input: { cwd: "b:vcs-status:c" }, | |
| 46 | }); | |
| 47 | const releaseSecond = yield* observeBackgroundActivitySubscription({ | |
| 48 | environmentId: secondEnvironmentId, | |
| 49 | method: WS_METHODS.subscribeVcsStatus, | |
| 50 | input: { cwd: "c" }, | |
| 51 | }); | |
| 52 | ||
| 53 | expect(retainedBackgroundScopes(firstEnvironmentId)).toEqual([ | |
| 54 | { type: "vcs-status", cwd: "b:vcs-status:c" }, | |
| 55 | ]); | |
| 56 | expect(retainedBackgroundScopes(secondEnvironmentId)).toEqual([ | |
| 57 | { type: "vcs-status", cwd: "c" }, | |
| 58 | ]); | |
| 59 | ||
| 60 | yield* Effect.all([releaseFirst, releaseSecond]); | |
| 61 | }), | |
| 62 | ); | |
| 63 | }); |
apps/web/src/lib/backgroundActivityReporter.ts created+254| ... | ... | @@ -0,0 +1,254 @@ |
| 1 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; | |
| 2 | import { | |
| 3 | EnvironmentRpcSubscriptionObserver, | |
| 4 | request, | |
| 5 | type EnvironmentRpcSubscriptionObservation, | |
| 6 | } from "@t3tools/client-runtime/rpc"; | |
| 7 | import { | |
| 8 | type BackgroundScope, | |
| 9 | type ClientActivityReportInput, | |
| 10 | type EnvironmentId, | |
| 11 | WS_METHODS, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as Clock from "effect/Clock"; | |
| 14 | import * as DateTime from "effect/DateTime"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Queue from "effect/Queue"; | |
| 18 | import * as Schedule from "effect/Schedule"; | |
| 19 | import * as Stream from "effect/Stream"; | |
| 20 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 21 | ||
| 22 | import { randomUUID } from "./utils"; | |
| 23 | ||
| 24 | const CLIENT_ID_STORAGE_KEY = "t3.backgroundActivity.clientId"; | |
| 25 | const REPORT_INTERVAL_MS = 25_000; | |
| 26 | const LEASE_TTL_MS = 45_000; | |
| 27 | const RECENT_INTERACTION_WINDOW_MS = LEASE_TTL_MS; | |
| 28 | const BASELINE_SCOPES: ReadonlyArray<BackgroundScope> = [{ type: "provider-status" }]; | |
| 29 | ||
| 30 | interface RetainedScope { | |
| 31 | readonly environmentId: EnvironmentId; | |
| 32 | readonly scope: BackgroundScope; | |
| 33 | refCount: number; | |
| 34 | } | |
| 35 | ||
| 36 | const retainedScopes = new Map<string, RetainedScope>(); | |
| 37 | const retainedScopeListeners = new Set<() => void>(); | |
| 38 | ||
| 39 | function notifyRetainedScopesChanged(): void { | |
| 40 | for (const listener of retainedScopeListeners) { | |
| 41 | try { | |
| 42 | listener(); | |
| 43 | } catch { | |
| 44 | // A failing observer must not corrupt retained-scope lifetime. | |
| 45 | } | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string { | |
| 50 | switch (scope.type) { | |
| 51 | case "server-config": | |
| 52 | case "diagnostics": | |
| 53 | return JSON.stringify([environmentId, scope.type]); | |
| 54 | case "provider-status": | |
| 55 | return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]); | |
| 56 | case "vcs-status": | |
| 57 | case "git-refs": | |
| 58 | return JSON.stringify([environmentId, scope.type, scope.cwd]); | |
| 59 | case "thread": | |
| 60 | return JSON.stringify([environmentId, scope.type, scope.threadId]); | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | function getClientId(): string { | |
| 65 | try { | |
| 66 | const existing = window.localStorage.getItem(CLIENT_ID_STORAGE_KEY); | |
| 67 | if (existing) return existing; | |
| 68 | const next = randomUUID(); | |
| 69 | window.localStorage.setItem(CLIENT_ID_STORAGE_KEY, next); | |
| 70 | return next; | |
| 71 | } catch { | |
| 72 | return "ephemeral-browser-client"; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | function resolveClientKind(): ClientActivityReportInput["clientKind"] { | |
| 77 | return window.desktopBridge ? "desktop-renderer" : "web"; | |
| 78 | } | |
| 79 | ||
| 80 | export function wasRecentlyInteracted(lastInteractionAtMs: number, observedAtMs: number): boolean { | |
| 81 | return ( | |
| 82 | lastInteractionAtMs <= observedAtMs && | |
| 83 | observedAtMs - lastInteractionAtMs <= RECENT_INTERACTION_WINDOW_MS | |
| 84 | ); | |
| 85 | } | |
| 86 | ||
| 87 | function createActivityReport( | |
| 88 | environmentId: EnvironmentId, | |
| 89 | lastInteractionAtMs: number, | |
| 90 | observedAtMs: number, | |
| 91 | ): ClientActivityReportInput { | |
| 92 | const scopes = [...BASELINE_SCOPES]; | |
| 93 | for (const entry of retainedScopes.values()) { | |
| 94 | if (entry.environmentId === environmentId) { | |
| 95 | scopes.push(entry.scope); | |
| 96 | } | |
| 97 | } | |
| 98 | return { | |
| 99 | environmentId, | |
| 100 | clientId: getClientId(), | |
| 101 | clientKind: resolveClientKind(), | |
| 102 | visible: document.visibilityState === "visible", | |
| 103 | focused: document.hasFocus(), | |
| 104 | recentlyInteracted: wasRecentlyInteracted(lastInteractionAtMs, observedAtMs), | |
| 105 | appState: document.visibilityState === "visible" ? "active" : "background", | |
| 106 | scopes, | |
| 107 | ttlMs: LEASE_TTL_MS, | |
| 108 | observedAt: DateTime.makeUnsafe(observedAtMs), | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 112 | function scopeForSubscription( | |
| 113 | observation: EnvironmentRpcSubscriptionObservation, | |
| 114 | ): BackgroundScope | null { | |
| 115 | if (observation.method === WS_METHODS.subscribeResourceTelemetry) { | |
| 116 | return { type: "diagnostics" }; | |
| 117 | } | |
| 118 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { | |
| 119 | return null; | |
| 120 | } | |
| 121 | const input = observation.input as { readonly cwd?: unknown }; | |
| 122 | return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; | |
| 123 | } | |
| 124 | ||
| 125 | function retainBackgroundScope(environmentId: EnvironmentId, scope: BackgroundScope): () => void { | |
| 126 | const key = stableScopeKey(environmentId, scope); | |
| 127 | const existing = retainedScopes.get(key); | |
| 128 | if (existing) { | |
| 129 | existing.refCount += 1; | |
| 130 | } else { | |
| 131 | retainedScopes.set(key, { environmentId, scope, refCount: 1 }); | |
| 132 | notifyRetainedScopesChanged(); | |
| 133 | } | |
| 134 | ||
| 135 | return () => { | |
| 136 | const current = retainedScopes.get(key); | |
| 137 | if (!current) return; | |
| 138 | current.refCount -= 1; | |
| 139 | if (current.refCount <= 0) { | |
| 140 | retainedScopes.delete(key); | |
| 141 | notifyRetainedScopesChanged(); | |
| 142 | } | |
| 143 | }; | |
| 144 | } | |
| 145 | ||
| 146 | export function observeBackgroundActivitySubscription( | |
| 147 | observation: EnvironmentRpcSubscriptionObservation, | |
| 148 | ): Effect.Effect<Effect.Effect<void>> { | |
| 149 | const scope = scopeForSubscription(observation); | |
| 150 | if (scope === null) { | |
| 151 | return Effect.succeed(Effect.void); | |
| 152 | } | |
| 153 | return Effect.sync(() => { | |
| 154 | const release = retainBackgroundScope(observation.environmentId as EnvironmentId, scope); | |
| 155 | return Effect.sync(release); | |
| 156 | }); | |
| 157 | } | |
| 158 | ||
| 159 | export function retainedBackgroundScopes( | |
| 160 | environmentId: EnvironmentId, | |
| 161 | ): ReadonlyArray<BackgroundScope> { | |
| 162 | return Array.from(retainedScopes.values(), (entry) => | |
| 163 | entry.environmentId === environmentId ? entry.scope : null, | |
| 164 | ).filter((scope): scope is BackgroundScope => scope !== null); | |
| 165 | } | |
| 166 | ||
| 167 | export const backgroundActivityObserverLayer = Layer.succeed( | |
| 168 | EnvironmentRpcSubscriptionObserver, | |
| 169 | EnvironmentRpcSubscriptionObserver.of({ | |
| 170 | observe: observeBackgroundActivitySubscription, | |
| 171 | }), | |
| 172 | ); | |
| 173 | ||
| 174 | export const backgroundActivityReporterLayer = Layer.effectDiscard( | |
| 175 | Effect.gen(function* () { | |
| 176 | if (typeof window === "undefined" || typeof document === "undefined") { | |
| 177 | return; | |
| 178 | } | |
| 179 | ||
| 180 | const registry = yield* EnvironmentRegistry; | |
| 181 | const clock = yield* Clock.Clock; | |
| 182 | const reportRequests = yield* Queue.sliding<void>(1); | |
| 183 | const requestReport = () => Queue.offerUnsafe(reportRequests, undefined); | |
| 184 | let lastInteractionAtMs = clock.currentTimeMillisUnsafe(); | |
| 185 | const recordInteraction = () => { | |
| 186 | const observedAtMs = clock.currentTimeMillisUnsafe(); | |
| 187 | const wasRecent = wasRecentlyInteracted(lastInteractionAtMs, observedAtMs); | |
| 188 | lastInteractionAtMs = observedAtMs; | |
| 189 | if (!wasRecent) { | |
| 190 | requestReport(); | |
| 191 | } | |
| 192 | }; | |
| 193 | const passiveListenerOptions = { passive: true } as const; | |
| 194 | ||
| 195 | const report = Effect.gen(function* () { | |
| 196 | const observedAtMs = yield* Clock.currentTimeMillis; | |
| 197 | const entries = yield* SubscriptionRef.get(registry.entries); | |
| 198 | yield* Effect.forEach( | |
| 199 | entries.keys(), | |
| 200 | (environmentId) => | |
| 201 | registry | |
| 202 | .run( | |
| 203 | environmentId, | |
| 204 | request( | |
| 205 | WS_METHODS.serverReportClientActivity, | |
| 206 | createActivityReport(environmentId, lastInteractionAtMs, observedAtMs), | |
| 207 | ), | |
| 208 | ) | |
| 209 | .pipe(Effect.ignore), | |
| 210 | { concurrency: "unbounded", discard: true }, | |
| 211 | ); | |
| 212 | }).pipe(Effect.withSpan("web.backgroundActivity.report")); | |
| 213 | ||
| 214 | yield* Effect.acquireRelease( | |
| 215 | Effect.sync(() => { | |
| 216 | retainedScopeListeners.add(requestReport); | |
| 217 | document.addEventListener("visibilitychange", requestReport); | |
| 218 | window.addEventListener("focus", requestReport); | |
| 219 | window.addEventListener("blur", requestReport); | |
| 220 | window.addEventListener("online", requestReport); | |
| 221 | window.addEventListener("pointermove", recordInteraction); | |
| 222 | window.addEventListener("keydown", recordInteraction); | |
| 223 | window.addEventListener("wheel", recordInteraction, passiveListenerOptions); | |
| 224 | window.addEventListener("touchstart", recordInteraction, passiveListenerOptions); | |
| 225 | }), | |
| 226 | () => | |
| 227 | Effect.sync(() => { | |
| 228 | retainedScopeListeners.delete(requestReport); | |
| 229 | document.removeEventListener("visibilitychange", requestReport); | |
| 230 | window.removeEventListener("focus", requestReport); | |
| 231 | window.removeEventListener("blur", requestReport); | |
| 232 | window.removeEventListener("online", requestReport); | |
| 233 | window.removeEventListener("pointermove", recordInteraction); | |
| 234 | window.removeEventListener("keydown", recordInteraction); | |
| 235 | window.removeEventListener("wheel", recordInteraction); | |
| 236 | window.removeEventListener("touchstart", recordInteraction); | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | yield* SubscriptionRef.changes(registry.entries).pipe( | |
| 241 | Stream.runForEach(() => Effect.sync(requestReport)), | |
| 242 | Effect.forkScoped, | |
| 243 | ); | |
| 244 | yield* Stream.fromQueue(reportRequests).pipe( | |
| 245 | Stream.debounce("250 millis"), | |
| 246 | Stream.runForEach(() => report), | |
| 247 | Effect.forkScoped, | |
| 248 | ); | |
| 249 | yield* Effect.sync(requestReport).pipe( | |
| 250 | Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)), | |
| 251 | Effect.forkScoped, | |
| 252 | ); | |
| 253 | }), | |
| 254 | ); |
apps/web/src/lib/resourceTelemetryState.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { useCallback } from "react"; | |
| 4 | ||
| 5 | import { usePrimaryEnvironment } from "../state/environments"; | |
| 6 | import { useEnvironmentQuery } from "../state/query"; | |
| 7 | import { serverEnvironment } from "../state/server"; | |
| 8 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 9 | ||
| 10 | export interface ResourceTelemetryState { | |
| 11 | readonly data: ResourceTelemetrySnapshot | null; | |
| 12 | readonly error: string | null; | |
| 13 | readonly isPending: boolean; | |
| 14 | readonly refresh: () => void; | |
| 15 | readonly retry: () => Promise<ResourceTelemetrySnapshot>; | |
| 16 | } | |
| 17 | ||
| 18 | export function useResourceTelemetry(): ResourceTelemetryState { | |
| 19 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 20 | const environmentId = primaryEnvironment?.environmentId ?? null; | |
| 21 | const query = useEnvironmentQuery( | |
| 22 | environmentId === null | |
| 23 | ? null | |
| 24 | : serverEnvironment.resourceTelemetry({ environmentId, input: {} }), | |
| 25 | ); | |
| 26 | const retryCommand = useAtomCommand(serverEnvironment.retryResourceTelemetry, { | |
| 27 | reportFailure: false, | |
| 28 | }); | |
| 29 | const retry = useCallback(async () => { | |
| 30 | if (environmentId === null) { | |
| 31 | throw new Error("No environment is selected."); | |
| 32 | } | |
| 33 | const result = await retryCommand({ environmentId, input: {} }); | |
| 34 | if (result._tag === "Failure") { | |
| 35 | throw Cause.squash(result.cause); | |
| 36 | } | |
| 37 | return result.value.snapshot; | |
| 38 | }, [environmentId, retryCommand]); | |
| 39 | ||
| 40 | return { ...query, retry }; | |
| 41 | } | |
| 42 | ||
| 43 | export function useResourceTelemetryHistory(input: ResourceTelemetryHistoryInput) { | |
| 44 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 45 | const environmentId = primaryEnvironment?.environmentId ?? null; | |
| 46 | return useEnvironmentQuery( | |
| 47 | environmentId === null | |
| 48 | ? null | |
| 49 | : serverEnvironment.resourceTelemetryHistory({ environmentId, input }), | |
| 50 | ); | |
| 51 | } |
apps/web/src/localApi.test.ts+3-16| ... | ... | @@ -49,7 +49,6 @@ beforeEach(() => { |
| 49 | 49 | }); |
| 50 | 50 | } |
| 51 | 51 | Reflect.deleteProperty(testWindow(), "desktopBridge"); |
| 52 | Reflect.deleteProperty(testWindow(), "nativeApi"); | |
| 53 | 52 | Object.defineProperty(testWindow(), "localStorage", { |
| 54 | 53 | configurable: true, |
| 55 | 54 | value: createLocalStorageStub(), |
| ... | ... | @@ -61,16 +60,12 @@ afterEach(() => { |
| 61 | 60 | }); |
| 62 | 61 | |
| 63 | 62 | describe("LocalApi", () => { |
| 64 | it("keeps backend operations unavailable in the browser facade", async () => { | |
| 63 | it("keeps backend operations out of the local host facade", async () => { | |
| 65 | 64 | const { createLocalApi } = await import("./localApi"); |
| 66 | 65 | const api = createLocalApi(); |
| 67 | 66 | |
| 68 | await expect(api.server.getConfig()).rejects.toThrow( | |
| 69 | "Local backend API is unavailable before a backend is paired.", | |
| 70 | ); | |
| 71 | await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow( | |
| 72 | "Local backend API is unavailable before a backend is paired.", | |
| 73 | ); | |
| 67 | expect(api).not.toHaveProperty("server"); | |
| 68 | expect(api.shell).not.toHaveProperty("openInEditor"); | |
| 74 | 69 | }); |
| 75 | 70 | |
| 76 | 71 | it("uses the browser context-menu fallback without a desktop bridge", async () => { |
| ... | ... | @@ -120,12 +115,4 @@ describe("LocalApi", () => { |
| 120 | 115 | await api.persistence.setClientSettings(settings); |
| 121 | 116 | await expect(api.persistence.getClientSettings()).resolves.toEqual(settings); |
| 122 | 117 | }); |
| 123 | ||
| 124 | it("prefers the native LocalApi when one is injected", async () => { | |
| 125 | const nativeApi = { dialogs: {} }; | |
| 126 | testWindow().nativeApi = nativeApi as never; | |
| 127 | const { readLocalApi } = await import("./localApi"); | |
| 128 | ||
| 129 | expect(readLocalApi()).toBe(nativeApi); | |
| 130 | }); | |
| 131 | 118 | }); |
apps/web/src/localApi.ts+1-25| ... | ... | @@ -6,10 +6,6 @@ import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientP |
| 6 | 6 | |
| 7 | 7 | let cachedApi: LocalApi | undefined; |
| 8 | 8 | |
| 9 | function unavailableLocalBackendError(): Error { | |
| 10 | return new Error("Local backend API is unavailable before a backend is paired."); | |
| 11 | } | |
| 12 | ||
| 13 | 9 | function createBrowserLocalApi(): LocalApi { |
| 14 | 10 | return { |
| 15 | 11 | dialogs: { |
| ... | ... | @@ -25,7 +21,6 @@ function createBrowserLocalApi(): LocalApi { |
| 25 | 21 | }, |
| 26 | 22 | }, |
| 27 | 23 | shell: { |
| 28 | openInEditor: () => Promise.reject(unavailableLocalBackendError()), | |
| 29 | 24 | openExternal: async (url) => { |
| 30 | 25 | if (window.desktopBridge) { |
| 31 | 26 | const opened = await window.desktopBridge.openExternal(url); |
| ... | ... | @@ -63,20 +58,6 @@ function createBrowserLocalApi(): LocalApi { |
| 63 | 58 | writeBrowserClientSettings(settings); |
| 64 | 59 | }, |
| 65 | 60 | }, |
| 66 | server: { | |
| 67 | getConfig: () => Promise.reject(unavailableLocalBackendError()), | |
| 68 | refreshProviders: () => Promise.reject(unavailableLocalBackendError()), | |
| 69 | updateProvider: () => Promise.reject(unavailableLocalBackendError()), | |
| 70 | upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), | |
| 71 | removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), | |
| 72 | getSettings: () => Promise.reject(unavailableLocalBackendError()), | |
| 73 | updateSettings: () => Promise.reject(unavailableLocalBackendError()), | |
| 74 | discoverSourceControl: () => Promise.reject(unavailableLocalBackendError()), | |
| 75 | getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()), | |
| 76 | getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), | |
| 77 | getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), | |
| 78 | signalProcess: () => Promise.reject(unavailableLocalBackendError()), | |
| 79 | }, | |
| 80 | 61 | }; |
| 81 | 62 | } |
| 82 | 63 | |
| ... | ... | @@ -88,12 +69,7 @@ export function readLocalApi(): LocalApi | undefined { |
| 88 | 69 | if (typeof window === "undefined") return undefined; |
| 89 | 70 | if (cachedApi) return cachedApi; |
| 90 | 71 | |
| 91 | if (window.nativeApi) { | |
| 92 | cachedApi = window.nativeApi; | |
| 93 | return cachedApi; | |
| 94 | } | |
| 95 | ||
| 96 | cachedApi = createBrowserLocalApi(); | |
| 72 | cachedApi = createLocalApi(); | |
| 97 | 73 | return cachedApi; |
| 98 | 74 | } |
| 99 | 75 |
apps/web/src/vite-env.d.ts+1-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | /// <reference types="vite-plus/client" /> |
| 2 | 2 | |
| 3 | import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; | |
| 3 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | 5 | interface ImportMetaEnv { |
| 6 | 6 | readonly VITE_HTTP_URL: string; |
| ... | ... | @@ -22,7 +22,6 @@ interface ImportMeta { |
| 22 | 22 | |
| 23 | 23 | declare global { |
| 24 | 24 | interface Window { |
| 25 | nativeApi?: LocalApi; | |
| 26 | 25 | desktopBridge?: DesktopBridge; |
| 27 | 26 | } |
| 28 | 27 | } |
docs/architecture/overview.md+4| ... | ... | @@ -39,6 +39,10 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS |
| 39 | 39 | |
| 40 | 40 | - **Server updates**: A connected environment advertises whether its server can replace itself. When client and server versions differ, the browser selects an automatic, desktop-managed, or manual update path without changing connection ownership. See [Server Update Architecture](./server-updates.md). |
| 41 | 41 | |
| 42 | Related design: | |
| 43 | ||
| 44 | - [Resource telemetry architecture](./resource-telemetry.md) | |
| 45 | ||
| 42 | 46 | ## Event Lifecycle |
| 43 | 47 | |
| 44 | 48 | ### Startup and client connect |
docs/architecture/resource-telemetry.md created+391| ... | ... | @@ -0,0 +1,391 @@ |
| 1 | # Resource telemetry architecture | |
| 2 | ||
| 3 | Status: implemented | |
| 4 | ||
| 5 | ## Purpose | |
| 6 | ||
| 7 | Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset` | |
| 8 | subprocess probes with two persistent, direct data sources: | |
| 9 | ||
| 10 | 1. a standalone Rust resource-monitor executable that reads process counters | |
| 11 | through operating-system APIs via `sysinfo`; | |
| 12 | 2. Electron main-process APIs for Electron process metrics and host power state. | |
| 13 | ||
| 14 | The native monitor owns bounded in-memory history. The server only merges and | |
| 15 | summarizes that history when diagnostics requests it. Telemetry history is not | |
| 16 | persisted to disk or continuously copied into Node. | |
| 17 | ||
| 18 | ## Why a standalone executable | |
| 19 | ||
| 20 | The monitor is intentionally not a Node native addon. | |
| 21 | ||
| 22 | - No N-API, `ffi-rs`, or dynamic-library ABI is loaded into the server process. | |
| 23 | - A monitor crash cannot corrupt the Node runtime. | |
| 24 | - The server can supervise, restart, version-check, and measure the monitor as a | |
| 25 | normal child process. | |
| 26 | - The same protocol works for the desktop app and the published CLI. | |
| 27 | - Packaging is a single platform executable instead of an addon toolchain plus | |
| 28 | Node/Electron ABI matrix. | |
| 29 | ||
| 30 | The cost is one persistent child process and NDJSON serialization. That is a | |
| 31 | better failure boundary than repeatedly spawning shell utilities or loading | |
| 32 | native code into Node. | |
| 33 | ||
| 34 | ## Runtime topology | |
| 35 | ||
| 36 | ### Desktop | |
| 37 | ||
| 38 | ```text | |
| 39 | Electron main | |
| 40 | ├─ powerMonitor | |
| 41 | ├─ app.getAppMetrics() while diagnostics is open | |
| 42 | ├─ inherited fd 4, telemetry NDJSON ─────────────┐ | |
| 43 | └─ inherited fd 5, demand-control NDJSON â—€──────┤ | |
| 44 | â–¼ | |
| 45 | Node server ── stdin/stdout NDJSON ── Rust resource monitor | |
| 46 | │ | |
| 47 | ├─ ResourceTelemetry Effect service | |
| 48 | ├─ background power policy projection | |
| 49 | └─ WebSocket RPC/subscription ── diagnostics UI | |
| 50 | ``` | |
| 51 | ||
| 52 | ### Web, headless, and remote server | |
| 53 | ||
| 54 | Electron telemetry is unavailable. The native monitor still runs beside the | |
| 55 | server and tracks the server process tree. Power fields degrade to `unknown` | |
| 56 | instead of invoking platform shell commands. | |
| 57 | ||
| 58 | ### WSL backend limitation | |
| 59 | ||
| 60 | Windows desktop packages currently ship the Windows resource-monitor executable. | |
| 61 | That executable cannot run inside the Linux WSL backend, so a WSL-only backend | |
| 62 | does not receive `resourceMonitorPath` and reports native process telemetry as | |
| 63 | unavailable. Electron host-power telemetry remains available over the inherited | |
| 64 | desktop pipe. Supporting native WSL process telemetry requires publishing a | |
| 65 | Linux sidecar for each supported architecture in the Windows artifact and | |
| 66 | converting its packaged path into the selected distro; the configuration | |
| 67 | deliberately does not pass the Windows `.exe` into WSL as a false fallback. | |
| 68 | ||
| 69 | ## Native monitor | |
| 70 | ||
| 71 | The executable lives in `native/resource-monitor`. | |
| 72 | ||
| 73 | It receives schema-compatible commands on stdin and emits one JSON object per | |
| 74 | line on stdout: | |
| 75 | ||
| 76 | - `configure` | |
| 77 | - `setExternalProcesses` | |
| 78 | - `setSampleInterval` | |
| 79 | - `setStreaming` | |
| 80 | - `sampleNow` | |
| 81 | - `readHistory` | |
| 82 | - `shutdown` | |
| 83 | - `hello` | |
| 84 | - `snapshot` | |
| 85 | - `historyChunk` | |
| 86 | - `error` | |
| 87 | ||
| 88 | The protocol version is defined by | |
| 89 | `RESOURCE_MONITOR_PROTOCOL_VERSION` in | |
| 90 | `packages/contracts/src/resourceTelemetry.ts`. | |
| 91 | ||
| 92 | ### Collection | |
| 93 | ||
| 94 | The monitor keeps one `sysinfo::System` instance and refreshes it at the | |
| 95 | power-adaptive interval selected by the server. It collects: | |
| 96 | ||
| 97 | - PID and parent PID; | |
| 98 | - process start time and run time; | |
| 99 | - process name and command line; | |
| 100 | - current and cumulative CPU usage; | |
| 101 | - resident and virtual memory; | |
| 102 | - cumulative process I/O counters. | |
| 103 | ||
| 104 | On Linux, task/thread enumeration is disabled. Command lines are loaded only | |
| 105 | when first needed. This avoids the expensive default behavior of walking every | |
| 106 | `/proc/<pid>/task/<tid>` directory on each refresh. | |
| 107 | ||
| 108 | ### Process-tree selection | |
| 109 | ||
| 110 | Each sample scans the accessible process table, builds the PID/PPID graph, and | |
| 111 | retains: | |
| 112 | ||
| 113 | - the server process; | |
| 114 | - every descendant of the server, including provider-spawned grandchildren such | |
| 115 | as shells, `node`, `tsgo`, language servers, and other tools; | |
| 116 | - Electron processes supplied as explicit external roots; | |
| 117 | - descendants of those Electron roots; | |
| 118 | - the resource monitor itself, because it is a server child. | |
| 119 | ||
| 120 | Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native | |
| 121 | start times are matched with a two-second tolerance because native start times | |
| 122 | can have coarser platform resolution. | |
| 123 | ||
| 124 | The process list is emitted in depth-first tree order so renderer collapse and | |
| 125 | expansion preserves complete subtrees. | |
| 126 | ||
| 127 | ### Native history and streaming | |
| 128 | ||
| 129 | Every native sample is appended to a one-hour in-memory ring bounded to 3,600 | |
| 130 | snapshots, 20,000 retained process rows, and 64 MiB of retained history bytes. | |
| 131 | History stays in the sidecar until a `readHistory` request and is returned in | |
| 132 | bounded chunks. The first bound reached wins, so high process counts or large | |
| 133 | process names and command lines shorten the effective history window. | |
| 134 | ||
| 135 | Periodic snapshot streaming is disabled by default. The server enables it only | |
| 136 | while at least one diagnostics subscription is retained. `sampleNow` remains | |
| 137 | available for explicit refreshes and identity validation. | |
| 138 | ||
| 139 | The server adjusts native sampling without restarting the sidecar: | |
| 140 | ||
| 141 | - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; | |
| 142 | - battery: 5 seconds; | |
| 143 | - normal AC: 1 second; | |
| 144 | - unknown or stale power: 5 seconds in the background and 1 second while live | |
| 145 | diagnostics is open. | |
| 146 | ||
| 147 | ### Sampling limits | |
| 148 | ||
| 149 | This is counter sampling, not syscall tracing. | |
| 150 | ||
| 151 | - A process that starts and exits entirely between samples may not be observed. | |
| 152 | - Cumulative CPU and I/O counters still provide accurate deltas for processes | |
| 153 | that survive across samples. | |
| 154 | - Exact file paths, individual write syscalls, ETW events, eBPF events, and | |
| 155 | Endpoint Security events are outside this implementation. | |
| 156 | ||
| 157 | Those deeper tracing systems can be added later as opt-in diagnostic modes | |
| 158 | without changing the public `ResourceTelemetry` model. | |
| 159 | ||
| 160 | ## I/O semantics | |
| 161 | ||
| 162 | The monitor preserves platform semantics instead of presenting all counters as | |
| 163 | equivalent: | |
| 164 | ||
| 165 | - Unix-like platforms report storage I/O counters exposed by `sysinfo`. | |
| 166 | - Windows reports all process I/O bytes, not only disk bytes. | |
| 167 | - Operating-system caches can prevent logical application reads or writes from | |
| 168 | appearing as physical storage bytes. | |
| 169 | ||
| 170 | The UI therefore labels these values as I/O reads and writes and exposes the | |
| 171 | per-process `ioSemantics` value. | |
| 172 | ||
| 173 | Group totals are observed deltas since telemetry startup. Per-process total | |
| 174 | columns are the operating system's cumulative counters for that process. | |
| 175 | ||
| 176 | ## Electron telemetry | |
| 177 | ||
| 178 | Electron main owns `DesktopTelemetryPublisher`. | |
| 179 | ||
| 180 | Power events trigger an immediate snapshot. While diagnostics is closed, the | |
| 181 | server sends the configured active and idle host-power intervals to Electron | |
| 182 | (30 seconds and 2 minutes in the balanced profile). During those heartbeats | |
| 183 | Electron reads: | |
| 184 | ||
| 185 | - `powerMonitor.isOnBatteryPower()`; | |
| 186 | - `powerMonitor.getSystemIdleTime()`; | |
| 187 | - `powerMonitor.getSystemIdleState()`; | |
| 188 | - `powerMonitor.getCurrentThermalState()`. | |
| 189 | ||
| 190 | `app.getAppMetrics()` is only called while diagnostics demand is active. Its | |
| 191 | live cadence is 1 second on AC, 5 seconds on battery, and 15 seconds while | |
| 192 | locked, suspended, or thermally constrained. | |
| 193 | ||
| 194 | It also listens for: | |
| 195 | ||
| 196 | - lock and unlock; | |
| 197 | - suspend and resume; | |
| 198 | - AC and battery transitions; | |
| 199 | - thermal-state changes; | |
| 200 | - CPU speed-limit changes. | |
| 201 | ||
| 202 | Suspension stays latched across event-driven snapshots. An explicit resume | |
| 203 | clears it immediately; a periodic heartbeat also clears a stale latch so a | |
| 204 | missed resume event cannot leave telemetry permanently constrained. | |
| 205 | ||
| 206 | Electron does not expose a cross-platform low-power-mode getter, so that field | |
| 207 | remains `unknown`. | |
| 208 | ||
| 209 | The desktop backend is spawned with: | |
| 210 | ||
| 211 | - fd 3 for the existing bootstrap payload; | |
| 212 | - fd 4 for Electron-to-server telemetry NDJSON; | |
| 213 | - fd 5 for server-to-Electron diagnostics-demand NDJSON. | |
| 214 | ||
| 215 | These are private Electron-main/server pipes. They do not use the renderer | |
| 216 | WebSocket and are recreated for every backend restart. | |
| 217 | ||
| 218 | ## Server Effect services | |
| 219 | ||
| 220 | The implementation is under `apps/server/src/resourceTelemetry`. | |
| 221 | ||
| 222 | ### `ResourceMonitorBinary` | |
| 223 | ||
| 224 | Resolves an executable from: | |
| 225 | ||
| 226 | 1. `T3CODE_RESOURCE_MONITOR_PATH`; | |
| 227 | 2. desktop bootstrap configuration; | |
| 228 | 3. bundled CLI resources; | |
| 229 | 4. local Cargo build outputs. | |
| 230 | ||
| 231 | Unsupported platforms, missing binaries, and non-executable binaries use | |
| 232 | schema-backed tagged errors with descriptive messages. | |
| 233 | ||
| 234 | ### `NativeTelemetryClient` | |
| 235 | ||
| 236 | Owns the resource-monitor process and protocol. | |
| 237 | ||
| 238 | - validates the hello/version handshake; | |
| 239 | - sends configuration and external process roots; | |
| 240 | - adapts the native interval from host power state; | |
| 241 | - enables streaming only for scoped live subscribers; | |
| 242 | - reads chunked native history on demand; | |
| 243 | - exposes `sampleNow`; | |
| 244 | - serializes commands; | |
| 245 | - supervises process exit and protocol failure; | |
| 246 | - restarts with bounded exponential backoff; | |
| 247 | - opens a circuit after repeated failures; | |
| 248 | - supports explicit retry; | |
| 249 | - publishes health changes immediately. | |
| 250 | ||
| 251 | Snapshot sequence numbers are scoped to a monitor generation. Server ingestion | |
| 252 | uses the monitor restart count as the generation key, so sequence reset after a | |
| 253 | restart cannot freeze telemetry. | |
| 254 | ||
| 255 | ### `DesktopTelemetryReceiver` | |
| 256 | ||
| 257 | Reads fd 4, decodes schema-validated messages, stores the latest Electron | |
| 258 | snapshot, and publishes desktop health. It writes diagnostics demand to fd 5 | |
| 259 | and gives the first sample a 90-second startup deadline. Once samples arrive, | |
| 260 | the stale deadline stays beyond the slower configured host-power interval with | |
| 261 | 30 seconds of scheduling grace, so intentional 2–10 minute idle polling does | |
| 262 | not oscillate the policy between constrained and unconstrained states. Decode | |
| 263 | errors, protocol mismatch, control-write failure, stream failure, stale input, | |
| 264 | and normal stream closure are represented explicitly. | |
| 265 | ||
| 266 | ### `ResourceTelemetry` | |
| 267 | ||
| 268 | Merges native and Electron data and owns public telemetry semantics. | |
| 269 | ||
| 270 | - calculates CPU and I/O rates from cumulative native counters; | |
| 271 | - preserves the last native rates during desktop-only updates; | |
| 272 | - classifies backend, Electron, and monitor processes; | |
| 273 | - computes process depth and child relationships; | |
| 274 | - tracks starts, exits, CPU time, and observed I/O; | |
| 275 | - projects power data; | |
| 276 | - acquires native streaming and Electron process metrics only for scoped live | |
| 277 | subscribers; | |
| 278 | - queries and replays native history only when requested; | |
| 279 | - validates `(pid, startTimeMs)` before process signaling; | |
| 280 | - updates history health even when no further native sample arrives. | |
| 281 | ||
| 282 | Electron and monitor processes are visible but are not valid targets for the | |
| 283 | existing process-signal RPC. | |
| 284 | ||
| 285 | ### History projection | |
| 286 | ||
| 287 | `ResourceTelemetryHistory` is a pure on-demand projection. It replays raw native | |
| 288 | snapshots to derive rates, lifecycle counters, buckets, and process summaries. | |
| 289 | Current Electron process metrics are intentionally excluded from historical | |
| 290 | replay so they cannot overwrite older native CPU or memory samples. | |
| 291 | ||
| 292 | ### `ResourceAttribution` | |
| 293 | ||
| 294 | Tracks known logical application I/O separately from OS counters. Current | |
| 295 | integration points record successful writes for: | |
| 296 | ||
| 297 | - provider native and canonical event logs; | |
| 298 | - the local server trace sink. | |
| 299 | ||
| 300 | Entries contain component, operation, logical bytes, count, and elapsed time. | |
| 301 | Future persistence paths should call `ResourceAttribution.record` rather than | |
| 302 | adding diagnostics-specific counters. | |
| 303 | ||
| 304 | ## Background policy integration | |
| 305 | ||
| 306 | `HostPowerMonitor` consumes `DesktopTelemetryReceiver` directly; observing host | |
| 307 | power does not retain live resource diagnostics or invoke shell probes. | |
| 308 | ||
| 309 | The monitor updates its latest timestamp on every Electron sample but only | |
| 310 | publishes semantic state changes. Increasing idle seconds alone does not cause a | |
| 311 | background-policy broadcast every second. | |
| 312 | ||
| 313 | ## Public API and UI | |
| 314 | ||
| 315 | The WebSocket RPC surface provides: | |
| 316 | ||
| 317 | - current snapshot; | |
| 318 | - bounded history; | |
| 319 | - explicit monitor retry; | |
| 320 | - a live snapshot subscription. | |
| 321 | ||
| 322 | The diagnostics page displays: | |
| 323 | ||
| 324 | - aggregate CPU, memory, I/O, and process counts; | |
| 325 | - backend, Electron, and monitor overhead groups; | |
| 326 | - power and thermal state; | |
| 327 | - collector health and restart information; | |
| 328 | - CPU and I/O history; | |
| 329 | - a collapsible live process tree; | |
| 330 | - safe process signaling for backend descendants; | |
| 331 | - instrumented logical application I/O. | |
| 332 | ||
| 333 | Legacy process diagnostics RPCs are projected from the same service so they no | |
| 334 | longer start recurring process-table commands. | |
| 335 | ||
| 336 | ## Packaging | |
| 337 | ||
| 338 | Desktop artifact builds compile the Rust target, stage it as | |
| 339 | `resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the | |
| 340 | backend bootstrap. | |
| 341 | ||
| 342 | CLI release jobs upload each active platform monitor artifact and copy it into: | |
| 343 | ||
| 344 | ```text | |
| 345 | apps/server/dist/resource-monitor/<platform>-<arch>/ | |
| 346 | ``` | |
| 347 | ||
| 348 | The published server package already includes `dist`, so those executables ship | |
| 349 | with the CLI. Missing platform artifacts degrade native telemetry to | |
| 350 | `unavailable`; the server continues running. | |
| 351 | ||
| 352 | ## Resource and failure behavior | |
| 353 | ||
| 354 | Steady state uses: | |
| 355 | ||
| 356 | - one native process; | |
| 357 | - power-adaptive native counter sampling with no periodic Node snapshot stream; | |
| 358 | - event-driven Electron power updates plus profile-driven heartbeats (30–60 | |
| 359 | seconds while active and 2–10 minutes while idle); | |
| 360 | - no `app.getAppMetrics()` calls while diagnostics is closed; | |
| 361 | - no telemetry database; | |
| 362 | - no recurring shell probes; | |
| 363 | - bounded PubSub queues and native ring history. | |
| 364 | ||
| 365 | The diagnostics page exposes the monitor's own process resource usage and | |
| 366 | collection duration so the observer's cost is measurable. | |
| 367 | ||
| 368 | Failures are isolated: | |
| 369 | ||
| 370 | - native failure does not stop the server; | |
| 371 | - Electron telemetry loss does not stop native telemetry; | |
| 372 | - schema/version errors are visible in health; | |
| 373 | - repeated native failures stop automatic restart churn until explicit retry; | |
| 374 | - server and desktop shutdown close their respective streams and child process | |
| 375 | scopes. | |
| 376 | ||
| 377 | ## Future integration points | |
| 378 | ||
| 379 | High-value follow-up work can use the existing service boundaries: | |
| 380 | ||
| 381 | - opt-in file-path attribution through platform-specific tracing; | |
| 382 | - process lifecycle events to reduce the chance of missing very short-lived | |
| 383 | children; | |
| 384 | - additional `ResourceAttribution` instrumentation for databases, checkpoints, | |
| 385 | caches, and file synchronization; | |
| 386 | - exported diagnostic bundles; | |
| 387 | - adaptive sample intervals based on diagnostics visibility and active work. | |
| 388 | ||
| 389 | These additions should preserve the current rules: direct platform APIs, | |
| 390 | schema-validated boundaries, explicit metric semantics, bounded retention, and | |
| 391 | no mandatory telemetry persistence. |
native/resource-monitor/Cargo.lock created+343| ... | ... | @@ -0,0 +1,343 @@ |
| 1 | # This file is automatically @generated by Cargo. | |
| 2 | # It is not intended for manual editing. | |
| 3 | version = 4 | |
| 4 | ||
| 5 | [[package]] | |
| 6 | name = "bitflags" | |
| 7 | version = "2.13.0" | |
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" | |
| 10 | ||
| 11 | [[package]] | |
| 12 | name = "dispatch2" | |
| 13 | version = "0.3.1" | |
| 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" | |
| 16 | dependencies = [ | |
| 17 | "bitflags", | |
| 18 | "objc2", | |
| 19 | ] | |
| 20 | ||
| 21 | [[package]] | |
| 22 | name = "itoa" | |
| 23 | version = "1.0.18" | |
| 24 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 26 | ||
| 27 | [[package]] | |
| 28 | name = "libc" | |
| 29 | version = "0.2.186" | |
| 30 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 31 | checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" | |
| 32 | ||
| 33 | [[package]] | |
| 34 | name = "memchr" | |
| 35 | version = "2.8.2" | |
| 36 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 37 | checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" | |
| 38 | ||
| 39 | [[package]] | |
| 40 | name = "ntapi" | |
| 41 | version = "0.4.3" | |
| 42 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 43 | checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" | |
| 44 | dependencies = [ | |
| 45 | "winapi", | |
| 46 | ] | |
| 47 | ||
| 48 | [[package]] | |
| 49 | name = "objc2" | |
| 50 | version = "0.6.4" | |
| 51 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 52 | checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" | |
| 53 | dependencies = [ | |
| 54 | "objc2-encode", | |
| 55 | ] | |
| 56 | ||
| 57 | [[package]] | |
| 58 | name = "objc2-core-foundation" | |
| 59 | version = "0.3.2" | |
| 60 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 61 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" | |
| 62 | dependencies = [ | |
| 63 | "bitflags", | |
| 64 | "dispatch2", | |
| 65 | "objc2", | |
| 66 | ] | |
| 67 | ||
| 68 | [[package]] | |
| 69 | name = "objc2-encode" | |
| 70 | version = "4.1.0" | |
| 71 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 72 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" | |
| 73 | ||
| 74 | [[package]] | |
| 75 | name = "objc2-foundation" | |
| 76 | version = "0.3.2" | |
| 77 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 78 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" | |
| 79 | dependencies = [ | |
| 80 | "bitflags", | |
| 81 | "objc2", | |
| 82 | ] | |
| 83 | ||
| 84 | [[package]] | |
| 85 | name = "objc2-io-kit" | |
| 86 | version = "0.3.2" | |
| 87 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 88 | checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" | |
| 89 | dependencies = [ | |
| 90 | "libc", | |
| 91 | "objc2-core-foundation", | |
| 92 | ] | |
| 93 | ||
| 94 | [[package]] | |
| 95 | name = "objc2-open-directory" | |
| 96 | version = "0.3.2" | |
| 97 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 98 | checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" | |
| 99 | dependencies = [ | |
| 100 | "objc2", | |
| 101 | "objc2-core-foundation", | |
| 102 | "objc2-foundation", | |
| 103 | ] | |
| 104 | ||
| 105 | [[package]] | |
| 106 | name = "proc-macro2" | |
| 107 | version = "1.0.106" | |
| 108 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 109 | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | |
| 110 | dependencies = [ | |
| 111 | "unicode-ident", | |
| 112 | ] | |
| 113 | ||
| 114 | [[package]] | |
| 115 | name = "quote" | |
| 116 | version = "1.0.45" | |
| 117 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 118 | checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" | |
| 119 | dependencies = [ | |
| 120 | "proc-macro2", | |
| 121 | ] | |
| 122 | ||
| 123 | [[package]] | |
| 124 | name = "serde" | |
| 125 | version = "1.0.228" | |
| 126 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 127 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" | |
| 128 | dependencies = [ | |
| 129 | "serde_core", | |
| 130 | "serde_derive", | |
| 131 | ] | |
| 132 | ||
| 133 | [[package]] | |
| 134 | name = "serde_core" | |
| 135 | version = "1.0.228" | |
| 136 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 137 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" | |
| 138 | dependencies = [ | |
| 139 | "serde_derive", | |
| 140 | ] | |
| 141 | ||
| 142 | [[package]] | |
| 143 | name = "serde_derive" | |
| 144 | version = "1.0.228" | |
| 145 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 146 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | |
| 147 | dependencies = [ | |
| 148 | "proc-macro2", | |
| 149 | "quote", | |
| 150 | "syn", | |
| 151 | ] | |
| 152 | ||
| 153 | [[package]] | |
| 154 | name = "serde_json" | |
| 155 | version = "1.0.150" | |
| 156 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 157 | checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" | |
| 158 | dependencies = [ | |
| 159 | "itoa", | |
| 160 | "memchr", | |
| 161 | "serde", | |
| 162 | "serde_core", | |
| 163 | "zmij", | |
| 164 | ] | |
| 165 | ||
| 166 | [[package]] | |
| 167 | name = "syn" | |
| 168 | version = "2.0.118" | |
| 169 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 170 | checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" | |
| 171 | dependencies = [ | |
| 172 | "proc-macro2", | |
| 173 | "quote", | |
| 174 | "unicode-ident", | |
| 175 | ] | |
| 176 | ||
| 177 | [[package]] | |
| 178 | name = "sysinfo" | |
| 179 | version = "0.39.3" | |
| 180 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 181 | checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" | |
| 182 | dependencies = [ | |
| 183 | "libc", | |
| 184 | "memchr", | |
| 185 | "ntapi", | |
| 186 | "objc2-core-foundation", | |
| 187 | "objc2-io-kit", | |
| 188 | "objc2-open-directory", | |
| 189 | "windows", | |
| 190 | ] | |
| 191 | ||
| 192 | [[package]] | |
| 193 | name = "t3-resource-monitor" | |
| 194 | version = "0.1.0" | |
| 195 | dependencies = [ | |
| 196 | "serde", | |
| 197 | "serde_json", | |
| 198 | "sysinfo", | |
| 199 | ] | |
| 200 | ||
| 201 | [[package]] | |
| 202 | name = "unicode-ident" | |
| 203 | version = "1.0.24" | |
| 204 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 205 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 206 | ||
| 207 | [[package]] | |
| 208 | name = "winapi" | |
| 209 | version = "0.3.9" | |
| 210 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 211 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 212 | dependencies = [ | |
| 213 | "winapi-i686-pc-windows-gnu", | |
| 214 | "winapi-x86_64-pc-windows-gnu", | |
| 215 | ] | |
| 216 | ||
| 217 | [[package]] | |
| 218 | name = "winapi-i686-pc-windows-gnu" | |
| 219 | version = "0.4.0" | |
| 220 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 221 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 222 | ||
| 223 | [[package]] | |
| 224 | name = "winapi-x86_64-pc-windows-gnu" | |
| 225 | version = "0.4.0" | |
| 226 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 227 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 228 | ||
| 229 | [[package]] | |
| 230 | name = "windows" | |
| 231 | version = "0.62.2" | |
| 232 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 233 | checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" | |
| 234 | dependencies = [ | |
| 235 | "windows-collections", | |
| 236 | "windows-core", | |
| 237 | "windows-future", | |
| 238 | "windows-numerics", | |
| 239 | ] | |
| 240 | ||
| 241 | [[package]] | |
| 242 | name = "windows-collections" | |
| 243 | version = "0.3.2" | |
| 244 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 245 | checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" | |
| 246 | dependencies = [ | |
| 247 | "windows-core", | |
| 248 | ] | |
| 249 | ||
| 250 | [[package]] | |
| 251 | name = "windows-core" | |
| 252 | version = "0.62.2" | |
| 253 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 254 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 255 | dependencies = [ | |
| 256 | "windows-implement", | |
| 257 | "windows-interface", | |
| 258 | "windows-link", | |
| 259 | "windows-result", | |
| 260 | "windows-strings", | |
| 261 | ] | |
| 262 | ||
| 263 | [[package]] | |
| 264 | name = "windows-future" | |
| 265 | version = "0.3.2" | |
| 266 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 267 | checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" | |
| 268 | dependencies = [ | |
| 269 | "windows-core", | |
| 270 | "windows-link", | |
| 271 | "windows-threading", | |
| 272 | ] | |
| 273 | ||
| 274 | [[package]] | |
| 275 | name = "windows-implement" | |
| 276 | version = "0.60.2" | |
| 277 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 278 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 279 | dependencies = [ | |
| 280 | "proc-macro2", | |
| 281 | "quote", | |
| 282 | "syn", | |
| 283 | ] | |
| 284 | ||
| 285 | [[package]] | |
| 286 | name = "windows-interface" | |
| 287 | version = "0.59.3" | |
| 288 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 289 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 290 | dependencies = [ | |
| 291 | "proc-macro2", | |
| 292 | "quote", | |
| 293 | "syn", | |
| 294 | ] | |
| 295 | ||
| 296 | [[package]] | |
| 297 | name = "windows-link" | |
| 298 | version = "0.2.1" | |
| 299 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 300 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 301 | ||
| 302 | [[package]] | |
| 303 | name = "windows-numerics" | |
| 304 | version = "0.3.1" | |
| 305 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 306 | checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" | |
| 307 | dependencies = [ | |
| 308 | "windows-core", | |
| 309 | "windows-link", | |
| 310 | ] | |
| 311 | ||
| 312 | [[package]] | |
| 313 | name = "windows-result" | |
| 314 | version = "0.4.1" | |
| 315 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | |
| 317 | dependencies = [ | |
| 318 | "windows-link", | |
| 319 | ] | |
| 320 | ||
| 321 | [[package]] | |
| 322 | name = "windows-strings" | |
| 323 | version = "0.5.1" | |
| 324 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 325 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | |
| 326 | dependencies = [ | |
| 327 | "windows-link", | |
| 328 | ] | |
| 329 | ||
| 330 | [[package]] | |
| 331 | name = "windows-threading" | |
| 332 | version = "0.2.1" | |
| 333 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 334 | checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" | |
| 335 | dependencies = [ | |
| 336 | "windows-link", | |
| 337 | ] | |
| 338 | ||
| 339 | [[package]] | |
| 340 | name = "zmij" | |
| 341 | version = "1.0.21" | |
| 342 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 343 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" |
native/resource-monitor/Cargo.toml created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | [package] | |
| 2 | name = "t3-resource-monitor" | |
| 3 | version = "0.1.0" | |
| 4 | edition = "2024" | |
| 5 | license = "MIT" | |
| 6 | publish = false | |
| 7 | ||
| 8 | [dependencies] | |
| 9 | serde = { version = "1.0.228", features = ["derive"] } | |
| 10 | serde_json = "1.0.150" | |
| 11 | sysinfo = "0.39.3" | |
| 12 | ||
| 13 | [profile.release] | |
| 14 | codegen-units = 1 | |
| 15 | lto = "thin" | |
| 16 | panic = "abort" | |
| 17 | strip = true |
native/resource-monitor/src/main.rs created+1160| ... | ... | @@ -0,0 +1,1160 @@ |
| 1 | use serde::{Deserialize, Serialize}; | |
| 2 | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 3 | use std::io::{self, BufRead, BufWriter, Write}; | |
| 4 | use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; | |
| 5 | use std::thread; | |
| 6 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; | |
| 7 | use sysinfo::{ | |
| 8 | MINIMUM_CPU_UPDATE_INTERVAL, Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind, | |
| 9 | }; | |
| 10 | ||
| 11 | const PROTOCOL_VERSION: u32 = 2; | |
| 12 | const MIN_SAMPLE_INTERVAL_MS: u64 = 250; | |
| 13 | const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000; | |
| 14 | const PROCESS_START_TIME_PRECISION_MS: u64 = 1_000; | |
| 15 | const HISTORY_RETENTION_MS: u64 = 60 * 60_000; | |
| 16 | const MAX_HISTORY_SNAPSHOTS: usize = 3_600; | |
| 17 | const INPUT_QUEUE_CAPACITY: usize = 64; | |
| 18 | const MAX_HISTORY_RETAINED_ENTRIES: usize = 20_000; | |
| 19 | const MAX_HISTORY_RETAINED_BYTES: usize = 64 * 1024 * 1024; | |
| 20 | const MAX_PROCESS_NAME_BYTES: usize = 1_024; | |
| 21 | const MAX_PROCESS_COMMAND_BYTES: usize = 16 * 1_024; | |
| 22 | const MAX_PROCESS_STATUS_BYTES: usize = 256; | |
| 23 | const HISTORY_CHUNK_SNAPSHOTS: usize = 32; | |
| 24 | ||
| 25 | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 26 | #[serde(rename_all = "camelCase")] | |
| 27 | struct ExternalProcess { | |
| 28 | pid: u32, | |
| 29 | #[serde(default)] | |
| 30 | start_time_ms: Option<u64>, | |
| 31 | } | |
| 32 | ||
| 33 | impl ExternalProcess { | |
| 34 | fn estimated_history_bytes(&self) -> usize { | |
| 35 | std::mem::size_of::<Self>() | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | #[derive(Debug, Deserialize)] | |
| 40 | #[serde( | |
| 41 | tag = "type", | |
| 42 | rename_all = "camelCase", | |
| 43 | rename_all_fields = "camelCase" | |
| 44 | )] | |
| 45 | enum Command { | |
| 46 | Configure { | |
| 47 | version: u32, | |
| 48 | root_pid: u32, | |
| 49 | sample_interval_ms: u64, | |
| 50 | #[serde(default)] | |
| 51 | external_processes: Vec<ExternalProcess>, | |
| 52 | }, | |
| 53 | SetExternalProcesses { | |
| 54 | version: u32, | |
| 55 | processes: Vec<ExternalProcess>, | |
| 56 | }, | |
| 57 | SetSampleInterval { | |
| 58 | version: u32, | |
| 59 | sample_interval_ms: u64, | |
| 60 | }, | |
| 61 | SetStreaming { | |
| 62 | version: u32, | |
| 63 | enabled: bool, | |
| 64 | }, | |
| 65 | SampleNow { | |
| 66 | version: u32, | |
| 67 | request_id: String, | |
| 68 | }, | |
| 69 | ReadHistory { | |
| 70 | version: u32, | |
| 71 | request_id: String, | |
| 72 | window_ms: u64, | |
| 73 | }, | |
| 74 | Shutdown { | |
| 75 | version: u32, | |
| 76 | }, | |
| 77 | } | |
| 78 | ||
| 79 | impl Command { | |
| 80 | fn version(&self) -> u32 { | |
| 81 | match self { | |
| 82 | Self::Configure { version, .. } | |
| 83 | | Self::SetExternalProcesses { version, .. } | |
| 84 | | Self::SetSampleInterval { version, .. } | |
| 85 | | Self::SetStreaming { version, .. } | |
| 86 | | Self::SampleNow { version, .. } | |
| 87 | | Self::ReadHistory { version, .. } | |
| 88 | | Self::Shutdown { version } => *version, | |
| 89 | } | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | enum Input { | |
| 94 | Command(Command), | |
| 95 | Invalid(String), | |
| 96 | } | |
| 97 | ||
| 98 | #[derive(Debug, Serialize)] | |
| 99 | #[serde(rename_all = "camelCase")] | |
| 100 | struct Capabilities { | |
| 101 | cumulative_cpu_time: bool, | |
| 102 | current_cpu_percent: bool, | |
| 103 | resident_memory: bool, | |
| 104 | virtual_memory: bool, | |
| 105 | io_bytes: bool, | |
| 106 | process_start_time: bool, | |
| 107 | process_tree: bool, | |
| 108 | } | |
| 109 | ||
| 110 | #[derive(Debug, Serialize)] | |
| 111 | #[serde(rename_all = "camelCase")] | |
| 112 | struct HelloEvent { | |
| 113 | version: u32, | |
| 114 | #[serde(rename = "type")] | |
| 115 | event_type: &'static str, | |
| 116 | sidecar_version: &'static str, | |
| 117 | sidecar_pid: u32, | |
| 118 | platform: &'static str, | |
| 119 | arch: &'static str, | |
| 120 | capabilities: Capabilities, | |
| 121 | } | |
| 122 | ||
| 123 | #[derive(Debug, Clone, Copy, Serialize)] | |
| 124 | #[serde(rename_all = "kebab-case")] | |
| 125 | enum IoSemantics { | |
| 126 | Storage, | |
| 127 | AllIo, | |
| 128 | } | |
| 129 | ||
| 130 | #[derive(Debug, Clone, Serialize)] | |
| 131 | #[serde(rename_all = "camelCase")] | |
| 132 | struct ProcessSample { | |
| 133 | pid: u32, | |
| 134 | ppid: u32, | |
| 135 | start_time_ms: u64, | |
| 136 | run_time_ms: u64, | |
| 137 | name: String, | |
| 138 | command: String, | |
| 139 | status: String, | |
| 140 | cpu_percent: f32, | |
| 141 | cpu_time_ms: u64, | |
| 142 | resident_bytes: u64, | |
| 143 | virtual_bytes: u64, | |
| 144 | io_read_bytes: u64, | |
| 145 | io_write_bytes: u64, | |
| 146 | io_semantics: IoSemantics, | |
| 147 | } | |
| 148 | ||
| 149 | impl ProcessSample { | |
| 150 | fn estimated_history_bytes(&self) -> usize { | |
| 151 | std::mem::size_of::<Self>() | |
| 152 | .saturating_add(self.name.len()) | |
| 153 | .saturating_add(self.command.len()) | |
| 154 | .saturating_add(self.status.len()) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | #[derive(Debug, Clone, Serialize)] | |
| 159 | #[serde(rename_all = "camelCase")] | |
| 160 | struct SnapshotEvent { | |
| 161 | version: u32, | |
| 162 | #[serde(rename = "type")] | |
| 163 | event_type: &'static str, | |
| 164 | sequence: u64, | |
| 165 | sampled_at_unix_ms: u64, | |
| 166 | collection_duration_micros: u64, | |
| 167 | scanned_process_count: usize, | |
| 168 | retained_process_count: usize, | |
| 169 | inaccessible_process_count: usize, | |
| 170 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 171 | request_id: Option<String>, | |
| 172 | external_processes: Vec<ExternalProcess>, | |
| 173 | processes: Vec<ProcessSample>, | |
| 174 | } | |
| 175 | ||
| 176 | impl SnapshotEvent { | |
| 177 | fn retained_entry_count(&self) -> usize { | |
| 178 | self.processes | |
| 179 | .len() | |
| 180 | .saturating_add(self.external_processes.len()) | |
| 181 | } | |
| 182 | ||
| 183 | fn estimated_history_bytes(&self) -> usize { | |
| 184 | std::mem::size_of::<Self>() | |
| 185 | .saturating_add( | |
| 186 | self.processes | |
| 187 | .iter() | |
| 188 | .map(ProcessSample::estimated_history_bytes) | |
| 189 | .sum::<usize>(), | |
| 190 | ) | |
| 191 | .saturating_add( | |
| 192 | self.external_processes | |
| 193 | .iter() | |
| 194 | .map(ExternalProcess::estimated_history_bytes) | |
| 195 | .sum::<usize>(), | |
| 196 | ) | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | #[derive(Debug, Serialize)] | |
| 201 | #[serde(rename_all = "camelCase")] | |
| 202 | struct HistoryChunkEvent<'a> { | |
| 203 | version: u32, | |
| 204 | #[serde(rename = "type")] | |
| 205 | event_type: &'static str, | |
| 206 | request_id: &'a str, | |
| 207 | done: bool, | |
| 208 | snapshots: &'a [SnapshotEvent], | |
| 209 | } | |
| 210 | ||
| 211 | #[derive(Debug, Serialize)] | |
| 212 | #[serde(rename_all = "camelCase")] | |
| 213 | struct ErrorEvent { | |
| 214 | version: u32, | |
| 215 | #[serde(rename = "type")] | |
| 216 | event_type: &'static str, | |
| 217 | code: &'static str, | |
| 218 | message: String, | |
| 219 | recoverable: bool, | |
| 220 | } | |
| 221 | ||
| 222 | #[derive(Debug, Clone)] | |
| 223 | struct CollectorConfig { | |
| 224 | root_pid: u32, | |
| 225 | sample_interval: Option<Duration>, | |
| 226 | external_processes: HashMap<u32, Option<u64>>, | |
| 227 | } | |
| 228 | ||
| 229 | #[derive(Default)] | |
| 230 | struct HistoryRecorder { | |
| 231 | snapshots: VecDeque<SnapshotEvent>, | |
| 232 | retained_entry_count: usize, | |
| 233 | retained_bytes: usize, | |
| 234 | } | |
| 235 | ||
| 236 | impl HistoryRecorder { | |
| 237 | fn record(&mut self, snapshot: &SnapshotEvent) { | |
| 238 | self.record_with_limits( | |
| 239 | snapshot, | |
| 240 | MAX_HISTORY_SNAPSHOTS, | |
| 241 | MAX_HISTORY_RETAINED_ENTRIES, | |
| 242 | MAX_HISTORY_RETAINED_BYTES, | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | fn record_with_limits( | |
| 247 | &mut self, | |
| 248 | snapshot: &SnapshotEvent, | |
| 249 | max_snapshots: usize, | |
| 250 | max_retained_entries: usize, | |
| 251 | max_retained_bytes: usize, | |
| 252 | ) { | |
| 253 | let mut retained = snapshot.clone(); | |
| 254 | retained.request_id = None; | |
| 255 | self.retained_entry_count = self | |
| 256 | .retained_entry_count | |
| 257 | .saturating_add(retained.retained_entry_count()); | |
| 258 | self.retained_bytes = self | |
| 259 | .retained_bytes | |
| 260 | .saturating_add(retained.estimated_history_bytes()); | |
| 261 | self.snapshots.push_back(retained); | |
| 262 | self.trim_to_limits( | |
| 263 | snapshot.sampled_at_unix_ms, | |
| 264 | max_snapshots, | |
| 265 | max_retained_entries, | |
| 266 | max_retained_bytes, | |
| 267 | ); | |
| 268 | } | |
| 269 | ||
| 270 | fn trim_to_limits( | |
| 271 | &mut self, | |
| 272 | now_ms: u64, | |
| 273 | max_snapshots: usize, | |
| 274 | max_retained_entries: usize, | |
| 275 | max_retained_bytes: usize, | |
| 276 | ) { | |
| 277 | let mut future_entry_count = 0usize; | |
| 278 | let mut future_bytes = 0usize; | |
| 279 | self.snapshots.retain(|snapshot| { | |
| 280 | let keep = snapshot.sampled_at_unix_ms <= now_ms; | |
| 281 | if !keep { | |
| 282 | future_entry_count = | |
| 283 | future_entry_count.saturating_add(snapshot.retained_entry_count()); | |
| 284 | future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); | |
| 285 | } | |
| 286 | keep | |
| 287 | }); | |
| 288 | self.retained_entry_count = self.retained_entry_count.saturating_sub(future_entry_count); | |
| 289 | self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); | |
| 290 | ||
| 291 | while self.snapshots.front().is_some_and(|snapshot| { | |
| 292 | snapshot.sampled_at_unix_ms < now_ms.saturating_sub(HISTORY_RETENTION_MS) | |
| 293 | || self.snapshots.len() > max_snapshots | |
| 294 | || self.retained_entry_count > max_retained_entries | |
| 295 | || self.retained_bytes > max_retained_bytes | |
| 296 | }) { | |
| 297 | if let Some(removed) = self.snapshots.pop_front() { | |
| 298 | self.retained_entry_count = self | |
| 299 | .retained_entry_count | |
| 300 | .saturating_sub(removed.retained_entry_count()); | |
| 301 | self.retained_bytes = self | |
| 302 | .retained_bytes | |
| 303 | .saturating_sub(removed.estimated_history_bytes()); | |
| 304 | } | |
| 305 | } | |
| 306 | } | |
| 307 | ||
| 308 | fn read(&self, window_ms: u64, now_ms: u64) -> Vec<SnapshotEvent> { | |
| 309 | let started_at_ms = now_ms.saturating_sub(window_ms.min(HISTORY_RETENTION_MS)); | |
| 310 | self.snapshots | |
| 311 | .iter() | |
| 312 | .filter(|snapshot| { | |
| 313 | snapshot.sampled_at_unix_ms >= started_at_ms | |
| 314 | && snapshot.sampled_at_unix_ms <= now_ms | |
| 315 | }) | |
| 316 | .cloned() | |
| 317 | .collect() | |
| 318 | } | |
| 319 | } | |
| 320 | ||
| 321 | struct Collector { | |
| 322 | system: System, | |
| 323 | sequence: u64, | |
| 324 | cpu_baseline_refreshed_at: Option<Instant>, | |
| 325 | } | |
| 326 | ||
| 327 | impl Collector { | |
| 328 | fn new() -> Self { | |
| 329 | Self { | |
| 330 | system: System::new(), | |
| 331 | sequence: 0, | |
| 332 | cpu_baseline_refreshed_at: None, | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | fn prime_cpu_usage(&mut self) { | |
| 337 | self.system.refresh_processes_specifics( | |
| 338 | ProcessesToUpdate::All, | |
| 339 | true, | |
| 340 | process_refresh_kind(), | |
| 341 | ); | |
| 342 | self.cpu_baseline_refreshed_at = Some(Instant::now()); | |
| 343 | } | |
| 344 | ||
| 345 | fn sample(&mut self, config: &CollectorConfig, request_id: Option<String>) -> SnapshotEvent { | |
| 346 | if let Some(delay) = | |
| 347 | remaining_cpu_measurement_delay(self.cpu_baseline_refreshed_at.take(), Instant::now()) | |
| 348 | { | |
| 349 | thread::sleep(delay); | |
| 350 | } | |
| 351 | let collection_started = Instant::now(); | |
| 352 | self.system.refresh_processes_specifics( | |
| 353 | ProcessesToUpdate::All, | |
| 354 | true, | |
| 355 | process_refresh_kind(), | |
| 356 | ); | |
| 357 | self.cpu_baseline_refreshed_at = Some(Instant::now()); | |
| 358 | ||
| 359 | let rows = self | |
| 360 | .system | |
| 361 | .processes() | |
| 362 | .iter() | |
| 363 | .map(|(pid, process)| { | |
| 364 | let pid = pid.as_u32(); | |
| 365 | let ppid = process.parent().map(Pid::as_u32).unwrap_or(0); | |
| 366 | (pid, ppid, process.start_time().saturating_mul(1_000)) | |
| 367 | }) | |
| 368 | .collect::<Vec<_>>(); | |
| 369 | let external_processes = config | |
| 370 | .external_processes | |
| 371 | .iter() | |
| 372 | .filter_map(|(pid, expected_start_time_ms)| { | |
| 373 | let (_, _, actual_start_time_ms) = rows | |
| 374 | .iter() | |
| 375 | .find(|(candidate_pid, _, _)| candidate_pid == pid)?; | |
| 376 | matches_external_identity(*actual_start_time_ms, *expected_start_time_ms).then_some( | |
| 377 | ExternalProcess { | |
| 378 | pid: *pid, | |
| 379 | start_time_ms: Some(*actual_start_time_ms), | |
| 380 | }, | |
| 381 | ) | |
| 382 | }) | |
| 383 | .collect::<Vec<_>>(); | |
| 384 | let mut roots = external_processes | |
| 385 | .iter() | |
| 386 | .map(|process| process.pid) | |
| 387 | .collect::<HashSet<_>>(); | |
| 388 | roots.insert(config.root_pid); | |
| 389 | let tracked = select_tracked_pids(&rows, &roots); | |
| 390 | let tracked_process_count = tracked.len(); | |
| 391 | let mut processes = tracked | |
| 392 | .into_iter() | |
| 393 | .filter_map(|pid| { | |
| 394 | let process = self.system.process(Pid::from_u32(pid))?; | |
| 395 | let disk_usage = process.disk_usage(); | |
| 396 | let command = if process.cmd().is_empty() { | |
| 397 | process.name().to_string_lossy().into_owned() | |
| 398 | } else { | |
| 399 | process | |
| 400 | .cmd() | |
| 401 | .iter() | |
| 402 | .map(|part| part.to_string_lossy()) | |
| 403 | .collect::<Vec<_>>() | |
| 404 | .join(" ") | |
| 405 | }; | |
| 406 | ||
| 407 | Some(ProcessSample { | |
| 408 | pid, | |
| 409 | ppid: process.parent().map(Pid::as_u32).unwrap_or(0), | |
| 410 | start_time_ms: process.start_time().saturating_mul(1_000), | |
| 411 | run_time_ms: process.run_time().saturating_mul(1_000), | |
| 412 | name: truncate_utf8( | |
| 413 | process.name().to_string_lossy().into_owned(), | |
| 414 | MAX_PROCESS_NAME_BYTES, | |
| 415 | ), | |
| 416 | command: truncate_utf8(command, MAX_PROCESS_COMMAND_BYTES), | |
| 417 | status: truncate_utf8( | |
| 418 | format!("{:?}", process.status()), | |
| 419 | MAX_PROCESS_STATUS_BYTES, | |
| 420 | ), | |
| 421 | cpu_percent: process.cpu_usage(), | |
| 422 | cpu_time_ms: process.accumulated_cpu_time(), | |
| 423 | resident_bytes: process.memory(), | |
| 424 | virtual_bytes: process.virtual_memory(), | |
| 425 | io_read_bytes: disk_usage.total_read_bytes, | |
| 426 | io_write_bytes: disk_usage.total_written_bytes, | |
| 427 | io_semantics: io_semantics(), | |
| 428 | }) | |
| 429 | }) | |
| 430 | .collect::<Vec<_>>(); | |
| 431 | processes.sort_by_key(|process| process.pid); | |
| 432 | self.sequence = self.sequence.saturating_add(1); | |
| 433 | ||
| 434 | SnapshotEvent { | |
| 435 | version: PROTOCOL_VERSION, | |
| 436 | event_type: "snapshot", | |
| 437 | sequence: self.sequence, | |
| 438 | sampled_at_unix_ms: unix_time_ms(), | |
| 439 | collection_duration_micros: collection_started.elapsed().as_micros() as u64, | |
| 440 | scanned_process_count: self.system.processes().len(), | |
| 441 | retained_process_count: processes.len(), | |
| 442 | inaccessible_process_count: inaccessible_process_count( | |
| 443 | tracked_process_count, | |
| 444 | processes.len(), | |
| 445 | ), | |
| 446 | request_id, | |
| 447 | external_processes, | |
| 448 | processes, | |
| 449 | } | |
| 450 | } | |
| 451 | } | |
| 452 | ||
| 453 | fn process_refresh_kind() -> ProcessRefreshKind { | |
| 454 | ProcessRefreshKind::nothing() | |
| 455 | .with_memory() | |
| 456 | .with_cpu() | |
| 457 | .with_disk_usage() | |
| 458 | .with_cmd(UpdateKind::Always) | |
| 459 | .without_tasks() | |
| 460 | } | |
| 461 | ||
| 462 | fn inaccessible_process_count(selected: usize, materialized: usize) -> usize { | |
| 463 | selected.saturating_sub(materialized) | |
| 464 | } | |
| 465 | ||
| 466 | fn remaining_cpu_measurement_delay( | |
| 467 | baseline_refreshed_at: Option<Instant>, | |
| 468 | now: Instant, | |
| 469 | ) -> Option<Duration> { | |
| 470 | baseline_refreshed_at | |
| 471 | .and_then(|baseline| MINIMUM_CPU_UPDATE_INTERVAL.checked_sub(now.duration_since(baseline))) | |
| 472 | .filter(|delay| !delay.is_zero()) | |
| 473 | } | |
| 474 | ||
| 475 | fn matches_external_identity( | |
| 476 | actual_start_time_ms: u64, | |
| 477 | expected_start_time_ms: Option<u64>, | |
| 478 | ) -> bool { | |
| 479 | // sysinfo reports process starts at whole-second precision. Normalize the | |
| 480 | // higher-resolution Electron timestamp to that same bucket instead of | |
| 481 | // accepting adjacent seconds, which could attach a quickly reused PID. | |
| 482 | expected_start_time_ms.is_none_or(|expected| { | |
| 483 | actual_start_time_ms == expected - (expected % PROCESS_START_TIME_PRECISION_MS) | |
| 484 | }) | |
| 485 | } | |
| 486 | ||
| 487 | fn select_tracked_pids(rows: &[(u32, u32, u64)], roots: &HashSet<u32>) -> HashSet<u32> { | |
| 488 | let mut children_by_parent = HashMap::<u32, Vec<(u32, u64)>>::new(); | |
| 489 | let mut start_time_by_pid = HashMap::<u32, u64>::new(); | |
| 490 | for (pid, ppid, start_time_ms) in rows { | |
| 491 | children_by_parent | |
| 492 | .entry(*ppid) | |
| 493 | .or_default() | |
| 494 | .push((*pid, *start_time_ms)); | |
| 495 | start_time_by_pid.insert(*pid, *start_time_ms); | |
| 496 | } | |
| 497 | ||
| 498 | let mut tracked = HashSet::new(); | |
| 499 | let mut visited_identities = HashSet::new(); | |
| 500 | let mut queue = roots | |
| 501 | .iter() | |
| 502 | .filter_map(|pid| { | |
| 503 | start_time_by_pid | |
| 504 | .get(pid) | |
| 505 | .map(|start_time_ms| (*pid, *start_time_ms)) | |
| 506 | }) | |
| 507 | .collect::<VecDeque<_>>(); | |
| 508 | ||
| 509 | while let Some((pid, start_time_ms)) = queue.pop_front() { | |
| 510 | if !visited_identities.insert((pid, start_time_ms)) { | |
| 511 | continue; | |
| 512 | } | |
| 513 | tracked.insert(pid); | |
| 514 | if let Some(children) = children_by_parent.get(&pid) { | |
| 515 | queue.extend( | |
| 516 | children | |
| 517 | .iter() | |
| 518 | .copied() | |
| 519 | .filter(|(_, child_start_time_ms)| *child_start_time_ms >= start_time_ms), | |
| 520 | ); | |
| 521 | } | |
| 522 | } | |
| 523 | ||
| 524 | tracked | |
| 525 | } | |
| 526 | ||
| 527 | fn truncate_utf8(mut value: String, max_bytes: usize) -> String { | |
| 528 | if value.len() <= max_bytes { | |
| 529 | return value; | |
| 530 | } | |
| 531 | let mut boundary = max_bytes; | |
| 532 | while !value.is_char_boundary(boundary) { | |
| 533 | boundary = boundary.saturating_sub(1); | |
| 534 | } | |
| 535 | value.truncate(boundary); | |
| 536 | value | |
| 537 | } | |
| 538 | ||
| 539 | fn io_semantics() -> IoSemantics { | |
| 540 | if cfg!(target_os = "windows") { | |
| 541 | IoSemantics::AllIo | |
| 542 | } else { | |
| 543 | IoSemantics::Storage | |
| 544 | } | |
| 545 | } | |
| 546 | ||
| 547 | fn unix_time_ms() -> u64 { | |
| 548 | SystemTime::now() | |
| 549 | .duration_since(UNIX_EPOCH) | |
| 550 | .unwrap_or_default() | |
| 551 | .as_millis() as u64 | |
| 552 | } | |
| 553 | ||
| 554 | fn clamp_sample_interval(sample_interval_ms: u64) -> Option<Duration> { | |
| 555 | (sample_interval_ms > 0).then(|| { | |
| 556 | Duration::from_millis( | |
| 557 | sample_interval_ms.clamp(MIN_SAMPLE_INTERVAL_MS, MAX_SAMPLE_INTERVAL_MS), | |
| 558 | ) | |
| 559 | }) | |
| 560 | } | |
| 561 | ||
| 562 | fn spawn_input_reader() -> Receiver<Input> { | |
| 563 | let (sender, receiver) = mpsc::sync_channel(INPUT_QUEUE_CAPACITY); | |
| 564 | thread::spawn(move || { | |
| 565 | let stdin = io::stdin(); | |
| 566 | for line in stdin.lock().lines() { | |
| 567 | let line = match line { | |
| 568 | Ok(line) => line, | |
| 569 | Err(error) => { | |
| 570 | let _ = sender.send(Input::Invalid(format!( | |
| 571 | "failed reading command stream: {error}" | |
| 572 | ))); | |
| 573 | return; | |
| 574 | } | |
| 575 | }; | |
| 576 | if line.trim().is_empty() { | |
| 577 | continue; | |
| 578 | } | |
| 579 | match serde_json::from_str::<Command>(&line) { | |
| 580 | Ok(command) => { | |
| 581 | if sender.send(Input::Command(command)).is_err() { | |
| 582 | return; | |
| 583 | } | |
| 584 | } | |
| 585 | Err(error) => { | |
| 586 | if sender | |
| 587 | .send(Input::Invalid(format!("invalid command: {error}"))) | |
| 588 | .is_err() | |
| 589 | { | |
| 590 | return; | |
| 591 | } | |
| 592 | } | |
| 593 | } | |
| 594 | } | |
| 595 | }); | |
| 596 | receiver | |
| 597 | } | |
| 598 | ||
| 599 | fn sample_now_deadline( | |
| 600 | current: Option<Instant>, | |
| 601 | interval: Option<Duration>, | |
| 602 | now: Instant, | |
| 603 | ) -> Option<Instant> { | |
| 604 | current.or_else(|| interval.map(|duration| now + duration)) | |
| 605 | } | |
| 606 | ||
| 607 | fn write_event<T: Serialize>(writer: &mut impl Write, event: &T) -> io::Result<()> { | |
| 608 | serde_json::to_writer(&mut *writer, event)?; | |
| 609 | writer.write_all(b"\n")?; | |
| 610 | writer.flush() | |
| 611 | } | |
| 612 | ||
| 613 | fn write_error( | |
| 614 | writer: &mut impl Write, | |
| 615 | code: &'static str, | |
| 616 | message: impl Into<String>, | |
| 617 | recoverable: bool, | |
| 618 | ) -> io::Result<()> { | |
| 619 | write_event( | |
| 620 | writer, | |
| 621 | &ErrorEvent { | |
| 622 | version: PROTOCOL_VERSION, | |
| 623 | event_type: "error", | |
| 624 | code, | |
| 625 | message: message.into(), | |
| 626 | recoverable, | |
| 627 | }, | |
| 628 | ) | |
| 629 | } | |
| 630 | ||
| 631 | fn write_history( | |
| 632 | writer: &mut impl Write, | |
| 633 | request_id: &str, | |
| 634 | snapshots: &[SnapshotEvent], | |
| 635 | ) -> io::Result<()> { | |
| 636 | if snapshots.is_empty() { | |
| 637 | return write_event( | |
| 638 | writer, | |
| 639 | &HistoryChunkEvent { | |
| 640 | version: PROTOCOL_VERSION, | |
| 641 | event_type: "historyChunk", | |
| 642 | request_id, | |
| 643 | done: true, | |
| 644 | snapshots, | |
| 645 | }, | |
| 646 | ); | |
| 647 | } | |
| 648 | ||
| 649 | let chunk_count = snapshots.len().div_ceil(HISTORY_CHUNK_SNAPSHOTS); | |
| 650 | for (index, chunk) in snapshots.chunks(HISTORY_CHUNK_SNAPSHOTS).enumerate() { | |
| 651 | write_event( | |
| 652 | writer, | |
| 653 | &HistoryChunkEvent { | |
| 654 | version: PROTOCOL_VERSION, | |
| 655 | event_type: "historyChunk", | |
| 656 | request_id, | |
| 657 | done: index + 1 == chunk_count, | |
| 658 | snapshots: chunk, | |
| 659 | }, | |
| 660 | )?; | |
| 661 | } | |
| 662 | Ok(()) | |
| 663 | } | |
| 664 | ||
| 665 | fn main() -> io::Result<()> { | |
| 666 | let mut writer = BufWriter::new(io::stdout().lock()); | |
| 667 | write_event( | |
| 668 | &mut writer, | |
| 669 | &HelloEvent { | |
| 670 | version: PROTOCOL_VERSION, | |
| 671 | event_type: "hello", | |
| 672 | sidecar_version: env!("CARGO_PKG_VERSION"), | |
| 673 | sidecar_pid: std::process::id(), | |
| 674 | platform: std::env::consts::OS, | |
| 675 | arch: std::env::consts::ARCH, | |
| 676 | capabilities: Capabilities { | |
| 677 | cumulative_cpu_time: true, | |
| 678 | current_cpu_percent: true, | |
| 679 | resident_memory: true, | |
| 680 | virtual_memory: true, | |
| 681 | io_bytes: true, | |
| 682 | process_start_time: true, | |
| 683 | process_tree: true, | |
| 684 | }, | |
| 685 | }, | |
| 686 | )?; | |
| 687 | ||
| 688 | let receiver = spawn_input_reader(); | |
| 689 | let mut collector = Collector::new(); | |
| 690 | let mut history = HistoryRecorder::default(); | |
| 691 | let mut config: Option<CollectorConfig> = None; | |
| 692 | let mut next_sample_at: Option<Instant> = None; | |
| 693 | let mut streaming_enabled = false; | |
| 694 | ||
| 695 | loop { | |
| 696 | if next_sample_at.is_some_and(|deadline| deadline <= Instant::now()) { | |
| 697 | if let Some(current) = config.as_ref() { | |
| 698 | if let Some(interval) = current.sample_interval { | |
| 699 | let event = collector.sample(current, None); | |
| 700 | history.record(&event); | |
| 701 | if streaming_enabled { | |
| 702 | write_event(&mut writer, &event)?; | |
| 703 | } | |
| 704 | next_sample_at = Some(Instant::now() + interval); | |
| 705 | } else { | |
| 706 | next_sample_at = None; | |
| 707 | } | |
| 708 | } else { | |
| 709 | next_sample_at = None; | |
| 710 | } | |
| 711 | continue; | |
| 712 | } | |
| 713 | ||
| 714 | let timeout = next_sample_at | |
| 715 | .map(|deadline| deadline.saturating_duration_since(Instant::now())) | |
| 716 | .unwrap_or(Duration::from_secs(60)); | |
| 717 | ||
| 718 | match receiver.recv_timeout(timeout) { | |
| 719 | Ok(Input::Invalid(message)) => { | |
| 720 | write_error(&mut writer, "invalid-command", message, true)?; | |
| 721 | } | |
| 722 | Ok(Input::Command(command)) => { | |
| 723 | if command.version() != PROTOCOL_VERSION { | |
| 724 | write_error( | |
| 725 | &mut writer, | |
| 726 | "protocol-mismatch", | |
| 727 | format!( | |
| 728 | "unsupported protocol version {}; expected {PROTOCOL_VERSION}", | |
| 729 | command.version() | |
| 730 | ), | |
| 731 | false, | |
| 732 | )?; | |
| 733 | continue; | |
| 734 | } | |
| 735 | ||
| 736 | match command { | |
| 737 | Command::Configure { | |
| 738 | root_pid, | |
| 739 | sample_interval_ms, | |
| 740 | external_processes, | |
| 741 | .. | |
| 742 | } => { | |
| 743 | let sample_interval = clamp_sample_interval(sample_interval_ms); | |
| 744 | config = Some(CollectorConfig { | |
| 745 | root_pid, | |
| 746 | sample_interval, | |
| 747 | external_processes: external_processes | |
| 748 | .into_iter() | |
| 749 | .map(|process| (process.pid, process.start_time_ms)) | |
| 750 | .collect(), | |
| 751 | }); | |
| 752 | collector.prime_cpu_usage(); | |
| 753 | next_sample_at = sample_interval.map(|_| Instant::now()); | |
| 754 | } | |
| 755 | Command::SetExternalProcesses { processes, .. } => { | |
| 756 | if let Some(current) = config.as_mut() { | |
| 757 | current.external_processes = processes | |
| 758 | .into_iter() | |
| 759 | .map(|process| (process.pid, process.start_time_ms)) | |
| 760 | .collect(); | |
| 761 | } else { | |
| 762 | write_error( | |
| 763 | &mut writer, | |
| 764 | "not-configured", | |
| 765 | "configure must be sent before external processes", | |
| 766 | true, | |
| 767 | )?; | |
| 768 | } | |
| 769 | } | |
| 770 | Command::SetSampleInterval { | |
| 771 | sample_interval_ms, .. | |
| 772 | } => { | |
| 773 | if let Some(current) = config.as_mut() { | |
| 774 | current.sample_interval = clamp_sample_interval(sample_interval_ms); | |
| 775 | next_sample_at = current | |
| 776 | .sample_interval | |
| 777 | .map(|interval| Instant::now() + interval); | |
| 778 | } else { | |
| 779 | write_error( | |
| 780 | &mut writer, | |
| 781 | "not-configured", | |
| 782 | "configure must be sent before changing the sample interval", | |
| 783 | true, | |
| 784 | )?; | |
| 785 | } | |
| 786 | } | |
| 787 | Command::SetStreaming { enabled, .. } => { | |
| 788 | streaming_enabled = enabled; | |
| 789 | } | |
| 790 | Command::SampleNow { request_id, .. } => { | |
| 791 | if let Some(current) = config.as_ref() { | |
| 792 | let event = collector.sample(current, Some(request_id)); | |
| 793 | history.record(&event); | |
| 794 | write_event(&mut writer, &event)?; | |
| 795 | next_sample_at = sample_now_deadline( | |
| 796 | next_sample_at, | |
| 797 | current.sample_interval, | |
| 798 | Instant::now(), | |
| 799 | ); | |
| 800 | } else { | |
| 801 | write_error( | |
| 802 | &mut writer, | |
| 803 | "not-configured", | |
| 804 | "configure must be sent before sampling", | |
| 805 | true, | |
| 806 | )?; | |
| 807 | } | |
| 808 | } | |
| 809 | Command::ReadHistory { | |
| 810 | request_id, | |
| 811 | window_ms, | |
| 812 | .. | |
| 813 | } => { | |
| 814 | if config.is_some() { | |
| 815 | let snapshots = history.read(window_ms, unix_time_ms()); | |
| 816 | write_history(&mut writer, &request_id, &snapshots)?; | |
| 817 | } else { | |
| 818 | write_error( | |
| 819 | &mut writer, | |
| 820 | "not-configured", | |
| 821 | "configure must be sent before reading history", | |
| 822 | true, | |
| 823 | )?; | |
| 824 | } | |
| 825 | } | |
| 826 | Command::Shutdown { .. } => return Ok(()), | |
| 827 | } | |
| 828 | } | |
| 829 | Err(RecvTimeoutError::Timeout) => {} | |
| 830 | Err(RecvTimeoutError::Disconnected) => return Ok(()), | |
| 831 | } | |
| 832 | } | |
| 833 | } | |
| 834 | ||
| 835 | #[cfg(test)] | |
| 836 | mod tests { | |
| 837 | use super::*; | |
| 838 | ||
| 839 | #[test] | |
| 840 | fn selects_roots_and_all_descendants() { | |
| 841 | let rows = vec![ | |
| 842 | (10, 1, 1_000), | |
| 843 | (11, 10, 1_100), | |
| 844 | (12, 11, 1_200), | |
| 845 | (20, 1, 2_000), | |
| 846 | (21, 20, 2_100), | |
| 847 | (30, 99, 3_000), | |
| 848 | ]; | |
| 849 | let tracked = select_tracked_pids(&rows, &HashSet::from([10, 20])); | |
| 850 | ||
| 851 | assert_eq!(tracked, HashSet::from([10, 11, 12, 20, 21])); | |
| 852 | } | |
| 853 | ||
| 854 | #[test] | |
| 855 | fn rejects_descendants_older_than_a_reused_parent_pid() { | |
| 856 | let rows = vec![ | |
| 857 | (20, 1, 5_000), | |
| 858 | (21, 20, 4_000), | |
| 859 | (22, 20, 5_100), | |
| 860 | (23, 21, 5_200), | |
| 861 | ]; | |
| 862 | let tracked = select_tracked_pids(&rows, &HashSet::from([20])); | |
| 863 | ||
| 864 | assert_eq!(tracked, HashSet::from([20, 22])); | |
| 865 | } | |
| 866 | ||
| 867 | #[test] | |
| 868 | fn ignores_missing_roots() { | |
| 869 | let rows = vec![(10, 1, 1_000), (11, 10, 1_100)]; | |
| 870 | let tracked = select_tracked_pids(&rows, &HashSet::from([99])); | |
| 871 | ||
| 872 | assert!(tracked.is_empty()); | |
| 873 | } | |
| 874 | ||
| 875 | #[test] | |
| 876 | fn validates_external_process_start_identity() { | |
| 877 | assert!(matches_external_identity(10_000, None)); | |
| 878 | assert!(matches_external_identity(10_000, Some(10_999))); | |
| 879 | assert!(!matches_external_identity(10_000, Some(11_000))); | |
| 880 | assert!(!matches_external_identity(10_000, Some(9_999))); | |
| 881 | } | |
| 882 | ||
| 883 | #[test] | |
| 884 | fn decodes_protocol_commands() { | |
| 885 | let configure = serde_json::from_str::<Command>( | |
| 886 | r#"{"version":2,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#, | |
| 887 | ) | |
| 888 | .expect("configure command"); | |
| 889 | ||
| 890 | match configure { | |
| 891 | Command::Configure { | |
| 892 | root_pid, | |
| 893 | sample_interval_ms, | |
| 894 | external_processes, | |
| 895 | .. | |
| 896 | } => { | |
| 897 | assert_eq!(root_pid, 42); | |
| 898 | assert_eq!(sample_interval_ms, 1_000); | |
| 899 | assert_eq!(external_processes[0].pid, 7); | |
| 900 | assert_eq!(external_processes[0].start_time_ms, None); | |
| 901 | } | |
| 902 | _ => panic!("unexpected command"), | |
| 903 | } | |
| 904 | ||
| 905 | let read_history = serde_json::from_str::<Command>( | |
| 906 | r#"{"version":2,"type":"readHistory","requestId":"history-1","windowMs":60000}"#, | |
| 907 | ) | |
| 908 | .expect("read history command"); | |
| 909 | assert!(matches!( | |
| 910 | read_history, | |
| 911 | Command::ReadHistory { | |
| 912 | request_id, | |
| 913 | window_ms: 60_000, | |
| 914 | .. | |
| 915 | } if request_id == "history-1" | |
| 916 | )); | |
| 917 | } | |
| 918 | ||
| 919 | #[test] | |
| 920 | fn clamps_sample_interval() { | |
| 921 | assert_eq!(clamp_sample_interval(0), None); | |
| 922 | assert_eq!(clamp_sample_interval(1), Some(Duration::from_millis(250))); | |
| 923 | assert_eq!( | |
| 924 | clamp_sample_interval(100_000), | |
| 925 | Some(Duration::from_millis(60_000)) | |
| 926 | ); | |
| 927 | } | |
| 928 | ||
| 929 | #[test] | |
| 930 | fn counts_selected_processes_that_could_not_be_materialized() { | |
| 931 | assert_eq!(inaccessible_process_count(5, 3), 2); | |
| 932 | assert_eq!(inaccessible_process_count(3, 5), 0); | |
| 933 | } | |
| 934 | ||
| 935 | #[test] | |
| 936 | fn waits_for_a_cpu_measurement_window_after_priming() { | |
| 937 | let baseline = Instant::now(); | |
| 938 | ||
| 939 | assert_eq!( | |
| 940 | remaining_cpu_measurement_delay(Some(baseline), baseline), | |
| 941 | Some(MINIMUM_CPU_UPDATE_INTERVAL) | |
| 942 | ); | |
| 943 | assert_eq!( | |
| 944 | remaining_cpu_measurement_delay(Some(baseline), baseline + MINIMUM_CPU_UPDATE_INTERVAL), | |
| 945 | None | |
| 946 | ); | |
| 947 | assert_eq!(remaining_cpu_measurement_delay(None, baseline), None); | |
| 948 | } | |
| 949 | ||
| 950 | #[test] | |
| 951 | fn retains_bounded_history_without_request_ids() { | |
| 952 | let mut history = HistoryRecorder::default(); | |
| 953 | for sequence in 0..=MAX_HISTORY_SNAPSHOTS { | |
| 954 | history.record(&SnapshotEvent { | |
| 955 | version: PROTOCOL_VERSION, | |
| 956 | event_type: "snapshot", | |
| 957 | sequence: sequence as u64, | |
| 958 | sampled_at_unix_ms: sequence as u64 * 1_000, | |
| 959 | collection_duration_micros: 1, | |
| 960 | scanned_process_count: 0, | |
| 961 | retained_process_count: 0, | |
| 962 | inaccessible_process_count: 0, | |
| 963 | request_id: Some("request".to_owned()), | |
| 964 | external_processes: vec![ExternalProcess { | |
| 965 | pid: 7, | |
| 966 | start_time_ms: Some(1_000), | |
| 967 | }], | |
| 968 | processes: Vec::new(), | |
| 969 | }); | |
| 970 | } | |
| 971 | ||
| 972 | assert_eq!(history.snapshots.len(), MAX_HISTORY_SNAPSHOTS); | |
| 973 | assert!( | |
| 974 | history | |
| 975 | .snapshots | |
| 976 | .iter() | |
| 977 | .all(|snapshot| snapshot.request_id.is_none()) | |
| 978 | ); | |
| 979 | assert!(history.snapshots.iter().all(|snapshot| { | |
| 980 | snapshot.external_processes.len() == 1 | |
| 981 | && snapshot.external_processes[0].pid == 7 | |
| 982 | && snapshot.external_processes[0].start_time_ms == Some(1_000) | |
| 983 | })); | |
| 984 | assert_eq!( | |
| 985 | history | |
| 986 | .read(10_000, MAX_HISTORY_SNAPSHOTS as u64 * 1_000) | |
| 987 | .len(), | |
| 988 | 11 | |
| 989 | ); | |
| 990 | } | |
| 991 | ||
| 992 | #[test] | |
| 993 | fn excludes_and_trims_future_history_after_the_clock_moves_backward() { | |
| 994 | let mut history = HistoryRecorder::default(); | |
| 995 | let snapshot = SnapshotEvent { | |
| 996 | version: PROTOCOL_VERSION, | |
| 997 | event_type: "snapshot", | |
| 998 | sequence: 1, | |
| 999 | sampled_at_unix_ms: 2_000, | |
| 1000 | collection_duration_micros: 1, | |
| 1001 | scanned_process_count: 0, | |
| 1002 | retained_process_count: 0, | |
| 1003 | inaccessible_process_count: 0, | |
| 1004 | request_id: None, | |
| 1005 | external_processes: Vec::new(), | |
| 1006 | processes: Vec::new(), | |
| 1007 | }; | |
| 1008 | history.record(&snapshot); | |
| 1009 | ||
| 1010 | assert!(history.read(0, 1_000).is_empty()); | |
| 1011 | ||
| 1012 | history.record(&SnapshotEvent { | |
| 1013 | sequence: 2, | |
| 1014 | sampled_at_unix_ms: 1_000, | |
| 1015 | ..snapshot | |
| 1016 | }); | |
| 1017 | assert_eq!(history.snapshots.len(), 1); | |
| 1018 | assert_eq!( | |
| 1019 | history.snapshots.front().map(|entry| entry.sequence), | |
| 1020 | Some(2) | |
| 1021 | ); | |
| 1022 | } | |
| 1023 | ||
| 1024 | #[test] | |
| 1025 | fn bounds_history_by_estimated_process_bytes() { | |
| 1026 | let mut history = HistoryRecorder::default(); | |
| 1027 | let command = "x".repeat(128); | |
| 1028 | let process = ProcessSample { | |
| 1029 | pid: 1, | |
| 1030 | ppid: 0, | |
| 1031 | start_time_ms: 0, | |
| 1032 | run_time_ms: 0, | |
| 1033 | name: "process".to_owned(), | |
| 1034 | command, | |
| 1035 | status: "Run".to_owned(), | |
| 1036 | cpu_percent: 0.0, | |
| 1037 | cpu_time_ms: 0, | |
| 1038 | resident_bytes: 0, | |
| 1039 | virtual_bytes: 0, | |
| 1040 | io_read_bytes: 0, | |
| 1041 | io_write_bytes: 0, | |
| 1042 | io_semantics: IoSemantics::Storage, | |
| 1043 | }; | |
| 1044 | let snapshot_bytes = | |
| 1045 | std::mem::size_of::<SnapshotEvent>() + process.estimated_history_bytes(); | |
| 1046 | for sequence in 0..3 { | |
| 1047 | history.record_with_limits( | |
| 1048 | &SnapshotEvent { | |
| 1049 | version: PROTOCOL_VERSION, | |
| 1050 | event_type: "snapshot", | |
| 1051 | sequence, | |
| 1052 | sampled_at_unix_ms: sequence * 1_000, | |
| 1053 | collection_duration_micros: 1, | |
| 1054 | scanned_process_count: 1, | |
| 1055 | retained_process_count: 1, | |
| 1056 | inaccessible_process_count: 0, | |
| 1057 | request_id: None, | |
| 1058 | external_processes: Vec::new(), | |
| 1059 | processes: vec![ProcessSample { | |
| 1060 | pid: sequence as u32 + 1, | |
| 1061 | start_time_ms: sequence * 1_000, | |
| 1062 | ..process.clone() | |
| 1063 | }], | |
| 1064 | }, | |
| 1065 | 3, | |
| 1066 | 3, | |
| 1067 | snapshot_bytes * 2, | |
| 1068 | ); | |
| 1069 | } | |
| 1070 | ||
| 1071 | assert!(history.retained_bytes <= snapshot_bytes * 2); | |
| 1072 | assert_eq!(history.snapshots.len(), 2); | |
| 1073 | assert_eq!( | |
| 1074 | history.snapshots.front().map(|snapshot| snapshot.sequence), | |
| 1075 | Some(1) | |
| 1076 | ); | |
| 1077 | } | |
| 1078 | ||
| 1079 | #[test] | |
| 1080 | fn counts_external_processes_toward_history_limits() { | |
| 1081 | let mut history = HistoryRecorder::default(); | |
| 1082 | let external_processes = (1..=128) | |
| 1083 | .map(|pid| ExternalProcess { | |
| 1084 | pid, | |
| 1085 | start_time_ms: Some(u64::from(pid) * 1_000), | |
| 1086 | }) | |
| 1087 | .collect::<Vec<_>>(); | |
| 1088 | let snapshot = SnapshotEvent { | |
| 1089 | version: PROTOCOL_VERSION, | |
| 1090 | event_type: "snapshot", | |
| 1091 | sequence: 0, | |
| 1092 | sampled_at_unix_ms: 0, | |
| 1093 | collection_duration_micros: 1, | |
| 1094 | scanned_process_count: 0, | |
| 1095 | retained_process_count: 0, | |
| 1096 | inaccessible_process_count: 0, | |
| 1097 | request_id: None, | |
| 1098 | external_processes, | |
| 1099 | processes: Vec::new(), | |
| 1100 | }; | |
| 1101 | let snapshot_bytes = snapshot.estimated_history_bytes(); | |
| 1102 | let snapshot_entries = snapshot.retained_entry_count(); | |
| 1103 | ||
| 1104 | for sequence in 0..3 { | |
| 1105 | history.record_with_limits( | |
| 1106 | &SnapshotEvent { | |
| 1107 | sequence, | |
| 1108 | sampled_at_unix_ms: sequence * 1_000, | |
| 1109 | ..snapshot.clone() | |
| 1110 | }, | |
| 1111 | 3, | |
| 1112 | snapshot_entries * 2, | |
| 1113 | snapshot_bytes * 2, | |
| 1114 | ); | |
| 1115 | } | |
| 1116 | ||
| 1117 | assert_eq!(history.retained_entry_count, snapshot_entries * 2); | |
| 1118 | assert!(history.retained_bytes <= snapshot_bytes * 2); | |
| 1119 | assert_eq!(history.snapshots.len(), 2); | |
| 1120 | assert_eq!( | |
| 1121 | history.snapshots.front().map(|snapshot| snapshot.sequence), | |
| 1122 | Some(1) | |
| 1123 | ); | |
| 1124 | } | |
| 1125 | ||
| 1126 | #[test] | |
| 1127 | fn truncates_process_strings_at_utf8_boundaries() { | |
| 1128 | let value = "é".repeat(MAX_PROCESS_NAME_BYTES); | |
| 1129 | let truncated = truncate_utf8(value, MAX_PROCESS_NAME_BYTES - 1); | |
| 1130 | ||
| 1131 | assert!(truncated.len() < MAX_PROCESS_NAME_BYTES); | |
| 1132 | assert!(truncated.is_char_boundary(truncated.len())); | |
| 1133 | } | |
| 1134 | ||
| 1135 | #[test] | |
| 1136 | fn refreshes_commands_without_enumerating_linux_tasks() { | |
| 1137 | let refresh_kind = process_refresh_kind(); | |
| 1138 | ||
| 1139 | assert_eq!(refresh_kind.cmd(), UpdateKind::Always); | |
| 1140 | assert!(!refresh_kind.tasks()); | |
| 1141 | assert!(refresh_kind.cpu()); | |
| 1142 | assert!(refresh_kind.memory()); | |
| 1143 | assert!(refresh_kind.disk_usage()); | |
| 1144 | } | |
| 1145 | ||
| 1146 | #[test] | |
| 1147 | fn sample_now_does_not_postpone_an_existing_periodic_deadline() { | |
| 1148 | let now = Instant::now(); | |
| 1149 | let deadline = now + Duration::from_secs(1); | |
| 1150 | ||
| 1151 | assert_eq!( | |
| 1152 | sample_now_deadline( | |
| 1153 | Some(deadline), | |
| 1154 | Some(Duration::from_secs(5)), | |
| 1155 | now + Duration::from_millis(100) | |
| 1156 | ), | |
| 1157 | Some(deadline) | |
| 1158 | ); | |
| 1159 | } | |
| 1160 | } |
package.json+2| ... | ... | @@ -20,11 +20,13 @@ |
| 20 | 20 | "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", |
| 21 | 21 | "build:marketing": "vp run --filter @t3tools/marketing build", |
| 22 | 22 | "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", |
| 23 | "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", | |
| 23 | 24 | "typecheck": "vp run -r --concurrency-limit 2 typecheck", |
| 24 | 25 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 25 | 26 | "lint": "vp lint --report-unused-disable-directives", |
| 26 | 27 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 27 | 28 | "test": "vp run -r test", |
| 29 | "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", | |
| 28 | 30 | "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", |
| 29 | 31 | "fmt": "vp fmt", |
| 30 | 32 | "fmt:check": "vp fmt --check", |
packages/client-runtime/src/rpc/client.ts+106-87| ... | ... | @@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 49 | 49 | | typeof WS_METHODS.subscribeTerminalMetadata |
| 50 | 50 | | typeof WS_METHODS.subscribePreviewEvents |
| 51 | 51 | | typeof WS_METHODS.subscribeDiscoveredLocalServers |
| 52 | | typeof WS_METHODS.subscribeResourceTelemetry | |
| 52 | 53 | | typeof WS_METHODS.previewAutomationConnect |
| 53 | 54 | | typeof WS_METHODS.subscribeVcsStatus |
| 54 | 55 | | typeof WS_METHODS.terminalAttach; |
| ... | ... | @@ -62,6 +63,23 @@ export type EnvironmentStreamRpcTag = |
| 62 | 63 | | EnvironmentStreamCommandRpcTag; |
| 63 | 64 | |
| 64 | 65 | export type EnvironmentUnaryRpcTag = Exclude<EnvironmentRpcTag, EnvironmentStreamRpcTag>; |
| 66 | ||
| 67 | export interface EnvironmentRpcSubscriptionObservation { | |
| 68 | readonly environmentId: string; | |
| 69 | readonly method: EnvironmentSubscriptionRpcTag; | |
| 70 | readonly input: unknown; | |
| 71 | } | |
| 72 | ||
| 73 | export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ | |
| 74 | readonly observe: ( | |
| 75 | subscription: EnvironmentRpcSubscriptionObservation, | |
| 76 | ) => Effect.Effect<Effect.Effect<void>>; | |
| 77 | }>("@t3tools/client-runtime/rpc/EnvironmentRpcSubscriptionObserver", { | |
| 78 | defaultValue: () => ({ | |
| 79 | observe: () => Effect.succeed(Effect.void), | |
| 80 | }), | |
| 81 | }) {} | |
| 82 | ||
| 65 | 83 | const isRpcClientError = Schema.is(RpcClientError.RpcClientError); |
| 66 | 84 | |
| 67 | 85 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; |
| ... | ... | @@ -166,93 +184,94 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( |
| 166 | 184 | EnvironmentSupervisor |
| 167 | 185 | > { |
| 168 | 186 | return Stream.unwrap( |
| 169 | EnvironmentSupervisor.pipe( | |
| 170 | Effect.map((supervisor) => { | |
| 171 | const sessionChanges = SubscriptionRef.changes(supervisor.session); | |
| 172 | const sessions = | |
| 173 | options?.resubscribe === undefined | |
| 174 | ? sessionChanges | |
| 175 | : Stream.merge( | |
| 176 | sessionChanges, | |
| 177 | options.resubscribe.pipe( | |
| 178 | Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), | |
| 179 | ), | |
| 180 | ); | |
| 181 | return sessions.pipe( | |
| 182 | Stream.switchMap( | |
| 183 | Option.match({ | |
| 184 | onNone: () => Stream.empty, | |
| 185 | onSome: (session) => { | |
| 186 | const method = session.client[tag] as ( | |
| 187 | input: EnvironmentRpcInput<TTag>, | |
| 188 | ) => Stream.Stream< | |
| 189 | EnvironmentRpcStreamValue<TTag>, | |
| 190 | EnvironmentRpcStreamFailure<TTag> | |
| 191 | >; | |
| 192 | const subscribeToSession = (): Stream.Stream< | |
| 193 | EnvironmentRpcStreamValue<TTag>, | |
| 194 | EnvironmentRpcStreamFailure<TTag> | |
| 195 | > => | |
| 196 | Stream.suspend(() => | |
| 197 | Stream.unwrap( | |
| 198 | makeInput(session).pipe( | |
| 199 | Effect.map((input) => | |
| 200 | method(input).pipe( | |
| 201 | Stream.catchCause((cause) => { | |
| 202 | const hasOnlyExpectedFailures = | |
| 203 | cause.reasons.length > 0 && | |
| 204 | cause.reasons.every((reason) => reason._tag === "Fail"); | |
| 205 | const isTransportFailure = | |
| 206 | hasOnlyExpectedFailures && | |
| 207 | cause.reasons.every( | |
| 208 | (reason) => | |
| 209 | reason._tag === "Fail" && isRpcClientError(reason.error), | |
| 210 | ); | |
| 211 | if (isTransportFailure) { | |
| 212 | return Stream.fromEffect( | |
| 213 | Effect.logWarning( | |
| 214 | "Durable RPC subscription lost its transport; waiting for the next session.", | |
| 215 | { | |
| 216 | cause: Cause.pretty(cause), | |
| 217 | method: tag, | |
| 218 | environmentId: supervisor.target.environmentId, | |
| 219 | }, | |
| 220 | ), | |
| 221 | ).pipe(Stream.drain); | |
| 222 | } | |
| 223 | if ( | |
| 224 | hasOnlyExpectedFailures && | |
| 225 | options?.onExpectedFailure !== undefined | |
| 226 | ) { | |
| 227 | const handled = Stream.fromEffect( | |
| 228 | options.onExpectedFailure(cause), | |
| 229 | ).pipe(Stream.drain); | |
| 230 | if (options.retryExpectedFailureAfter === undefined) { | |
| 231 | return handled; | |
| 232 | } | |
| 233 | return handled.pipe( | |
| 234 | Stream.concat( | |
| 235 | Stream.fromEffect( | |
| 236 | Effect.sleep(options.retryExpectedFailureAfter), | |
| 237 | ).pipe(Stream.drain), | |
| 238 | ), | |
| 239 | Stream.concat(subscribeToSession()), | |
| 240 | ); | |
| 241 | } | |
| 242 | return Stream.failCause(cause); | |
| 243 | }), | |
| 244 | ), | |
| 245 | ), | |
| 246 | ), | |
| 247 | ), | |
| 248 | ); | |
| 249 | return subscribeToSession(); | |
| 250 | }, | |
| 251 | }), | |
| 252 | ), | |
| 253 | ); | |
| 254 | }), | |
| 255 | ), | |
| 187 | Effect.gen(function* () { | |
| 188 | const supervisor = yield* EnvironmentSupervisor; | |
| 189 | const observer = yield* EnvironmentRpcSubscriptionObserver; | |
| 190 | const sessionChanges = SubscriptionRef.changes(supervisor.session); | |
| 191 | const sessions = | |
| 192 | options?.resubscribe === undefined | |
| 193 | ? sessionChanges | |
| 194 | : Stream.merge( | |
| 195 | sessionChanges, | |
| 196 | options.resubscribe.pipe( | |
| 197 | Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), | |
| 198 | ), | |
| 199 | ); | |
| 200 | return sessions.pipe( | |
| 201 | Stream.switchMap( | |
| 202 | Option.match({ | |
| 203 | onNone: () => Stream.empty, | |
| 204 | onSome: (session) => { | |
| 205 | const method = session.client[tag] as ( | |
| 206 | input: EnvironmentRpcInput<TTag>, | |
| 207 | ) => Stream.Stream< | |
| 208 | EnvironmentRpcStreamValue<TTag>, | |
| 209 | EnvironmentRpcStreamFailure<TTag> | |
| 210 | >; | |
| 211 | const subscribeToSession = (): Stream.Stream< | |
| 212 | EnvironmentRpcStreamValue<TTag>, | |
| 213 | EnvironmentRpcStreamFailure<TTag> | |
| 214 | > => | |
| 215 | Stream.suspend(() => | |
| 216 | Stream.unwrap( | |
| 217 | Effect.gen(function* () { | |
| 218 | const input = yield* makeInput(session); | |
| 219 | const completeObservation = yield* observer.observe({ | |
| 220 | environmentId: supervisor.target.environmentId, | |
| 221 | method: tag, | |
| 222 | input, | |
| 223 | }); | |
| 224 | return method(input).pipe( | |
| 225 | Stream.ensuring(completeObservation), | |
| 226 | Stream.catchCause((cause) => { | |
| 227 | const hasOnlyExpectedFailures = | |
| 228 | cause.reasons.length > 0 && | |
| 229 | cause.reasons.every((reason) => reason._tag === "Fail"); | |
| 230 | const isTransportFailure = | |
| 231 | hasOnlyExpectedFailures && | |
| 232 | cause.reasons.every( | |
| 233 | (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), | |
| 234 | ); | |
| 235 | if (isTransportFailure) { | |
| 236 | return Stream.fromEffect( | |
| 237 | Effect.logWarning( | |
| 238 | "Durable RPC subscription lost its transport; waiting for the next session.", | |
| 239 | { | |
| 240 | cause: Cause.pretty(cause), | |
| 241 | method: tag, | |
| 242 | environmentId: supervisor.target.environmentId, | |
| 243 | }, | |
| 244 | ), | |
| 245 | ).pipe(Stream.drain); | |
| 246 | } | |
| 247 | if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { | |
| 248 | const handled = Stream.fromEffect( | |
| 249 | options.onExpectedFailure(cause), | |
| 250 | ).pipe(Stream.drain); | |
| 251 | if (options.retryExpectedFailureAfter === undefined) { | |
| 252 | return handled; | |
| 253 | } | |
| 254 | return handled.pipe( | |
| 255 | Stream.concat( | |
| 256 | Stream.fromEffect( | |
| 257 | Effect.sleep(options.retryExpectedFailureAfter), | |
| 258 | ).pipe(Stream.drain), | |
| 259 | ), | |
| 260 | Stream.concat(subscribeToSession()), | |
| 261 | ); | |
| 262 | } | |
| 263 | return Stream.failCause(cause); | |
| 264 | }), | |
| 265 | ); | |
| 266 | }), | |
| 267 | ), | |
| 268 | ); | |
| 269 | return subscribeToSession(); | |
| 270 | }, | |
| 271 | }), | |
| 272 | ), | |
| 273 | ); | |
| 274 | }), | |
| 256 | 275 | ).pipe( |
| 257 | 276 | Stream.withSpan("EnvironmentRpc.subscribe", { |
| 258 | 277 | attributes: { "rpc.method": tag }, |
packages/client-runtime/src/state/server.ts+18| ... | ... | @@ -298,6 +298,16 @@ export function createServerEnvironmentAtoms<R, E>( |
| 298 | 298 | label: "environment-data:server:process-resource-history", |
| 299 | 299 | tag: WS_METHODS.serverGetProcessResourceHistory, |
| 300 | 300 | }), |
| 301 | resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | |
| 302 | label: "environment-data:server:resource-telemetry", | |
| 303 | tag: WS_METHODS.subscribeResourceTelemetry, | |
| 304 | idleTtlMs: 0, | |
| 305 | }), | |
| 306 | resourceTelemetryHistory: createEnvironmentRpcQueryAtomFamily(runtime, { | |
| 307 | label: "environment-data:server:resource-telemetry-history", | |
| 308 | tag: WS_METHODS.serverGetResourceTelemetryHistory, | |
| 309 | staleTimeMs: 5_000, | |
| 310 | }), | |
| 301 | 311 | configProjection, |
| 302 | 312 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { |
| 303 | 313 | label: "environment-data:server:welcome", |
| ... | ... | @@ -349,5 +359,13 @@ export function createServerEnvironmentAtoms<R, E>( |
| 349 | 359 | label: "environment-data:server:signal-process", |
| 350 | 360 | tag: WS_METHODS.serverSignalProcess, |
| 351 | 361 | }), |
| 362 | retryResourceTelemetry: createEnvironmentRpcCommand(runtime, { | |
| 363 | label: "environment-data:server:retry-resource-telemetry", | |
| 364 | tag: WS_METHODS.serverRetryResourceTelemetry, | |
| 365 | concurrency: { | |
| 366 | mode: "singleFlight", | |
| 367 | key: ({ environmentId }) => environmentId, | |
| 368 | }, | |
| 369 | }), | |
| 352 | 370 | }; |
| 353 | 371 | } |
packages/contracts/src/background.test.ts created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { ClientActivityClientId } from "./background.ts"; | |
| 5 | ||
| 6 | const decodeClientActivityClientId = Schema.decodeUnknownSync(ClientActivityClientId); | |
| 7 | ||
| 8 | describe("ClientActivityClientId", () => { | |
| 9 | it("trims and accepts bounded client identifiers", () => { | |
| 10 | expect(decodeClientActivityClientId(" client-1 ")).toBe("client-1"); | |
| 11 | expect(decodeClientActivityClientId("x".repeat(128))).toBe("x".repeat(128)); | |
| 12 | }); | |
| 13 | ||
| 14 | it("rejects empty and oversized client identifiers", () => { | |
| 15 | expect(() => decodeClientActivityClientId(" ")).toThrow(); | |
| 16 | expect(() => decodeClientActivityClientId("x".repeat(129))).toThrow(); | |
| 17 | }); | |
| 18 | }); |
packages/contracts/src/background.ts created+110| ... | ... | @@ -0,0 +1,110 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { | |
| 4 | AuthSessionId, | |
| 5 | EnvironmentId, | |
| 6 | RpcClientId, | |
| 7 | ThreadId, | |
| 8 | TrimmedNonEmptyString, | |
| 9 | } from "./baseSchemas.ts"; | |
| 10 | import { ProviderInstanceId } from "./providerInstance.ts"; | |
| 11 | ||
| 12 | export const BackgroundBooleanState = Schema.Literals(["true", "false", "unknown"]); | |
| 13 | export type BackgroundBooleanState = typeof BackgroundBooleanState.Type; | |
| 14 | ||
| 15 | export const HostPowerThermalState = Schema.Literals([ | |
| 16 | "unknown", | |
| 17 | "nominal", | |
| 18 | "fair", | |
| 19 | "serious", | |
| 20 | "critical", | |
| 21 | ]); | |
| 22 | export type HostPowerThermalState = typeof HostPowerThermalState.Type; | |
| 23 | ||
| 24 | export const HostPowerSource = Schema.Literals([ | |
| 25 | "unknown", | |
| 26 | "node-macos-shell", | |
| 27 | "node-macos-native", | |
| 28 | "node-linux", | |
| 29 | "node-windows", | |
| 30 | "electron-main", | |
| 31 | ]); | |
| 32 | export type HostPowerSource = typeof HostPowerSource.Type; | |
| 33 | ||
| 34 | export const HostPowerSnapshot = Schema.Struct({ | |
| 35 | source: HostPowerSource, | |
| 36 | idle: BackgroundBooleanState, | |
| 37 | idleSeconds: Schema.NullOr(Schema.Number), | |
| 38 | locked: BackgroundBooleanState, | |
| 39 | suspended: Schema.Boolean, | |
| 40 | onBattery: BackgroundBooleanState, | |
| 41 | lowPowerMode: BackgroundBooleanState, | |
| 42 | thermalState: HostPowerThermalState, | |
| 43 | stale: Schema.Boolean, | |
| 44 | updatedAt: Schema.DateTimeUtc, | |
| 45 | }); | |
| 46 | export type HostPowerSnapshot = typeof HostPowerSnapshot.Type; | |
| 47 | ||
| 48 | export const BackgroundScope = Schema.Union([ | |
| 49 | Schema.Struct({ type: Schema.Literal("server-config") }), | |
| 50 | Schema.Struct({ | |
| 51 | type: Schema.Literal("provider-status"), | |
| 52 | instanceId: Schema.optionalKey(ProviderInstanceId), | |
| 53 | }), | |
| 54 | Schema.Struct({ type: Schema.Literal("vcs-status"), cwd: Schema.String }), | |
| 55 | Schema.Struct({ type: Schema.Literal("git-refs"), cwd: Schema.String }), | |
| 56 | Schema.Struct({ type: Schema.Literal("diagnostics") }), | |
| 57 | Schema.Struct({ type: Schema.Literal("thread"), threadId: ThreadId }), | |
| 58 | ]); | |
| 59 | export type BackgroundScope = typeof BackgroundScope.Type; | |
| 60 | ||
| 61 | export const ClientKind = Schema.Literals(["web", "desktop-renderer", "mobile", "unknown"]); | |
| 62 | export type ClientKind = typeof ClientKind.Type; | |
| 63 | ||
| 64 | export const ClientActivityClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | |
| 65 | export type ClientActivityClientId = typeof ClientActivityClientId.Type; | |
| 66 | ||
| 67 | export const ClientActivityReportInput = Schema.Struct({ | |
| 68 | environmentId: Schema.optionalKey(EnvironmentId), | |
| 69 | clientId: ClientActivityClientId, | |
| 70 | clientKind: ClientKind, | |
| 71 | visible: Schema.Boolean, | |
| 72 | focused: Schema.Boolean, | |
| 73 | recentlyInteracted: Schema.Boolean, | |
| 74 | appState: Schema.optionalKey(Schema.Literals(["active", "inactive", "background", "unknown"])), | |
| 75 | lowPowerMode: Schema.optionalKey(BackgroundBooleanState), | |
| 76 | batteryState: Schema.optionalKey(Schema.Literals(["unknown", "unplugged", "charging", "full"])), | |
| 77 | networkType: Schema.optionalKey(Schema.String), | |
| 78 | scopes: Schema.Array(BackgroundScope), | |
| 79 | ttlMs: Schema.optionalKey(Schema.Number), | |
| 80 | observedAt: Schema.DateTimeUtc, | |
| 81 | }); | |
| 82 | export type ClientActivityReportInput = typeof ClientActivityReportInput.Type; | |
| 83 | ||
| 84 | export const ClientActivityLease = Schema.Struct({ | |
| 85 | sessionId: AuthSessionId, | |
| 86 | rpcClientId: RpcClientId, | |
| 87 | clientId: ClientActivityClientId, | |
| 88 | clientKind: ClientKind, | |
| 89 | visible: Schema.Boolean, | |
| 90 | focused: Schema.Boolean, | |
| 91 | recentlyInteracted: Schema.Boolean, | |
| 92 | appState: Schema.optionalKey(Schema.Literals(["active", "inactive", "background", "unknown"])), | |
| 93 | lowPowerMode: Schema.optionalKey(BackgroundBooleanState), | |
| 94 | batteryState: Schema.optionalKey(Schema.Literals(["unknown", "unplugged", "charging", "full"])), | |
| 95 | networkType: Schema.optionalKey(Schema.String), | |
| 96 | scopes: Schema.Array(BackgroundScope), | |
| 97 | updatedAt: Schema.DateTimeUtc, | |
| 98 | expiresAt: Schema.DateTimeUtc, | |
| 99 | }); | |
| 100 | export type ClientActivityLease = typeof ClientActivityLease.Type; | |
| 101 | ||
| 102 | export const BackgroundPolicySnapshot = Schema.Struct({ | |
| 103 | hostPower: HostPowerSnapshot, | |
| 104 | leases: Schema.Array(ClientActivityLease), | |
| 105 | activeForegroundLeaseCount: Schema.Number, | |
| 106 | activeScopeKeys: Schema.Array(Schema.String), | |
| 107 | shouldRunOpportunisticWork: Schema.Boolean, | |
| 108 | updatedAt: Schema.DateTimeUtc, | |
| 109 | }); | |
| 110 | export type BackgroundPolicySnapshot = typeof BackgroundPolicySnapshot.Type; |
packages/contracts/src/baseSchemas.ts+2| ... | ... | @@ -43,6 +43,8 @@ export const TurnId = makeEntityId("TurnId"); |
| 43 | 43 | export type TurnId = typeof TurnId.Type; |
| 44 | 44 | export const AuthSessionId = makeEntityId("AuthSessionId"); |
| 45 | 45 | export type AuthSessionId = typeof AuthSessionId.Type; |
| 46 | export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); | |
| 47 | export type RpcClientId = typeof RpcClientId.Type; | |
| 46 | 48 | |
| 47 | 49 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 48 | 50 | export type ProviderItemId = typeof ProviderItemId.Type; |
packages/contracts/src/desktopBootstrap.ts+4-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | |
| 3 | import { PortSchema } from "./baseSchemas.ts"; | |
| 3 | import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | 4 | |
| 5 | 5 | export const DesktopBackendBootstrap = Schema.Struct({ |
| 6 | 6 | mode: Schema.Literal("desktop"), |
| ... | ... | @@ -16,6 +16,9 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 16 | 16 | tailscaleServePort: PortSchema, |
| 17 | 17 | otlpTracesUrl: Schema.optional(Schema.String), |
| 18 | 18 | otlpMetricsUrl: Schema.optional(Schema.String), |
| 19 | desktopTelemetryFd: Schema.optionalKey(PositiveInt), | |
| 20 | desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), | |
| 21 | resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), | |
| 19 | 22 | }); |
| 20 | 23 | |
| 21 | 24 | export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; |
packages/contracts/src/index.ts+2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export * from "./baseSchemas.ts"; |
| 2 | export * from "./background.ts"; | |
| 2 | 3 | export * from "./auth.ts"; |
| 3 | 4 | export * from "./environment.ts"; |
| 4 | 5 | export * from "./environmentHttp.ts"; |
| ... | ... | @@ -26,4 +27,5 @@ export * from "./assets.ts"; |
| 26 | 27 | export * from "./review.ts"; |
| 27 | 28 | export * from "./preview.ts"; |
| 28 | 29 | export * from "./previewAutomation.ts"; |
| 30 | export * from "./resourceTelemetry.ts"; | |
| 29 | 31 | export * from "./rpc.ts"; |
packages/contracts/src/ipc.ts+2-43| ... | ... | @@ -31,20 +31,6 @@ import type { |
| 31 | 31 | ProjectWriteFileInput, |
| 32 | 32 | ProjectWriteFileResult, |
| 33 | 33 | } from "./project.ts"; |
| 34 | import type { ProviderInstanceId } from "./providerInstance.ts"; | |
| 35 | import type { | |
| 36 | ServerConfig, | |
| 37 | ServerProcessDiagnosticsResult, | |
| 38 | ServerProcessResourceHistoryInput, | |
| 39 | ServerProcessResourceHistoryResult, | |
| 40 | ServerProviderUpdateInput, | |
| 41 | ServerProviderUpdatedPayload, | |
| 42 | ServerRemoveKeybindingResult, | |
| 43 | ServerSignalProcessInput, | |
| 44 | ServerSignalProcessResult, | |
| 45 | ServerTraceDiagnosticsResult, | |
| 46 | ServerUpsertKeybindingResult, | |
| 47 | } from "./server.ts"; | |
| 48 | 34 | import type { |
| 49 | 35 | TerminalAttachInput, |
| 50 | 36 | TerminalAttachStreamEvent, |
| ... | ... | @@ -57,7 +43,6 @@ import type { |
| 57 | 43 | TerminalSessionSnapshot, |
| 58 | 44 | TerminalWriteInput, |
| 59 | 45 | } from "./terminal.ts"; |
| 60 | import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; | |
| 61 | 46 | import * as Schema from "effect/Schema"; |
| 62 | 47 | import type { |
| 63 | 48 | DiscoveredLocalServerList, |
| ... | ... | @@ -100,13 +85,11 @@ import type { |
| 100 | 85 | import { EnvironmentId } from "./baseSchemas.ts"; |
| 101 | 86 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 102 | 87 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 103 | import { EditorId } from "./editor.ts"; | |
| 104 | 88 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 105 | import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; | |
| 89 | import type { ClientSettings } from "./settings.ts"; | |
| 106 | 90 | import type { |
| 107 | 91 | SourceControlCloneRepositoryInput, |
| 108 | 92 | SourceControlCloneRepositoryResult, |
| 109 | SourceControlDiscoveryResult, | |
| 110 | 93 | SourceControlPublishRepositoryInput, |
| 111 | 94 | SourceControlPublishRepositoryResult, |
| 112 | 95 | SourceControlRepositoryInfo, |
| ... | ... | @@ -1115,7 +1098,7 @@ export interface DesktopPreviewBridge { |
| 1115 | 1098 | * APIs bound to the local app shell, not to any particular backend environment. |
| 1116 | 1099 | * |
| 1117 | 1100 | * These capabilities describe the desktop/browser host that the user is |
| 1118 | * currently running: dialogs, editor/external-link opening, context menus, and | |
| 1101 | * currently running: dialogs, external-link opening, context menus, and | |
| 1119 | 1102 | * app-level settings/config access. They must not be used as a proxy for |
| 1120 | 1103 | * "whatever environment the user is targeting", because in a multi-environment |
| 1121 | 1104 | * world the local shell and a selected backend environment are distinct |
| ... | ... | @@ -1127,7 +1110,6 @@ export interface LocalApi { |
| 1127 | 1110 | confirm: (message: string) => Promise<boolean>; |
| 1128 | 1111 | }; |
| 1129 | 1112 | shell: { |
| 1130 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | |
| 1131 | 1113 | openExternal: (url: string) => Promise<void>; |
| 1132 | 1114 | }; |
| 1133 | 1115 | contextMenu: { |
| ... | ... | @@ -1140,29 +1122,6 @@ export interface LocalApi { |
| 1140 | 1122 | getClientSettings: () => Promise<ClientSettings | null>; |
| 1141 | 1123 | setClientSettings: (settings: ClientSettings) => Promise<void>; |
| 1142 | 1124 | }; |
| 1143 | server: { | |
| 1144 | getConfig: () => Promise<ServerConfig>; | |
| 1145 | /** | |
| 1146 | * Refresh provider snapshots. When `input.instanceId` is supplied only that | |
| 1147 | * configured instance is probed; otherwise every configured instance is | |
| 1148 | * refreshed (legacy untargeted refresh). | |
| 1149 | */ | |
| 1150 | refreshProviders: (input?: { | |
| 1151 | readonly instanceId?: ProviderInstanceId; | |
| 1152 | }) => Promise<ServerProviderUpdatedPayload>; | |
| 1153 | updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>; | |
| 1154 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | |
| 1155 | removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise<ServerRemoveKeybindingResult>; | |
| 1156 | getSettings: () => Promise<ServerSettings>; | |
| 1157 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | |
| 1158 | discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; | |
| 1159 | getTraceDiagnostics: () => Promise<ServerTraceDiagnosticsResult>; | |
| 1160 | getProcessDiagnostics: () => Promise<ServerProcessDiagnosticsResult>; | |
| 1161 | getProcessResourceHistory: ( | |
| 1162 | input: ServerProcessResourceHistoryInput, | |
| 1163 | ) => Promise<ServerProcessResourceHistoryResult>; | |
| 1164 | signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>; | |
| 1165 | }; | |
| 1166 | 1125 | } |
| 1167 | 1126 | |
| 1168 | 1127 | /** |
packages/contracts/src/resourceTelemetry.ts created+430| ... | ... | @@ -0,0 +1,430 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | import { HostPowerSnapshot } from "./background.ts"; | |
| 5 | ||
| 6 | export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; | |
| 7 | ||
| 8 | export const ResourceTelemetryIoSemantics = Schema.Literals([ | |
| 9 | "storage", | |
| 10 | "logical", | |
| 11 | "all-io", | |
| 12 | "unavailable", | |
| 13 | ]); | |
| 14 | export type ResourceTelemetryIoSemantics = typeof ResourceTelemetryIoSemantics.Type; | |
| 15 | ||
| 16 | export const ResourceTelemetryProcessCategory = Schema.Literals([ | |
| 17 | "server", | |
| 18 | "server-child", | |
| 19 | "provider-root", | |
| 20 | "terminal-root", | |
| 21 | "electron-main", | |
| 22 | "electron-renderer", | |
| 23 | "electron-gpu", | |
| 24 | "electron-utility", | |
| 25 | "resource-monitor", | |
| 26 | "unknown-t3", | |
| 27 | ]); | |
| 28 | export type ResourceTelemetryProcessCategory = typeof ResourceTelemetryProcessCategory.Type; | |
| 29 | ||
| 30 | export const ResourceTelemetrySourceStatus = Schema.Literals([ | |
| 31 | "starting", | |
| 32 | "healthy", | |
| 33 | "degraded", | |
| 34 | "unavailable", | |
| 35 | "stopped", | |
| 36 | ]); | |
| 37 | export type ResourceTelemetrySourceStatus = typeof ResourceTelemetrySourceStatus.Type; | |
| 38 | ||
| 39 | export const ResourceTelemetryProcessIdentity = Schema.Struct({ | |
| 40 | pid: PositiveInt, | |
| 41 | startTimeMs: NonNegativeInt, | |
| 42 | }); | |
| 43 | export type ResourceTelemetryProcessIdentity = typeof ResourceTelemetryProcessIdentity.Type; | |
| 44 | ||
| 45 | export const ResourceMonitorExternalProcess = Schema.Struct({ | |
| 46 | pid: PositiveInt, | |
| 47 | startTimeMs: Schema.optionalKey(NonNegativeInt), | |
| 48 | }); | |
| 49 | export type ResourceMonitorExternalProcess = typeof ResourceMonitorExternalProcess.Type; | |
| 50 | ||
| 51 | export const ResourceMonitorCapabilities = Schema.Struct({ | |
| 52 | cumulativeCpuTime: Schema.Boolean, | |
| 53 | currentCpuPercent: Schema.Boolean, | |
| 54 | residentMemory: Schema.Boolean, | |
| 55 | virtualMemory: Schema.Boolean, | |
| 56 | ioBytes: Schema.Boolean, | |
| 57 | processStartTime: Schema.Boolean, | |
| 58 | processTree: Schema.Boolean, | |
| 59 | }); | |
| 60 | export type ResourceMonitorCapabilities = typeof ResourceMonitorCapabilities.Type; | |
| 61 | ||
| 62 | export const ResourceMonitorProcessSample = Schema.Struct({ | |
| 63 | pid: PositiveInt, | |
| 64 | ppid: NonNegativeInt, | |
| 65 | startTimeMs: NonNegativeInt, | |
| 66 | runTimeMs: NonNegativeInt, | |
| 67 | name: Schema.String, | |
| 68 | command: Schema.String, | |
| 69 | status: Schema.String, | |
| 70 | cpuPercent: Schema.Number, | |
| 71 | cpuTimeMs: NonNegativeInt, | |
| 72 | residentBytes: NonNegativeInt, | |
| 73 | virtualBytes: NonNegativeInt, | |
| 74 | ioReadBytes: NonNegativeInt, | |
| 75 | ioWriteBytes: NonNegativeInt, | |
| 76 | ioSemantics: Schema.Literals(["storage", "all-io"]), | |
| 77 | }); | |
| 78 | export type ResourceMonitorProcessSample = typeof ResourceMonitorProcessSample.Type; | |
| 79 | ||
| 80 | export const ResourceMonitorConfigureCommand = Schema.Struct({ | |
| 81 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 82 | type: Schema.Literal("configure"), | |
| 83 | rootPid: PositiveInt, | |
| 84 | sampleIntervalMs: NonNegativeInt, | |
| 85 | externalProcesses: Schema.Array(ResourceMonitorExternalProcess), | |
| 86 | }); | |
| 87 | export type ResourceMonitorConfigureCommand = typeof ResourceMonitorConfigureCommand.Type; | |
| 88 | ||
| 89 | export const ResourceMonitorSetExternalProcessesCommand = Schema.Struct({ | |
| 90 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 91 | type: Schema.Literal("setExternalProcesses"), | |
| 92 | processes: Schema.Array(ResourceMonitorExternalProcess), | |
| 93 | }); | |
| 94 | export type ResourceMonitorSetExternalProcessesCommand = | |
| 95 | typeof ResourceMonitorSetExternalProcessesCommand.Type; | |
| 96 | ||
| 97 | export const ResourceMonitorSampleNowCommand = Schema.Struct({ | |
| 98 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 99 | type: Schema.Literal("sampleNow"), | |
| 100 | requestId: TrimmedNonEmptyString, | |
| 101 | }); | |
| 102 | export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type; | |
| 103 | ||
| 104 | export const ResourceMonitorSetSampleIntervalCommand = Schema.Struct({ | |
| 105 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 106 | type: Schema.Literal("setSampleInterval"), | |
| 107 | sampleIntervalMs: NonNegativeInt, | |
| 108 | }); | |
| 109 | export type ResourceMonitorSetSampleIntervalCommand = | |
| 110 | typeof ResourceMonitorSetSampleIntervalCommand.Type; | |
| 111 | ||
| 112 | export const ResourceMonitorSetStreamingCommand = Schema.Struct({ | |
| 113 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 114 | type: Schema.Literal("setStreaming"), | |
| 115 | enabled: Schema.Boolean, | |
| 116 | }); | |
| 117 | export type ResourceMonitorSetStreamingCommand = typeof ResourceMonitorSetStreamingCommand.Type; | |
| 118 | ||
| 119 | export const ResourceMonitorReadHistoryCommand = Schema.Struct({ | |
| 120 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 121 | type: Schema.Literal("readHistory"), | |
| 122 | requestId: TrimmedNonEmptyString, | |
| 123 | windowMs: NonNegativeInt, | |
| 124 | }); | |
| 125 | export type ResourceMonitorReadHistoryCommand = typeof ResourceMonitorReadHistoryCommand.Type; | |
| 126 | ||
| 127 | export const ResourceMonitorShutdownCommand = Schema.Struct({ | |
| 128 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 129 | type: Schema.Literal("shutdown"), | |
| 130 | }); | |
| 131 | export type ResourceMonitorShutdownCommand = typeof ResourceMonitorShutdownCommand.Type; | |
| 132 | ||
| 133 | export const ResourceMonitorCommand = Schema.Union([ | |
| 134 | ResourceMonitorConfigureCommand, | |
| 135 | ResourceMonitorSetExternalProcessesCommand, | |
| 136 | ResourceMonitorSetSampleIntervalCommand, | |
| 137 | ResourceMonitorSetStreamingCommand, | |
| 138 | ResourceMonitorSampleNowCommand, | |
| 139 | ResourceMonitorReadHistoryCommand, | |
| 140 | ResourceMonitorShutdownCommand, | |
| 141 | ]); | |
| 142 | export type ResourceMonitorCommand = typeof ResourceMonitorCommand.Type; | |
| 143 | ||
| 144 | export const ResourceMonitorHelloEvent = Schema.Struct({ | |
| 145 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 146 | type: Schema.Literal("hello"), | |
| 147 | sidecarVersion: TrimmedNonEmptyString, | |
| 148 | sidecarPid: PositiveInt, | |
| 149 | platform: TrimmedNonEmptyString, | |
| 150 | arch: TrimmedNonEmptyString, | |
| 151 | capabilities: ResourceMonitorCapabilities, | |
| 152 | }); | |
| 153 | export type ResourceMonitorHelloEvent = typeof ResourceMonitorHelloEvent.Type; | |
| 154 | ||
| 155 | export const ResourceMonitorSnapshotEvent = Schema.Struct({ | |
| 156 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 157 | type: Schema.Literal("snapshot"), | |
| 158 | sequence: NonNegativeInt, | |
| 159 | sampledAtUnixMs: NonNegativeInt, | |
| 160 | collectionDurationMicros: NonNegativeInt, | |
| 161 | scannedProcessCount: NonNegativeInt, | |
| 162 | retainedProcessCount: NonNegativeInt, | |
| 163 | inaccessibleProcessCount: NonNegativeInt, | |
| 164 | requestId: Schema.optionalKey(TrimmedNonEmptyString), | |
| 165 | externalProcesses: Schema.optionalKey(Schema.Array(ResourceMonitorExternalProcess)), | |
| 166 | processes: Schema.Array(ResourceMonitorProcessSample), | |
| 167 | }); | |
| 168 | export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type; | |
| 169 | ||
| 170 | export const ResourceMonitorHistoryChunkEvent = Schema.Struct({ | |
| 171 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 172 | type: Schema.Literal("historyChunk"), | |
| 173 | requestId: TrimmedNonEmptyString, | |
| 174 | done: Schema.Boolean, | |
| 175 | snapshots: Schema.Array(ResourceMonitorSnapshotEvent), | |
| 176 | }); | |
| 177 | export type ResourceMonitorHistoryChunkEvent = typeof ResourceMonitorHistoryChunkEvent.Type; | |
| 178 | ||
| 179 | export const ResourceMonitorErrorEvent = Schema.Struct({ | |
| 180 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | |
| 181 | type: Schema.Literal("error"), | |
| 182 | code: TrimmedNonEmptyString, | |
| 183 | message: TrimmedNonEmptyString, | |
| 184 | recoverable: Schema.Boolean, | |
| 185 | }); | |
| 186 | export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type; | |
| 187 | ||
| 188 | export const ResourceMonitorEvent = Schema.Union([ | |
| 189 | ResourceMonitorHelloEvent, | |
| 190 | ResourceMonitorSnapshotEvent, | |
| 191 | ResourceMonitorHistoryChunkEvent, | |
| 192 | ResourceMonitorErrorEvent, | |
| 193 | ]); | |
| 194 | export type ResourceMonitorEvent = typeof ResourceMonitorEvent.Type; | |
| 195 | ||
| 196 | export const DesktopElectronProcessType = Schema.Literals([ | |
| 197 | "Browser", | |
| 198 | "Tab", | |
| 199 | "Utility", | |
| 200 | "Zygote", | |
| 201 | "Sandbox helper", | |
| 202 | "GPU", | |
| 203 | "Pepper Plugin", | |
| 204 | "Pepper Plugin Broker", | |
| 205 | "Unknown", | |
| 206 | ]); | |
| 207 | export type DesktopElectronProcessType = typeof DesktopElectronProcessType.Type; | |
| 208 | ||
| 209 | export const DesktopElectronProcessMetric = Schema.Struct({ | |
| 210 | pid: PositiveInt, | |
| 211 | creationTimeMs: NonNegativeInt, | |
| 212 | type: DesktopElectronProcessType, | |
| 213 | name: Schema.optionalKey(Schema.String), | |
| 214 | serviceName: Schema.optionalKey(Schema.String), | |
| 215 | cpuPercent: Schema.Number, | |
| 216 | cumulativeCpuSeconds: Schema.optionalKey(Schema.Number), | |
| 217 | idleWakeupsPerSecond: Schema.Number, | |
| 218 | workingSetBytes: NonNegativeInt, | |
| 219 | peakWorkingSetBytes: NonNegativeInt, | |
| 220 | }); | |
| 221 | export type DesktopElectronProcessMetric = typeof DesktopElectronProcessMetric.Type; | |
| 222 | ||
| 223 | const DesktopHostPowerSnapshot = Schema.Struct({ | |
| 224 | ...HostPowerSnapshot.fields, | |
| 225 | updatedAt: Schema.DateTimeUtcFromString, | |
| 226 | }); | |
| 227 | ||
| 228 | export const DesktopHostTelemetrySnapshot = Schema.Struct({ | |
| 229 | version: Schema.Literal(1), | |
| 230 | type: Schema.Literal("desktopTelemetry"), | |
| 231 | sequence: NonNegativeInt, | |
| 232 | sampledAtUnixMs: NonNegativeInt, | |
| 233 | electronPid: PositiveInt, | |
| 234 | power: DesktopHostPowerSnapshot, | |
| 235 | speedLimitPercent: Schema.OptionFromNullOr(Schema.Number), | |
| 236 | electronProcesses: Schema.Array(DesktopElectronProcessMetric), | |
| 237 | }); | |
| 238 | export type DesktopHostTelemetrySnapshot = typeof DesktopHostTelemetrySnapshot.Type; | |
| 239 | ||
| 240 | export const DesktopHostTelemetryHello = Schema.Struct({ | |
| 241 | version: Schema.Literal(1), | |
| 242 | type: Schema.Literal("desktopTelemetryHello"), | |
| 243 | electronPid: PositiveInt, | |
| 244 | }); | |
| 245 | export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type; | |
| 246 | ||
| 247 | export const DesktopHostTelemetryMessage = Schema.Union([ | |
| 248 | DesktopHostTelemetryHello, | |
| 249 | DesktopHostTelemetrySnapshot, | |
| 250 | ]); | |
| 251 | export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type; | |
| 252 | ||
| 253 | export const DesktopTelemetrySetDiagnosticsDemand = Schema.Struct({ | |
| 254 | version: Schema.Literal(1), | |
| 255 | type: Schema.Literal("setDiagnosticsDemand"), | |
| 256 | enabled: Schema.Boolean, | |
| 257 | }); | |
| 258 | export type DesktopTelemetrySetDiagnosticsDemand = typeof DesktopTelemetrySetDiagnosticsDemand.Type; | |
| 259 | ||
| 260 | export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({ | |
| 261 | version: Schema.Literal(1), | |
| 262 | type: Schema.Literal("setHostPowerIntervals"), | |
| 263 | activeIntervalMs: PositiveInt, | |
| 264 | idleIntervalMs: PositiveInt, | |
| 265 | }); | |
| 266 | export type DesktopTelemetrySetHostPowerIntervals = | |
| 267 | typeof DesktopTelemetrySetHostPowerIntervals.Type; | |
| 268 | ||
| 269 | export const DesktopTelemetryControlMessage = Schema.Union([ | |
| 270 | DesktopTelemetrySetDiagnosticsDemand, | |
| 271 | DesktopTelemetrySetHostPowerIntervals, | |
| 272 | ]); | |
| 273 | export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type; | |
| 274 | ||
| 275 | export const ResourceTelemetryProcess = Schema.Struct({ | |
| 276 | identity: ResourceTelemetryProcessIdentity, | |
| 277 | ppid: NonNegativeInt, | |
| 278 | childPids: Schema.Array(PositiveInt), | |
| 279 | depth: NonNegativeInt, | |
| 280 | name: Schema.String, | |
| 281 | command: Schema.String, | |
| 282 | status: Schema.String, | |
| 283 | category: ResourceTelemetryProcessCategory, | |
| 284 | electronType: Schema.optionalKey(DesktopElectronProcessType), | |
| 285 | electronServiceName: Schema.optionalKey(Schema.String), | |
| 286 | cpuPercent: Schema.Number, | |
| 287 | cpuTimeMs: NonNegativeInt, | |
| 288 | residentBytes: NonNegativeInt, | |
| 289 | peakResidentBytes: NonNegativeInt, | |
| 290 | virtualBytes: NonNegativeInt, | |
| 291 | ioReadBytes: NonNegativeInt, | |
| 292 | ioWriteBytes: NonNegativeInt, | |
| 293 | ioReadBytesPerSecond: Schema.Number, | |
| 294 | ioWriteBytesPerSecond: Schema.Number, | |
| 295 | ioSemantics: ResourceTelemetryIoSemantics, | |
| 296 | idleWakeupsPerSecond: Schema.optionalKey(Schema.Number), | |
| 297 | runTimeMs: NonNegativeInt, | |
| 298 | firstSeenAt: Schema.DateTimeUtc, | |
| 299 | lastSeenAt: Schema.DateTimeUtc, | |
| 300 | }); | |
| 301 | export type ResourceTelemetryProcess = typeof ResourceTelemetryProcess.Type; | |
| 302 | ||
| 303 | export const ResourceTelemetryAggregate = Schema.Struct({ | |
| 304 | processCount: NonNegativeInt, | |
| 305 | currentCpuPercent: Schema.Number, | |
| 306 | cpuTimeMs: NonNegativeInt, | |
| 307 | currentRssBytes: NonNegativeInt, | |
| 308 | peakRssBytes: NonNegativeInt, | |
| 309 | ioReadBytes: NonNegativeInt, | |
| 310 | ioWriteBytes: NonNegativeInt, | |
| 311 | ioReadBytesPerSecond: Schema.Number, | |
| 312 | ioWriteBytesPerSecond: Schema.Number, | |
| 313 | processStarts: NonNegativeInt, | |
| 314 | processExits: NonNegativeInt, | |
| 315 | }); | |
| 316 | export type ResourceTelemetryAggregate = typeof ResourceTelemetryAggregate.Type; | |
| 317 | ||
| 318 | export const ResourceTelemetryGroups = Schema.Struct({ | |
| 319 | backend: ResourceTelemetryAggregate, | |
| 320 | electron: ResourceTelemetryAggregate, | |
| 321 | monitor: ResourceTelemetryAggregate, | |
| 322 | allT3: ResourceTelemetryAggregate, | |
| 323 | }); | |
| 324 | export type ResourceTelemetryGroups = typeof ResourceTelemetryGroups.Type; | |
| 325 | ||
| 326 | export const ResourceTelemetrySourceHealth = Schema.Struct({ | |
| 327 | status: ResourceTelemetrySourceStatus, | |
| 328 | lastSampleAt: Schema.Option(Schema.DateTimeUtc), | |
| 329 | lastError: Schema.Option(TrimmedNonEmptyString), | |
| 330 | }); | |
| 331 | export type ResourceTelemetrySourceHealth = typeof ResourceTelemetrySourceHealth.Type; | |
| 332 | ||
| 333 | export const ResourceTelemetryHealth = Schema.Struct({ | |
| 334 | native: ResourceTelemetrySourceHealth, | |
| 335 | desktop: ResourceTelemetrySourceHealth, | |
| 336 | sidecarVersion: Schema.Option(TrimmedNonEmptyString), | |
| 337 | sidecarPid: Schema.Option(PositiveInt), | |
| 338 | restartCount: NonNegativeInt, | |
| 339 | collectionDurationMicros: NonNegativeInt, | |
| 340 | scannedProcessCount: NonNegativeInt, | |
| 341 | retainedProcessCount: NonNegativeInt, | |
| 342 | inaccessibleProcessCount: NonNegativeInt, | |
| 343 | }); | |
| 344 | export type ResourceTelemetryHealth = typeof ResourceTelemetryHealth.Type; | |
| 345 | ||
| 346 | export const ResourceAttributionEntry = Schema.Struct({ | |
| 347 | component: TrimmedNonEmptyString, | |
| 348 | operation: TrimmedNonEmptyString, | |
| 349 | logicalReadBytes: NonNegativeInt, | |
| 350 | logicalWriteBytes: NonNegativeInt, | |
| 351 | count: NonNegativeInt, | |
| 352 | durationMs: NonNegativeInt, | |
| 353 | }); | |
| 354 | export type ResourceAttributionEntry = typeof ResourceAttributionEntry.Type; | |
| 355 | ||
| 356 | export const ResourceAttributionSnapshot = Schema.Struct({ | |
| 357 | readAt: Schema.DateTimeUtc, | |
| 358 | entries: Schema.Array(ResourceAttributionEntry), | |
| 359 | }); | |
| 360 | export type ResourceAttributionSnapshot = typeof ResourceAttributionSnapshot.Type; | |
| 361 | ||
| 362 | export const ResourceTelemetrySnapshot = Schema.Struct({ | |
| 363 | readAt: Schema.DateTimeUtc, | |
| 364 | sampleIntervalMs: NonNegativeInt, | |
| 365 | processes: Schema.Array(ResourceTelemetryProcess), | |
| 366 | groups: ResourceTelemetryGroups, | |
| 367 | power: HostPowerSnapshot, | |
| 368 | speedLimitPercent: Schema.Option(Schema.Number), | |
| 369 | attribution: ResourceAttributionSnapshot, | |
| 370 | health: ResourceTelemetryHealth, | |
| 371 | }); | |
| 372 | export type ResourceTelemetrySnapshot = typeof ResourceTelemetrySnapshot.Type; | |
| 373 | ||
| 374 | export const ResourceTelemetryHistoryInput = Schema.Struct({ | |
| 375 | windowMs: NonNegativeInt, | |
| 376 | bucketMs: NonNegativeInt, | |
| 377 | }); | |
| 378 | export type ResourceTelemetryHistoryInput = typeof ResourceTelemetryHistoryInput.Type; | |
| 379 | ||
| 380 | export const ResourceTelemetryHistoryBucket = Schema.Struct({ | |
| 381 | startedAt: Schema.DateTimeUtc, | |
| 382 | endedAt: Schema.DateTimeUtc, | |
| 383 | avgCpuPercent: Schema.Number, | |
| 384 | maxCpuPercent: Schema.Number, | |
| 385 | maxRssBytes: NonNegativeInt, | |
| 386 | ioReadBytes: NonNegativeInt, | |
| 387 | ioWriteBytes: NonNegativeInt, | |
| 388 | maxProcessCount: NonNegativeInt, | |
| 389 | }); | |
| 390 | export type ResourceTelemetryHistoryBucket = typeof ResourceTelemetryHistoryBucket.Type; | |
| 391 | ||
| 392 | export const ResourceTelemetryProcessSummary = Schema.Struct({ | |
| 393 | identity: ResourceTelemetryProcessIdentity, | |
| 394 | ppid: NonNegativeInt, | |
| 395 | depth: NonNegativeInt, | |
| 396 | name: Schema.String, | |
| 397 | command: Schema.String, | |
| 398 | category: ResourceTelemetryProcessCategory, | |
| 399 | firstSeenAt: Schema.DateTimeUtc, | |
| 400 | lastSeenAt: Schema.DateTimeUtc, | |
| 401 | currentCpuPercent: Schema.Number, | |
| 402 | avgCpuPercent: Schema.Number, | |
| 403 | maxCpuPercent: Schema.Number, | |
| 404 | cpuTimeMs: NonNegativeInt, | |
| 405 | currentRssBytes: NonNegativeInt, | |
| 406 | peakRssBytes: NonNegativeInt, | |
| 407 | ioReadBytes: NonNegativeInt, | |
| 408 | ioWriteBytes: NonNegativeInt, | |
| 409 | ioSemantics: ResourceTelemetryIoSemantics, | |
| 410 | sampleCount: NonNegativeInt, | |
| 411 | }); | |
| 412 | export type ResourceTelemetryProcessSummary = typeof ResourceTelemetryProcessSummary.Type; | |
| 413 | ||
| 414 | export const ResourceTelemetryHistory = Schema.Struct({ | |
| 415 | readAt: Schema.DateTimeUtc, | |
| 416 | windowMs: NonNegativeInt, | |
| 417 | bucketMs: NonNegativeInt, | |
| 418 | sampleIntervalMs: NonNegativeInt, | |
| 419 | retainedSampleCount: NonNegativeInt, | |
| 420 | buckets: Schema.Array(ResourceTelemetryHistoryBucket), | |
| 421 | topProcesses: Schema.Array(ResourceTelemetryProcessSummary), | |
| 422 | health: ResourceTelemetryHealth, | |
| 423 | }); | |
| 424 | export type ResourceTelemetryHistory = typeof ResourceTelemetryHistory.Type; | |
| 425 | ||
| 426 | export const ResourceTelemetryRetryResult = Schema.Struct({ | |
| 427 | accepted: Schema.Boolean, | |
| 428 | snapshot: ResourceTelemetrySnapshot, | |
| 429 | }); | |
| 430 | export type ResourceTelemetryRetryResult = typeof ResourceTelemetryRetryResult.Type; |
packages/contracts/src/rpc.ts+70| ... | ... | @@ -8,6 +8,11 @@ import { |
| 8 | 8 | AuthAccessStreamEvent, |
| 9 | 9 | EnvironmentAuthorizationError, |
| 10 | 10 | } from "./auth.ts"; |
| 11 | import { | |
| 12 | BackgroundPolicySnapshot, | |
| 13 | ClientActivityReportInput, | |
| 14 | HostPowerSnapshot, | |
| 15 | } from "./background.ts"; | |
| 11 | 16 | import { |
| 12 | 17 | FilesystemBrowseInput, |
| 13 | 18 | FilesystemBrowseResult, |
| ... | ... | @@ -132,6 +137,12 @@ import { |
| 132 | 137 | ServerUpsertKeybindingInput, |
| 133 | 138 | ServerUpsertKeybindingResult, |
| 134 | 139 | } from "./server.ts"; |
| 140 | import { | |
| 141 | ResourceTelemetryHistory, | |
| 142 | ResourceTelemetryHistoryInput, | |
| 143 | ResourceTelemetryRetryResult, | |
| 144 | ResourceTelemetrySnapshot, | |
| 145 | } from "./resourceTelemetry.ts"; | |
| 135 | 146 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 136 | 147 | import { |
| 137 | 148 | SourceControlCloneRepositoryInput, |
| ... | ... | @@ -215,7 +226,12 @@ export const WS_METHODS = { |
| 215 | 226 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", |
| 216 | 227 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", |
| 217 | 228 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 229 | serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", | |
| 230 | serverRetryResourceTelemetry: "server.retryResourceTelemetry", | |
| 218 | 231 | serverSignalProcess: "server.signalProcess", |
| 232 | serverReportClientActivity: "server.reportClientActivity", | |
| 233 | serverReportHostPowerState: "server.reportHostPowerState", | |
| 234 | serverGetBackgroundPolicy: "server.getBackgroundPolicy", | |
| 219 | 235 | |
| 220 | 236 | // Cloud environment methods |
| 221 | 237 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", |
| ... | ... | @@ -235,6 +251,8 @@ export const WS_METHODS = { |
| 235 | 251 | subscribeServerConfig: "subscribeServerConfig", |
| 236 | 252 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 237 | 253 | subscribeAuthAccess: "subscribeAuthAccess", |
| 254 | subscribeBackgroundPolicy: "subscribeBackgroundPolicy", | |
| 255 | subscribeResourceTelemetry: "subscribeResourceTelemetry", | |
| 238 | 256 | } as const; |
| 239 | 257 | |
| 240 | 258 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { |
| ... | ... | @@ -326,6 +344,21 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( |
| 326 | 344 | }, |
| 327 | 345 | ); |
| 328 | 346 | |
| 347 | export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( | |
| 348 | WS_METHODS.serverGetResourceTelemetryHistory, | |
| 349 | { | |
| 350 | payload: ResourceTelemetryHistoryInput, | |
| 351 | success: ResourceTelemetryHistory, | |
| 352 | error: EnvironmentAuthorizationError, | |
| 353 | }, | |
| 354 | ); | |
| 355 | ||
| 356 | export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { | |
| 357 | payload: Schema.Struct({}), | |
| 358 | success: ResourceTelemetryRetryResult, | |
| 359 | error: EnvironmentAuthorizationError, | |
| 360 | }); | |
| 361 | ||
| 329 | 362 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { |
| 330 | 363 | payload: ServerSignalProcessInput, |
| 331 | 364 | success: ServerSignalProcessResult, |
| ... | ... | @@ -345,6 +378,22 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela |
| 345 | 378 | stream: true, |
| 346 | 379 | }); |
| 347 | 380 | |
| 381 | export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { | |
| 382 | payload: ClientActivityReportInput, | |
| 383 | error: EnvironmentAuthorizationError, | |
| 384 | }); | |
| 385 | ||
| 386 | export const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { | |
| 387 | payload: HostPowerSnapshot, | |
| 388 | error: EnvironmentAuthorizationError, | |
| 389 | }); | |
| 390 | ||
| 391 | export const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { | |
| 392 | payload: Schema.Struct({}), | |
| 393 | success: BackgroundPolicySnapshot, | |
| 394 | error: EnvironmentAuthorizationError, | |
| 395 | }); | |
| 396 | ||
| 348 | 397 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( |
| 349 | 398 | WS_METHODS.sourceControlLookupRepository, |
| 350 | 399 | { |
| ... | ... | @@ -690,6 +739,20 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, |
| 690 | 739 | stream: true, |
| 691 | 740 | }); |
| 692 | 741 | |
| 742 | export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { | |
| 743 | payload: Schema.Struct({}), | |
| 744 | success: BackgroundPolicySnapshot, | |
| 745 | error: EnvironmentAuthorizationError, | |
| 746 | stream: true, | |
| 747 | }); | |
| 748 | ||
| 749 | export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { | |
| 750 | payload: Schema.Struct({}), | |
| 751 | success: ResourceTelemetrySnapshot, | |
| 752 | error: EnvironmentAuthorizationError, | |
| 753 | stream: true, | |
| 754 | }); | |
| 755 | ||
| 693 | 756 | export const WsRpcGroup = RpcGroup.make( |
| 694 | 757 | WsServerProbeRpc, |
| 695 | 758 | WsServerGetConfigRpc, |
| ... | ... | @@ -704,7 +767,12 @@ export const WsRpcGroup = RpcGroup.make( |
| 704 | 767 | WsServerGetTraceDiagnosticsRpc, |
| 705 | 768 | WsServerGetProcessDiagnosticsRpc, |
| 706 | 769 | WsServerGetProcessResourceHistoryRpc, |
| 770 | WsServerGetResourceTelemetryHistoryRpc, | |
| 771 | WsServerRetryResourceTelemetryRpc, | |
| 707 | 772 | WsServerSignalProcessRpc, |
| 773 | WsServerReportClientActivityRpc, | |
| 774 | WsServerReportHostPowerStateRpc, | |
| 775 | WsServerGetBackgroundPolicyRpc, | |
| 708 | 776 | WsCloudGetRelayClientStatusRpc, |
| 709 | 777 | WsCloudInstallRelayClientRpc, |
| 710 | 778 | WsSourceControlLookupRepositoryRpc, |
| ... | ... | @@ -754,6 +822,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 754 | 822 | WsSubscribeServerConfigRpc, |
| 755 | 823 | WsSubscribeServerLifecycleRpc, |
| 756 | 824 | WsSubscribeAuthAccessRpc, |
| 825 | WsSubscribeBackgroundPolicyRpc, | |
| 826 | WsSubscribeResourceTelemetryRpc, | |
| 757 | 827 | WsOrchestrationDispatchCommandRpc, |
| 758 | 828 | WsOrchestrationGetTurnDiffRpc, |
| 759 | 829 | WsOrchestrationGetFullThreadDiffRpc, |
packages/contracts/src/server.ts+2| ... | ... | @@ -303,6 +303,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type; |
| 303 | 303 | |
| 304 | 304 | export const ServerProcessDiagnosticsEntry = Schema.Struct({ |
| 305 | 305 | pid: PositiveInt, |
| 306 | startTimeMs: NonNegativeInt, | |
| 306 | 307 | ppid: NonNegativeInt, |
| 307 | 308 | pgid: Schema.Option(Schema.Int), |
| 308 | 309 | status: TrimmedNonEmptyString, |
| ... | ... | @@ -395,6 +396,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis |
| 395 | 396 | |
| 396 | 397 | export const ServerSignalProcessInput = Schema.Struct({ |
| 397 | 398 | pid: PositiveInt, |
| 399 | startTimeMs: NonNegativeInt, | |
| 398 | 400 | signal: ServerProcessSignal, |
| 399 | 401 | }); |
| 400 | 402 | export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type; |
packages/contracts/src/settings.ts+61| ... | ... | @@ -426,15 +426,66 @@ export const SourceControlWritingStyleSettings = Schema.Struct({ |
| 426 | 426 | export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type; |
| 427 | 427 | |
| 428 | 428 | export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); |
| 429 | export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5); | |
| 430 | ||
| 431 | export const BackgroundActivityProfile = Schema.Literals([ | |
| 432 | "balanced", | |
| 433 | "performance", | |
| 434 | "battery-saver", | |
| 435 | ]); | |
| 436 | export type BackgroundActivityProfile = typeof BackgroundActivityProfile.Type; | |
| 437 | export const DEFAULT_BACKGROUND_ACTIVITY_PROFILE: BackgroundActivityProfile = "balanced"; | |
| 438 | ||
| 439 | export const BackgroundActivityProfileSelection = Schema.Literals([ | |
| 440 | "balanced", | |
| 441 | "performance", | |
| 442 | "battery-saver", | |
| 443 | "custom", | |
| 444 | ]); | |
| 445 | export type BackgroundActivityProfileSelection = typeof BackgroundActivityProfileSelection.Type; | |
| 446 | ||
| 447 | export const BackgroundActivityOverrides = Schema.Struct({ | |
| 448 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), | |
| 449 | providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), | |
| 450 | hostPowerMonitorActiveInterval: Schema.optionalKey(Schema.DurationFromMillis), | |
| 451 | hostPowerMonitorIdleInterval: Schema.optionalKey(Schema.DurationFromMillis), | |
| 452 | idleClientTtl: Schema.optionalKey(Schema.DurationFromMillis), | |
| 453 | pauseWhenHostLocked: Schema.optionalKey(Schema.Boolean), | |
| 454 | pauseWhenHostLowPower: Schema.optionalKey(Schema.Boolean), | |
| 455 | pauseWhenClientLowPower: Schema.optionalKey(Schema.Boolean), | |
| 456 | pauseWhenOnBattery: Schema.optionalKey(Schema.Boolean), | |
| 457 | }); | |
| 458 | export type BackgroundActivityOverrides = typeof BackgroundActivityOverrides.Type; | |
| 459 | ||
| 460 | export const BackgroundActivitySettings = Schema.Struct({ | |
| 461 | schemaVersion: Schema.Literal(1).pipe(Schema.withDecodingDefault(Effect.succeed(1 as const))), | |
| 462 | profile: BackgroundActivityProfileSelection.pipe( | |
| 463 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), | |
| 464 | ), | |
| 465 | baseProfile: Schema.optionalKey(BackgroundActivityProfile), | |
| 466 | overrides: BackgroundActivityOverrides.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 467 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); | |
| 468 | export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; | |
| 429 | 469 | |
| 430 | 470 | export const ServerSettings = Schema.Struct({ |
| 431 | 471 | enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 432 | 472 | enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 473 | backgroundActivity: BackgroundActivitySettings, | |
| 474 | // Legacy flat fields retained for old settings files and old clients. New | |
| 475 | // consumers should resolve `backgroundActivity` instead. | |
| 433 | 476 | automaticGitFetchInterval: Schema.DurationFromMillis.pipe( |
| 434 | 477 | Schema.withDecodingDefault( |
| 435 | 478 | Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), |
| 436 | 479 | ), |
| 437 | 480 | ), |
| 481 | providerHealthRefreshInterval: Schema.DurationFromMillis.pipe( | |
| 482 | Schema.withDecodingDefault( | |
| 483 | Effect.succeed(Duration.toMillis(DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL)), | |
| 484 | ), | |
| 485 | ), | |
| 486 | backgroundActivityProfile: BackgroundActivityProfile.pipe( | |
| 487 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), | |
| 488 | ), | |
| 438 | 489 | defaultThreadEnvMode: ThreadEnvMode.pipe( |
| 439 | 490 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), |
| 440 | 491 | ), |
| ... | ... | @@ -576,7 +627,17 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 576 | 627 | // Server settings |
| 577 | 628 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| 578 | 629 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), |
| 630 | backgroundActivity: Schema.optionalKey( | |
| 631 | Schema.Struct({ | |
| 632 | schemaVersion: Schema.optionalKey(Schema.Literal(1)), | |
| 633 | profile: Schema.optionalKey(BackgroundActivityProfileSelection), | |
| 634 | baseProfile: Schema.optionalKey(BackgroundActivityProfile), | |
| 635 | overrides: Schema.optionalKey(BackgroundActivityOverrides), | |
| 636 | }), | |
| 637 | ), | |
| 579 | 638 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 639 | providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), | |
| 640 | backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), | |
| 580 | 641 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 581 | 642 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 582 | 643 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), |
packages/shared/package.json+4| ... | ... | @@ -79,6 +79,10 @@ |
| 79 | 79 | "types": "./src/serverSettings.ts", |
| 80 | 80 | "import": "./src/serverSettings.ts" |
| 81 | 81 | }, |
| 82 | "./backgroundActivitySettings": { | |
| 83 | "types": "./src/backgroundActivitySettings.ts", | |
| 84 | "import": "./src/backgroundActivitySettings.ts" | |
| 85 | }, | |
| 82 | 86 | "./String": { |
| 83 | 87 | "types": "./src/String.ts", |
| 84 | 88 | "import": "./src/String.ts" |
packages/shared/src/backgroundActivitySettings.ts created+270| ... | ... | @@ -0,0 +1,270 @@ |
| 1 | import { | |
| 2 | type BackgroundActivityProfile, | |
| 3 | type BackgroundActivitySettings, | |
| 4 | DEFAULT_BACKGROUND_ACTIVITY_PROFILE, | |
| 5 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, | |
| 6 | DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, | |
| 7 | type ServerSettings, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 10 | ||
| 11 | export interface ResolvedBackgroundActivitySettings { | |
| 12 | readonly profile: BackgroundActivityProfile; | |
| 13 | readonly automaticGitFetchInterval: Duration.Duration; | |
| 14 | readonly providerHealthRefreshInterval: Duration.Duration; | |
| 15 | readonly hostPowerMonitorActiveInterval: Duration.Duration; | |
| 16 | readonly hostPowerMonitorIdleInterval: Duration.Duration; | |
| 17 | readonly idleClientTtl: Duration.Duration; | |
| 18 | readonly pauseWhenHostLocked: boolean; | |
| 19 | readonly pauseWhenHostLowPower: boolean; | |
| 20 | readonly pauseWhenClientLowPower: boolean; | |
| 21 | readonly pauseWhenOnBattery: boolean; | |
| 22 | } | |
| 23 | ||
| 24 | const PRESET_SETTINGS: Record<BackgroundActivityProfile, ResolvedBackgroundActivitySettings> = { | |
| 25 | performance: { | |
| 26 | profile: "performance", | |
| 27 | automaticGitFetchInterval: Duration.seconds(15), | |
| 28 | providerHealthRefreshInterval: Duration.minutes(1), | |
| 29 | hostPowerMonitorActiveInterval: Duration.seconds(30), | |
| 30 | hostPowerMonitorIdleInterval: Duration.minutes(2), | |
| 31 | idleClientTtl: Duration.seconds(45), | |
| 32 | pauseWhenHostLocked: true, | |
| 33 | pauseWhenHostLowPower: false, | |
| 34 | pauseWhenClientLowPower: false, | |
| 35 | pauseWhenOnBattery: false, | |
| 36 | }, | |
| 37 | balanced: { | |
| 38 | profile: "balanced", | |
| 39 | automaticGitFetchInterval: DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, | |
| 40 | providerHealthRefreshInterval: DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, | |
| 41 | hostPowerMonitorActiveInterval: Duration.seconds(30), | |
| 42 | hostPowerMonitorIdleInterval: Duration.minutes(5), | |
| 43 | idleClientTtl: Duration.seconds(45), | |
| 44 | pauseWhenHostLocked: true, | |
| 45 | pauseWhenHostLowPower: true, | |
| 46 | pauseWhenClientLowPower: true, | |
| 47 | pauseWhenOnBattery: false, | |
| 48 | }, | |
| 49 | "battery-saver": { | |
| 50 | profile: "battery-saver", | |
| 51 | automaticGitFetchInterval: Duration.seconds(0), | |
| 52 | providerHealthRefreshInterval: Duration.minutes(15), | |
| 53 | hostPowerMonitorActiveInterval: Duration.minutes(1), | |
| 54 | hostPowerMonitorIdleInterval: Duration.minutes(10), | |
| 55 | idleClientTtl: Duration.seconds(45), | |
| 56 | pauseWhenHostLocked: true, | |
| 57 | pauseWhenHostLowPower: true, | |
| 58 | pauseWhenClientLowPower: true, | |
| 59 | pauseWhenOnBattery: true, | |
| 60 | }, | |
| 61 | }; | |
| 62 | ||
| 63 | export function getBackgroundActivityPresetSettings( | |
| 64 | profile: BackgroundActivityProfile, | |
| 65 | ): ResolvedBackgroundActivitySettings { | |
| 66 | return PRESET_SETTINGS[profile]; | |
| 67 | } | |
| 68 | ||
| 69 | export function getBackgroundActivityBaseProfile( | |
| 70 | backgroundActivity: BackgroundActivitySettings, | |
| 71 | ): BackgroundActivityProfile { | |
| 72 | if (backgroundActivity.profile === "custom") { | |
| 73 | return backgroundActivity.baseProfile ?? DEFAULT_BACKGROUND_ACTIVITY_PROFILE; | |
| 74 | } | |
| 75 | return backgroundActivity.profile; | |
| 76 | } | |
| 77 | ||
| 78 | export function resolveBackgroundActivitySettings( | |
| 79 | backgroundActivity: BackgroundActivitySettings, | |
| 80 | ): ResolvedBackgroundActivitySettings { | |
| 81 | const baseProfile = getBackgroundActivityBaseProfile(backgroundActivity); | |
| 82 | const preset = PRESET_SETTINGS[baseProfile]; | |
| 83 | const overrides = backgroundActivity.profile === "custom" ? backgroundActivity.overrides : {}; | |
| 84 | return { | |
| 85 | profile: baseProfile, | |
| 86 | automaticGitFetchInterval: | |
| 87 | overrides.automaticGitFetchInterval ?? preset.automaticGitFetchInterval, | |
| 88 | providerHealthRefreshInterval: | |
| 89 | overrides.providerHealthRefreshInterval ?? preset.providerHealthRefreshInterval, | |
| 90 | hostPowerMonitorActiveInterval: | |
| 91 | overrides.hostPowerMonitorActiveInterval ?? preset.hostPowerMonitorActiveInterval, | |
| 92 | hostPowerMonitorIdleInterval: | |
| 93 | overrides.hostPowerMonitorIdleInterval ?? preset.hostPowerMonitorIdleInterval, | |
| 94 | idleClientTtl: overrides.idleClientTtl ?? preset.idleClientTtl, | |
| 95 | pauseWhenHostLocked: overrides.pauseWhenHostLocked ?? preset.pauseWhenHostLocked, | |
| 96 | pauseWhenHostLowPower: overrides.pauseWhenHostLowPower ?? preset.pauseWhenHostLowPower, | |
| 97 | pauseWhenClientLowPower: overrides.pauseWhenClientLowPower ?? preset.pauseWhenClientLowPower, | |
| 98 | pauseWhenOnBattery: overrides.pauseWhenOnBattery ?? preset.pauseWhenOnBattery, | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | function durationsEqual(a: Duration.Duration, b: Duration.Duration): boolean { | |
| 103 | return Duration.toMillis(a) === Duration.toMillis(b); | |
| 104 | } | |
| 105 | ||
| 106 | function resolvedSettingsEqual( | |
| 107 | a: ResolvedBackgroundActivitySettings, | |
| 108 | b: ResolvedBackgroundActivitySettings, | |
| 109 | ): boolean { | |
| 110 | return ( | |
| 111 | durationsEqual(a.automaticGitFetchInterval, b.automaticGitFetchInterval) && | |
| 112 | durationsEqual(a.providerHealthRefreshInterval, b.providerHealthRefreshInterval) && | |
| 113 | durationsEqual(a.hostPowerMonitorActiveInterval, b.hostPowerMonitorActiveInterval) && | |
| 114 | durationsEqual(a.hostPowerMonitorIdleInterval, b.hostPowerMonitorIdleInterval) && | |
| 115 | durationsEqual(a.idleClientTtl, b.idleClientTtl) && | |
| 116 | a.pauseWhenHostLocked === b.pauseWhenHostLocked && | |
| 117 | a.pauseWhenHostLowPower === b.pauseWhenHostLowPower && | |
| 118 | a.pauseWhenClientLowPower === b.pauseWhenClientLowPower && | |
| 119 | a.pauseWhenOnBattery === b.pauseWhenOnBattery | |
| 120 | ); | |
| 121 | } | |
| 122 | ||
| 123 | export function normalizeBackgroundActivitySettings( | |
| 124 | backgroundActivity: BackgroundActivitySettings, | |
| 125 | ): BackgroundActivitySettings { | |
| 126 | if (backgroundActivity.profile !== "custom") { | |
| 127 | return { | |
| 128 | schemaVersion: 1, | |
| 129 | profile: backgroundActivity.profile, | |
| 130 | overrides: {}, | |
| 131 | }; | |
| 132 | } | |
| 133 | ||
| 134 | const resolved = resolveBackgroundActivitySettings(backgroundActivity); | |
| 135 | const profiles: ReadonlyArray<BackgroundActivityProfile> = [ | |
| 136 | getBackgroundActivityBaseProfile(backgroundActivity), | |
| 137 | "balanced", | |
| 138 | "performance", | |
| 139 | "battery-saver", | |
| 140 | ]; | |
| 141 | for (const profile of profiles) { | |
| 142 | if (resolvedSettingsEqual(resolved, PRESET_SETTINGS[profile])) { | |
| 143 | return { | |
| 144 | schemaVersion: 1, | |
| 145 | profile, | |
| 146 | overrides: {}, | |
| 147 | }; | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | const baseProfile = getBackgroundActivityBaseProfile(backgroundActivity); | |
| 152 | const preset = PRESET_SETTINGS[baseProfile]; | |
| 153 | const overrides: BackgroundActivitySettings["overrides"] = { | |
| 154 | ...(!durationsEqual(resolved.automaticGitFetchInterval, preset.automaticGitFetchInterval) | |
| 155 | ? { automaticGitFetchInterval: resolved.automaticGitFetchInterval } | |
| 156 | : {}), | |
| 157 | ...(!durationsEqual( | |
| 158 | resolved.providerHealthRefreshInterval, | |
| 159 | preset.providerHealthRefreshInterval, | |
| 160 | ) | |
| 161 | ? { providerHealthRefreshInterval: resolved.providerHealthRefreshInterval } | |
| 162 | : {}), | |
| 163 | ...(!durationsEqual( | |
| 164 | resolved.hostPowerMonitorActiveInterval, | |
| 165 | preset.hostPowerMonitorActiveInterval, | |
| 166 | ) | |
| 167 | ? { hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval } | |
| 168 | : {}), | |
| 169 | ...(!durationsEqual(resolved.hostPowerMonitorIdleInterval, preset.hostPowerMonitorIdleInterval) | |
| 170 | ? { hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval } | |
| 171 | : {}), | |
| 172 | ...(!durationsEqual(resolved.idleClientTtl, preset.idleClientTtl) | |
| 173 | ? { idleClientTtl: resolved.idleClientTtl } | |
| 174 | : {}), | |
| 175 | ...(resolved.pauseWhenHostLocked !== preset.pauseWhenHostLocked | |
| 176 | ? { pauseWhenHostLocked: resolved.pauseWhenHostLocked } | |
| 177 | : {}), | |
| 178 | ...(resolved.pauseWhenHostLowPower !== preset.pauseWhenHostLowPower | |
| 179 | ? { pauseWhenHostLowPower: resolved.pauseWhenHostLowPower } | |
| 180 | : {}), | |
| 181 | ...(resolved.pauseWhenClientLowPower !== preset.pauseWhenClientLowPower | |
| 182 | ? { pauseWhenClientLowPower: resolved.pauseWhenClientLowPower } | |
| 183 | : {}), | |
| 184 | ...(resolved.pauseWhenOnBattery !== preset.pauseWhenOnBattery | |
| 185 | ? { pauseWhenOnBattery: resolved.pauseWhenOnBattery } | |
| 186 | : {}), | |
| 187 | }; | |
| 188 | ||
| 189 | return { | |
| 190 | schemaVersion: 1, | |
| 191 | profile: "custom", | |
| 192 | baseProfile, | |
| 193 | overrides, | |
| 194 | }; | |
| 195 | } | |
| 196 | ||
| 197 | export function resolveServerBackgroundActivitySettings( | |
| 198 | settings: ServerSettings, | |
| 199 | ): ResolvedBackgroundActivitySettings { | |
| 200 | const defaultBackgroundActivity: BackgroundActivitySettings = { | |
| 201 | schemaVersion: 1, | |
| 202 | profile: DEFAULT_BACKGROUND_ACTIVITY_PROFILE, | |
| 203 | overrides: {}, | |
| 204 | }; | |
| 205 | const backgroundActivityIsDefault = | |
| 206 | settings.backgroundActivity.profile === defaultBackgroundActivity.profile && | |
| 207 | settings.backgroundActivity.baseProfile === undefined && | |
| 208 | Object.keys(settings.backgroundActivity.overrides).length === 0; | |
| 209 | const legacyProfile = settings.backgroundActivityProfile; | |
| 210 | const hasLegacyOverrides = | |
| 211 | legacyProfile !== DEFAULT_BACKGROUND_ACTIVITY_PROFILE || | |
| 212 | Duration.toMillis(settings.automaticGitFetchInterval) !== | |
| 213 | Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL) || | |
| 214 | Duration.toMillis(settings.providerHealthRefreshInterval) !== | |
| 215 | Duration.toMillis(DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL); | |
| 216 | if (backgroundActivityIsDefault && hasLegacyOverrides) { | |
| 217 | return resolveBackgroundActivitySettings({ | |
| 218 | schemaVersion: 1, | |
| 219 | profile: | |
| 220 | Duration.toMillis(settings.automaticGitFetchInterval) === | |
| 221 | Duration.toMillis( | |
| 222 | getBackgroundActivityPresetSettings(legacyProfile).automaticGitFetchInterval, | |
| 223 | ) && | |
| 224 | Duration.toMillis(settings.providerHealthRefreshInterval) === | |
| 225 | Duration.toMillis( | |
| 226 | getBackgroundActivityPresetSettings(legacyProfile).providerHealthRefreshInterval, | |
| 227 | ) | |
| 228 | ? legacyProfile | |
| 229 | : "custom", | |
| 230 | baseProfile: legacyProfile, | |
| 231 | overrides: { | |
| 232 | ...(Duration.toMillis(settings.automaticGitFetchInterval) !== | |
| 233 | Duration.toMillis( | |
| 234 | getBackgroundActivityPresetSettings(legacyProfile).automaticGitFetchInterval, | |
| 235 | ) | |
| 236 | ? { automaticGitFetchInterval: settings.automaticGitFetchInterval } | |
| 237 | : {}), | |
| 238 | ...(Duration.toMillis(settings.providerHealthRefreshInterval) !== | |
| 239 | Duration.toMillis( | |
| 240 | getBackgroundActivityPresetSettings(legacyProfile).providerHealthRefreshInterval, | |
| 241 | ) | |
| 242 | ? { providerHealthRefreshInterval: settings.providerHealthRefreshInterval } | |
| 243 | : {}), | |
| 244 | }, | |
| 245 | }); | |
| 246 | } | |
| 247 | return resolveBackgroundActivitySettings(settings.backgroundActivity); | |
| 248 | } | |
| 249 | ||
| 250 | export function normalizeServerBackgroundActivitySettings( | |
| 251 | settings: ServerSettings, | |
| 252 | ): BackgroundActivitySettings { | |
| 253 | const resolved = resolveServerBackgroundActivitySettings(settings); | |
| 254 | return normalizeBackgroundActivitySettings({ | |
| 255 | schemaVersion: 1, | |
| 256 | profile: "custom", | |
| 257 | baseProfile: resolved.profile, | |
| 258 | overrides: { | |
| 259 | automaticGitFetchInterval: resolved.automaticGitFetchInterval, | |
| 260 | providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, | |
| 261 | hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, | |
| 262 | hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, | |
| 263 | idleClientTtl: resolved.idleClientTtl, | |
| 264 | pauseWhenHostLocked: resolved.pauseWhenHostLocked, | |
| 265 | pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, | |
| 266 | pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, | |
| 267 | pauseWhenOnBattery: resolved.pauseWhenOnBattery, | |
| 268 | }, | |
| 269 | }); | |
| 270 | } |
packages/shared/src/logging.test.ts+19| ... | ... | @@ -127,6 +127,25 @@ describe("RotatingFileSink", () => { |
| 127 | 127 | expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); |
| 128 | 128 | }); |
| 129 | 129 | |
| 130 | it("never reports a rotation failure after successfully appending a chunk", () => { | |
| 131 | const directory = makeTempDirectory(); | |
| 132 | const filePath = NodePath.join(directory, "log.ndjson"); | |
| 133 | NodeFS.mkdirSync(`${filePath}.1`); | |
| 134 | const sink = new RotatingFileSink({ | |
| 135 | filePath, | |
| 136 | maxBytes: 1, | |
| 137 | maxFiles: 1, | |
| 138 | throwOnError: true, | |
| 139 | }); | |
| 140 | ||
| 141 | sink.write("oversized"); | |
| 142 | ||
| 143 | expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized"); | |
| 144 | const thrown = captureError(() => sink.write("next")); | |
| 145 | expect(thrown).toMatchObject({ operation: "rotate", filePath }); | |
| 146 | expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized"); | |
| 147 | }); | |
| 148 | ||
| 130 | 149 | it("preserves backup pruning failures", () => { |
| 131 | 150 | const directory = makeTempDirectory(); |
| 132 | 151 | const filePath = NodePath.join(directory, "log.ndjson"); |
packages/shared/src/logging.ts-4| ... | ... | @@ -93,10 +93,6 @@ export class RotatingFileSink { |
| 93 | 93 | |
| 94 | 94 | NodeFS.appendFileSync(this.filePath, buffer); |
| 95 | 95 | this.currentSize += buffer.length; |
| 96 | ||
| 97 | if (this.currentSize > this.maxBytes) { | |
| 98 | this.rotate(); | |
| 99 | } | |
| 100 | 96 | } catch (cause) { |
| 101 | 97 | if (isRotatingFileSinkError(cause)) { |
| 102 | 98 | throw cause; |
packages/shared/src/observability.test.ts+95-1| ... | ... | @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Logger from "effect/Logger"; |
| 9 | 9 | import * as Order from "effect/Order"; |
| 10 | 10 | import * as Path from "effect/Path"; |
| 11 | import * as Ref from "effect/Ref"; | |
| 11 | 12 | import * as References from "effect/References"; |
| 12 | 13 | import * as Schema from "effect/Schema"; |
| 13 | 14 | import * as Tracer from "effect/Tracer"; |
| ... | ... | @@ -19,6 +20,7 @@ import { |
| 19 | 20 | makeLocalFileTracer, |
| 20 | 21 | makeTraceSink, |
| 21 | 22 | type TraceRecord, |
| 23 | type TraceSinkFlushStats, | |
| 22 | 24 | } from "./observability.ts"; |
| 23 | 25 | |
| 24 | 26 | describe("errorTag", () => { |
| ... | ... | @@ -176,6 +178,34 @@ describe("observability", () => { |
| 176 | 178 | ), |
| 177 | 179 | ); |
| 178 | 180 | |
| 181 | it.effect("reports successful logical trace writes", () => | |
| 182 | Effect.scoped( | |
| 183 | Effect.gen(function* () { | |
| 184 | const fileSystem = yield* FileSystem.FileSystem; | |
| 185 | const path = yield* Path.Path; | |
| 186 | const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" }); | |
| 187 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 188 | const reported = yield* Ref.make<ReadonlyArray<TraceSinkFlushStats>>([]); | |
| 189 | ||
| 190 | const sink = yield* makeTraceSink({ | |
| 191 | filePath: tracePath, | |
| 192 | maxBytes: 1024, | |
| 193 | maxFiles: 2, | |
| 194 | batchWindowMs: 10_000, | |
| 195 | onFlush: (stats) => Ref.update(reported, (current) => [...current, stats]), | |
| 196 | }); | |
| 197 | ||
| 198 | sink.push(makeRecord("attributed")); | |
| 199 | yield* sink.flush; | |
| 200 | ||
| 201 | const stats = yield* Ref.get(reported); | |
| 202 | assert.equal(stats.length, 1); | |
| 203 | assert.equal(stats[0]?.count, 1); | |
| 204 | assert.isAbove(stats[0]?.logicalWriteBytes ?? 0, 0); | |
| 205 | }), | |
| 206 | ), | |
| 207 | ); | |
| 208 | ||
| 179 | 209 | it.effect("rotates the trace file when the configured max size is exceeded", () => |
| 180 | 210 | Effect.scoped( |
| 181 | 211 | Effect.gen(function* () { |
| ... | ... | @@ -186,7 +216,7 @@ describe("observability", () => { |
| 186 | 216 | |
| 187 | 217 | const sink = yield* makeTraceSink({ |
| 188 | 218 | filePath: tracePath, |
| 189 | maxBytes: 180, | |
| 219 | maxBytes: 500, | |
| 190 | 220 | maxFiles: 2, |
| 191 | 221 | batchWindowMs: 10_000, |
| 192 | 222 | }); |
| ... | ... | @@ -217,6 +247,70 @@ describe("observability", () => { |
| 217 | 247 | ), |
| 218 | 248 | ); |
| 219 | 249 | |
| 250 | it.effect("keeps every trace file within the configured limit for threshold flushes", () => | |
| 251 | Effect.scoped( | |
| 252 | Effect.gen(function* () { | |
| 253 | const fileSystem = yield* FileSystem.FileSystem; | |
| 254 | const path = yield* Path.Path; | |
| 255 | const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" }); | |
| 256 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 257 | const maxBytes = 1_024; | |
| 258 | ||
| 259 | const sink = yield* makeTraceSink({ | |
| 260 | filePath: tracePath, | |
| 261 | maxBytes, | |
| 262 | maxFiles: 2, | |
| 263 | batchWindowMs: 10_000, | |
| 264 | }); | |
| 265 | ||
| 266 | for (let index = 0; index < 256; index += 1) { | |
| 267 | sink.push(makeRecord("threshold", `${index}-${"x".repeat(48)}`)); | |
| 268 | } | |
| 269 | yield* sink.close(); | |
| 270 | ||
| 271 | const matchingFiles = (yield* fileSystem.readDirectory(tempDir)).filter( | |
| 272 | (entry) => entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."), | |
| 273 | ); | |
| 274 | assert.include(matchingFiles, "shared.trace.ndjson.1"); | |
| 275 | for (const entry of matchingFiles) { | |
| 276 | const stat = yield* fileSystem.stat(path.join(tempDir, entry)); | |
| 277 | assert.isAtMost(Number(stat.size), maxBytes, entry); | |
| 278 | } | |
| 279 | }), | |
| 280 | ), | |
| 281 | ); | |
| 282 | ||
| 283 | it.effect("drops a single trace record that cannot fit within the configured limit", () => | |
| 284 | Effect.scoped( | |
| 285 | Effect.gen(function* () { | |
| 286 | const fileSystem = yield* FileSystem.FileSystem; | |
| 287 | const path = yield* Path.Path; | |
| 288 | const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" }); | |
| 289 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 290 | const maxBytes = 1_024; | |
| 291 | ||
| 292 | const sink = yield* makeTraceSink({ | |
| 293 | filePath: tracePath, | |
| 294 | maxBytes, | |
| 295 | maxFiles: 2, | |
| 296 | batchWindowMs: 10_000, | |
| 297 | }); | |
| 298 | ||
| 299 | sink.push(makeRecord("oversized", "x".repeat(maxBytes * 2))); | |
| 300 | sink.push(makeRecord("retained")); | |
| 301 | yield* sink.close(); | |
| 302 | ||
| 303 | const records = yield* readTraceRecords(tracePath); | |
| 304 | const stat = yield* fileSystem.stat(tracePath); | |
| 305 | assert.deepEqual( | |
| 306 | records.map((record) => record.name), | |
| 307 | ["retained"], | |
| 308 | ); | |
| 309 | assert.isAtMost(Number(stat.size), maxBytes); | |
| 310 | }), | |
| 311 | ), | |
| 312 | ); | |
| 313 | ||
| 220 | 314 | it.effect("drops only the invalid trace record when serialization fails", () => |
| 221 | 315 | Effect.scoped( |
| 222 | 316 | Effect.gen(function* () { |
packages/shared/src/observability.ts+63-7| ... | ... | @@ -8,7 +8,8 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; |
| 8 | 8 | |
| 9 | 9 | import { RotatingFileSink } from "./logging.ts"; |
| 10 | 10 | |
| 11 | const FLUSH_BUFFER_THRESHOLD = 32; | |
| 11 | const FLUSH_BUFFER_THRESHOLD = 256; | |
| 12 | const textEncoder = new TextEncoder(); | |
| 12 | 13 | |
| 13 | 14 | export type TraceAttributes = Readonly<Record<string, unknown>>; |
| 14 | 15 | |
| ... | ... | @@ -109,6 +110,13 @@ export interface TraceSinkOptions { |
| 109 | 110 | readonly maxBytes: number; |
| 110 | 111 | readonly maxFiles: number; |
| 111 | 112 | readonly batchWindowMs: number; |
| 113 | readonly onFlush?: (stats: TraceSinkFlushStats) => Effect.Effect<void>; | |
| 114 | } | |
| 115 | ||
| 116 | export interface TraceSinkFlushStats { | |
| 117 | readonly logicalWriteBytes: number; | |
| 118 | readonly count: number; | |
| 119 | readonly durationMs: number; | |
| 112 | 120 | } |
| 113 | 121 | |
| 114 | 122 | export interface TraceSink { |
| ... | ... | @@ -275,26 +283,73 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac |
| 275 | 283 | filePath: options.filePath, |
| 276 | 284 | maxBytes: options.maxBytes, |
| 277 | 285 | maxFiles: options.maxFiles, |
| 286 | throwOnError: true, | |
| 278 | 287 | }); |
| 279 | 288 | |
| 280 | 289 | let buffer: Array<string> = []; |
| 290 | let pendingFlushStats: TraceSinkFlushStats = { | |
| 291 | logicalWriteBytes: 0, | |
| 292 | count: 0, | |
| 293 | durationMs: 0, | |
| 294 | }; | |
| 281 | 295 | |
| 282 | 296 | const flushUnsafe = () => { |
| 283 | 297 | if (buffer.length === 0) { |
| 284 | 298 | return; |
| 285 | 299 | } |
| 286 | 300 | |
| 287 | const chunk = buffer.join(""); | |
| 301 | const records = buffer; | |
| 288 | 302 | buffer = []; |
| 303 | let persistedCount = 0; | |
| 304 | ||
| 305 | while (persistedCount < records.length) { | |
| 306 | const firstRecordBytes = textEncoder.encode(records[persistedCount]).byteLength; | |
| 307 | if (firstRecordBytes > options.maxBytes) { | |
| 308 | persistedCount += 1; | |
| 309 | continue; | |
| 310 | } | |
| 311 | ||
| 312 | let nextIndex = persistedCount + 1; | |
| 313 | let chunkBytes = firstRecordBytes; | |
| 314 | while (nextIndex < records.length) { | |
| 315 | const nextRecordBytes = textEncoder.encode(records[nextIndex]).byteLength; | |
| 316 | if (chunkBytes + nextRecordBytes > options.maxBytes) break; | |
| 317 | chunkBytes += nextRecordBytes; | |
| 318 | nextIndex += 1; | |
| 319 | } | |
| 289 | 320 | |
| 290 | try { | |
| 291 | sink.write(chunk); | |
| 292 | } catch { | |
| 293 | buffer.unshift(chunk); | |
| 321 | const chunk = records.slice(persistedCount, nextIndex).join(""); | |
| 322 | const startedAt = performance.now(); | |
| 323 | try { | |
| 324 | sink.write(chunk); | |
| 325 | } catch { | |
| 326 | buffer.unshift(...records.slice(persistedCount)); | |
| 327 | return; | |
| 328 | } | |
| 329 | pendingFlushStats = { | |
| 330 | logicalWriteBytes: pendingFlushStats.logicalWriteBytes + chunkBytes, | |
| 331 | count: pendingFlushStats.count + nextIndex - persistedCount, | |
| 332 | durationMs: pendingFlushStats.durationMs + Math.max(0, performance.now() - startedAt), | |
| 333 | }; | |
| 334 | persistedCount = nextIndex; | |
| 294 | 335 | } |
| 295 | 336 | }; |
| 296 | 337 | |
| 297 | const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); | |
| 338 | const flush = Effect.sync(() => { | |
| 339 | flushUnsafe(); | |
| 340 | const stats = pendingFlushStats; | |
| 341 | pendingFlushStats = { | |
| 342 | logicalWriteBytes: 0, | |
| 343 | count: 0, | |
| 344 | durationMs: 0, | |
| 345 | }; | |
| 346 | return stats; | |
| 347 | }).pipe( | |
| 348 | Effect.flatMap((stats) => | |
| 349 | stats.count > 0 && options.onFlush ? options.onFlush(stats).pipe(Effect.ignore) : Effect.void, | |
| 350 | ), | |
| 351 | Effect.withTracerEnabled(false), | |
| 352 | ); | |
| 298 | 353 | |
| 299 | 354 | yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); |
| 300 | 355 | yield* Effect.forkScoped( |
| ... | ... | @@ -399,6 +454,7 @@ export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( |
| 399 | 454 | maxBytes: options.maxBytes, |
| 400 | 455 | maxFiles: options.maxFiles, |
| 401 | 456 | batchWindowMs: options.batchWindowMs, |
| 457 | ...(options.onFlush ? { onFlush: options.onFlush } : {}), | |
| 402 | 458 | })); |
| 403 | 459 | |
| 404 | 460 | const delegate = |
packages/shared/src/serverSettings.test.ts+217| ... | ... | @@ -4,7 +4,9 @@ import { |
| 4 | 4 | ProviderInstanceId, |
| 5 | 5 | type ServerProvider, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import * as Duration from "effect/Duration"; | |
| 7 | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; | |
| 8 | 10 | import { createModelSelection } from "./model.ts"; |
| 9 | 11 | import { |
| 10 | 12 | applyServerSettingsPatch, |
| ... | ... | @@ -295,4 +297,219 @@ describe("serverSettings helpers", () => { |
| 295 | 297 | config: { homePath: "~/.codex" }, |
| 296 | 298 | }); |
| 297 | 299 | }); |
| 300 | ||
| 301 | it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { | |
| 302 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 303 | backgroundActivity: { | |
| 304 | schemaVersion: 1, | |
| 305 | profile: "battery-saver", | |
| 306 | overrides: {}, | |
| 307 | }, | |
| 308 | }); | |
| 309 | ||
| 310 | expect(next.backgroundActivity).toEqual({ | |
| 311 | schemaVersion: 1, | |
| 312 | profile: "battery-saver", | |
| 313 | overrides: {}, | |
| 314 | }); | |
| 315 | expect(next.backgroundActivityProfile).toBe("battery-saver"); | |
| 316 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0); | |
| 317 | expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe( | |
| 318 | Duration.toMillis(Duration.minutes(15)), | |
| 319 | ); | |
| 320 | }); | |
| 321 | ||
| 322 | it("turns legacy interval patches into custom background activity overrides", () => { | |
| 323 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 324 | automaticGitFetchInterval: Duration.seconds(15), | |
| 325 | }); | |
| 326 | ||
| 327 | expect(next.backgroundActivity).toEqual({ | |
| 328 | schemaVersion: 1, | |
| 329 | profile: "custom", | |
| 330 | baseProfile: "balanced", | |
| 331 | overrides: { | |
| 332 | automaticGitFetchInterval: Duration.seconds(15), | |
| 333 | }, | |
| 334 | }); | |
| 335 | expect(resolveServerBackgroundActivitySettings(next).profile).toBe("balanced"); | |
| 336 | expect( | |
| 337 | Duration.toMillis(resolveServerBackgroundActivitySettings(next).automaticGitFetchInterval), | |
| 338 | ).toBe(15_000); | |
| 339 | }); | |
| 340 | ||
| 341 | it("preserves legacy background activity settings when applying an unrelated patch", () => { | |
| 342 | const current = { | |
| 343 | ...DEFAULT_SERVER_SETTINGS, | |
| 344 | backgroundActivityProfile: "performance" as const, | |
| 345 | automaticGitFetchInterval: Duration.seconds(7), | |
| 346 | providerHealthRefreshInterval: Duration.minutes(4), | |
| 347 | }; | |
| 348 | ||
| 349 | const next = applyServerSettingsPatch(current, { | |
| 350 | sourceControlWriterModelSelection: createModelSelection( | |
| 351 | ProviderInstanceId.make("codex"), | |
| 352 | "gpt-5.4-mini", | |
| 353 | ), | |
| 354 | }); | |
| 355 | ||
| 356 | expect(next.backgroundActivity).toEqual({ | |
| 357 | schemaVersion: 1, | |
| 358 | profile: "custom", | |
| 359 | baseProfile: "performance", | |
| 360 | overrides: { | |
| 361 | automaticGitFetchInterval: Duration.seconds(7), | |
| 362 | providerHealthRefreshInterval: Duration.minutes(4), | |
| 363 | }, | |
| 364 | }); | |
| 365 | expect(next.backgroundActivityProfile).toBe("performance"); | |
| 366 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(7_000); | |
| 367 | expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe(240_000); | |
| 368 | }); | |
| 369 | ||
| 370 | it("does not reactivate dormant overrides from a concrete profile", () => { | |
| 371 | const current = { | |
| 372 | ...DEFAULT_SERVER_SETTINGS, | |
| 373 | backgroundActivity: { | |
| 374 | schemaVersion: 1 as const, | |
| 375 | profile: "battery-saver" as const, | |
| 376 | overrides: { | |
| 377 | providerHealthRefreshInterval: Duration.seconds(5), | |
| 378 | }, | |
| 379 | }, | |
| 380 | }; | |
| 381 | ||
| 382 | const next = applyServerSettingsPatch(current, { | |
| 383 | automaticGitFetchInterval: Duration.seconds(15), | |
| 384 | }); | |
| 385 | ||
| 386 | expect(next.backgroundActivity).toEqual({ | |
| 387 | schemaVersion: 1, | |
| 388 | profile: "custom", | |
| 389 | baseProfile: "battery-saver", | |
| 390 | overrides: { | |
| 391 | automaticGitFetchInterval: Duration.seconds(15), | |
| 392 | }, | |
| 393 | }); | |
| 394 | }); | |
| 395 | ||
| 396 | it("prefers structured background activity settings over legacy aliases", () => { | |
| 397 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 398 | backgroundActivity: { | |
| 399 | schemaVersion: 1, | |
| 400 | profile: "battery-saver", | |
| 401 | overrides: {}, | |
| 402 | }, | |
| 403 | automaticGitFetchInterval: Duration.seconds(5), | |
| 404 | backgroundActivityProfile: "performance", | |
| 405 | }); | |
| 406 | ||
| 407 | expect(next.backgroundActivity).toEqual({ | |
| 408 | schemaVersion: 1, | |
| 409 | profile: "battery-saver", | |
| 410 | overrides: {}, | |
| 411 | }); | |
| 412 | expect(next.backgroundActivityProfile).toBe("battery-saver"); | |
| 413 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0); | |
| 414 | }); | |
| 415 | ||
| 416 | it("reconciles custom background activity back to a preset when overrides match the preset", () => { | |
| 417 | const custom = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 418 | automaticGitFetchInterval: Duration.seconds(15), | |
| 419 | }); | |
| 420 | const next = applyServerSettingsPatch(custom, { | |
| 421 | automaticGitFetchInterval: Duration.seconds(30), | |
| 422 | }); | |
| 423 | ||
| 424 | expect(next.backgroundActivity).toEqual({ | |
| 425 | schemaVersion: 1, | |
| 426 | profile: "balanced", | |
| 427 | overrides: {}, | |
| 428 | }); | |
| 429 | expect(next.backgroundActivityProfile).toBe("balanced"); | |
| 430 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(30_000); | |
| 431 | }); | |
| 432 | ||
| 433 | it("drops custom overrides that duplicate the base profile", () => { | |
| 434 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 435 | backgroundActivity: { | |
| 436 | schemaVersion: 1, | |
| 437 | profile: "custom", | |
| 438 | baseProfile: "balanced", | |
| 439 | overrides: { | |
| 440 | automaticGitFetchInterval: Duration.seconds(30), | |
| 441 | }, | |
| 442 | }, | |
| 443 | }); | |
| 444 | ||
| 445 | expect(next.backgroundActivity).toEqual({ | |
| 446 | schemaVersion: 1, | |
| 447 | profile: "balanced", | |
| 448 | overrides: {}, | |
| 449 | }); | |
| 450 | }); | |
| 451 | ||
| 452 | it("replaces the complete background override record", () => { | |
| 453 | const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 454 | backgroundActivity: { | |
| 455 | schemaVersion: 1, | |
| 456 | profile: "custom", | |
| 457 | baseProfile: "balanced", | |
| 458 | overrides: { | |
| 459 | automaticGitFetchInterval: Duration.seconds(15), | |
| 460 | providerHealthRefreshInterval: Duration.minutes(3), | |
| 461 | }, | |
| 462 | }, | |
| 463 | }); | |
| 464 | ||
| 465 | const next = applyServerSettingsPatch(current, { | |
| 466 | backgroundActivity: { | |
| 467 | overrides: { | |
| 468 | automaticGitFetchInterval: Duration.seconds(10), | |
| 469 | }, | |
| 470 | }, | |
| 471 | }); | |
| 472 | ||
| 473 | expect(next.backgroundActivity).toEqual({ | |
| 474 | schemaVersion: 1, | |
| 475 | profile: "custom", | |
| 476 | baseProfile: "balanced", | |
| 477 | overrides: { | |
| 478 | automaticGitFetchInterval: Duration.seconds(10), | |
| 479 | }, | |
| 480 | }); | |
| 481 | }); | |
| 482 | ||
| 483 | it("keeps interval overrides supplied with a profile patch", () => { | |
| 484 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 485 | backgroundActivityProfile: "performance", | |
| 486 | automaticGitFetchInterval: Duration.seconds(0), | |
| 487 | providerHealthRefreshInterval: Duration.minutes(4), | |
| 488 | }); | |
| 489 | ||
| 490 | expect(next.backgroundActivity).toEqual({ | |
| 491 | schemaVersion: 1, | |
| 492 | profile: "custom", | |
| 493 | baseProfile: "performance", | |
| 494 | overrides: { | |
| 495 | automaticGitFetchInterval: Duration.seconds(0), | |
| 496 | providerHealthRefreshInterval: Duration.minutes(4), | |
| 497 | }, | |
| 498 | }); | |
| 499 | }); | |
| 500 | ||
| 501 | it("ignores overrides attached to a concrete background profile", () => { | |
| 502 | const resolved = resolveServerBackgroundActivitySettings({ | |
| 503 | ...DEFAULT_SERVER_SETTINGS, | |
| 504 | backgroundActivity: { | |
| 505 | schemaVersion: 1, | |
| 506 | profile: "balanced", | |
| 507 | overrides: { | |
| 508 | pauseWhenOnBattery: true, | |
| 509 | }, | |
| 510 | }, | |
| 511 | }); | |
| 512 | ||
| 513 | expect(resolved.pauseWhenOnBattery).toBe(false); | |
| 514 | }); | |
| 298 | 515 | }); |
packages/shared/src/serverSettings.ts+76-2| ... | ... | @@ -12,6 +12,12 @@ import * as Schema from "effect/Schema"; |
| 12 | 12 | import { deepMerge } from "./Struct.ts"; |
| 13 | 13 | import { fromLenientJson } from "./schemaJson.ts"; |
| 14 | 14 | import { createModelSelection } from "./model.ts"; |
| 15 | import { | |
| 16 | getBackgroundActivityBaseProfile, | |
| 17 | normalizeBackgroundActivitySettings, | |
| 18 | normalizeServerBackgroundActivitySettings, | |
| 19 | resolveBackgroundActivitySettings, | |
| 20 | } from "./backgroundActivitySettings.ts"; | |
| 15 | 21 | |
| 16 | 22 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 17 | 23 | const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); |
| ... | ... | @@ -120,10 +126,64 @@ export function applyServerSettingsPatch( |
| 120 | 126 | patch: ServerSettingsPatch, |
| 121 | 127 | ): ServerSettings { |
| 122 | 128 | const selectionPatch = patch.textGenerationModelSelection; |
| 123 | const { automaticGitFetchInterval, ...patchForMerge } = patch; | |
| 129 | const { | |
| 130 | automaticGitFetchInterval, | |
| 131 | providerHealthRefreshInterval, | |
| 132 | backgroundActivityProfile, | |
| 133 | backgroundActivity, | |
| 134 | ...patchForMerge | |
| 135 | } = patch; | |
| 136 | const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); | |
| 137 | const backgroundActivityPatch = | |
| 138 | backgroundActivityProfile !== undefined | |
| 139 | ? { | |
| 140 | schemaVersion: 1 as const, | |
| 141 | profile: | |
| 142 | automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined | |
| 143 | ? ("custom" as const) | |
| 144 | : backgroundActivityProfile, | |
| 145 | ...(automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined | |
| 146 | ? { baseProfile: backgroundActivityProfile } | |
| 147 | : {}), | |
| 148 | overrides: { | |
| 149 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), | |
| 150 | ...(providerHealthRefreshInterval !== undefined | |
| 151 | ? { providerHealthRefreshInterval } | |
| 152 | : {}), | |
| 153 | }, | |
| 154 | } | |
| 155 | : automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined | |
| 156 | ? { | |
| 157 | schemaVersion: 1 as const, | |
| 158 | profile: "custom" as const, | |
| 159 | baseProfile: getBackgroundActivityBaseProfile(currentBackgroundActivity), | |
| 160 | overrides: { | |
| 161 | ...(currentBackgroundActivity.profile === "custom" | |
| 162 | ? currentBackgroundActivity.overrides | |
| 163 | : {}), | |
| 164 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), | |
| 165 | ...(providerHealthRefreshInterval !== undefined | |
| 166 | ? { providerHealthRefreshInterval } | |
| 167 | : {}), | |
| 168 | }, | |
| 169 | } | |
| 170 | : undefined; | |
| 124 | 171 | const next = deepMerge(current, patchForMerge); |
| 125 | const nextWithReplacements = { | |
| 172 | const nextWithReplacementsBase = { | |
| 126 | 173 | ...next, |
| 174 | ...(backgroundActivity !== undefined | |
| 175 | ? { | |
| 176 | backgroundActivity: { | |
| 177 | ...deepMerge(currentBackgroundActivity, backgroundActivity), | |
| 178 | ...(backgroundActivity.overrides !== undefined | |
| 179 | ? { overrides: backgroundActivity.overrides } | |
| 180 | : {}), | |
| 181 | }, | |
| 182 | } | |
| 183 | : { backgroundActivity: currentBackgroundActivity }), | |
| 184 | ...(backgroundActivity === undefined && backgroundActivityPatch !== undefined | |
| 185 | ? { backgroundActivity: backgroundActivityPatch } | |
| 186 | : {}), | |
| 127 | 187 | ...(patch.providerInstances !== undefined |
| 128 | 188 | ? { providerInstances: patch.providerInstances } |
| 129 | 189 | : {}), |
| ... | ... | @@ -131,6 +191,20 @@ export function applyServerSettingsPatch( |
| 131 | 191 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } |
| 132 | 192 | : {}), |
| 133 | 193 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), |
| 194 | ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}), | |
| 195 | }; | |
| 196 | const normalizedBackgroundActivity = normalizeBackgroundActivitySettings( | |
| 197 | nextWithReplacementsBase.backgroundActivity, | |
| 198 | ); | |
| 199 | const resolvedBackgroundActivity = resolveBackgroundActivitySettings( | |
| 200 | normalizedBackgroundActivity, | |
| 201 | ); | |
| 202 | const nextWithReplacements = { | |
| 203 | ...nextWithReplacementsBase, | |
| 204 | backgroundActivity: normalizedBackgroundActivity, | |
| 205 | automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval, | |
| 206 | providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval, | |
| 207 | backgroundActivityProfile: resolvedBackgroundActivity.profile, | |
| 134 | 208 | }; |
| 135 | 209 | if (!selectionPatch) { |
| 136 | 210 | return nextWithReplacements; |
scripts/build-desktop-artifact.test.ts+50| ... | ... | @@ -15,9 +15,11 @@ import { |
| 15 | 15 | createBuildConfig, |
| 16 | 16 | DESKTOP_ELECTRON_LANGUAGES, |
| 17 | 17 | DESKTOP_FILE_EXCLUSIONS, |
| 18 | DESKTOP_EXTRA_RESOURCES, | |
| 18 | 19 | InvalidMacPasskeyRpDomainError, |
| 19 | 20 | InvalidMacPasskeyPublishableKeyError, |
| 20 | 21 | InvalidMockUpdateServerPortError, |
| 22 | UnsupportedDesktopBuildArchitectureError, | |
| 21 | 23 | isMacPasskeySigningConfigurationError, |
| 22 | 24 | LinuxIconResizeError, |
| 23 | 25 | MacPasskeySigningConfigurationResolutionError, |
| ... | ... | @@ -32,6 +34,8 @@ import { |
| 32 | 34 | resolveDesktopProductName, |
| 33 | 35 | resolveDesktopUpdateChannel, |
| 34 | 36 | resolveDesktopWebAssetBrand, |
| 37 | resolveResourceMonitorRustTargets, | |
| 38 | resourceMonitorExecutableName, | |
| 35 | 39 | resolveGitHubPublishConfig, |
| 36 | 40 | resolveMockUpdateServerPort, |
| 37 | 41 | resolveMockUpdateServerUrl, |
| ... | ... | @@ -543,6 +547,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 543 | 547 | }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), |
| 544 | 548 | ); |
| 545 | 549 | |
| 550 | it("stages the resource monitor as an external executable resource", () => { | |
| 551 | assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [ | |
| 552 | { | |
| 553 | from: "apps/desktop/prod-resources/resource-monitor", | |
| 554 | to: "resource-monitor", | |
| 555 | }, | |
| 556 | ]); | |
| 557 | assert.deepStrictEqual(resolveResourceMonitorRustTargets("mac", "universal"), [ | |
| 558 | "aarch64-apple-darwin", | |
| 559 | "x86_64-apple-darwin", | |
| 560 | ]); | |
| 561 | assert.deepStrictEqual(resolveResourceMonitorRustTargets("linux", "x64"), [ | |
| 562 | "x86_64-unknown-linux-gnu", | |
| 563 | ]); | |
| 564 | assert.deepStrictEqual(resolveResourceMonitorRustTargets("win", "arm64"), [ | |
| 565 | "aarch64-pc-windows-msvc", | |
| 566 | ]); | |
| 567 | assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor"); | |
| 568 | assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); | |
| 569 | }); | |
| 546 | 570 | it("promotes target fff binaries to direct staged dependencies", () => { |
| 547 | 571 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { |
| 548 | 572 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", |
| ... | ... | @@ -673,6 +697,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 673 | 697 | }), |
| 674 | 698 | ); |
| 675 | 699 | |
| 700 | it.effect("rejects universal builds on Linux and Windows before staging binaries", () => | |
| 701 | Effect.gen(function* () { | |
| 702 | for (const platform of ["linux", "win"] as const) { | |
| 703 | const error = yield* Effect.flip( | |
| 704 | resolveBuildOptions({ | |
| 705 | platform: Option.some(platform), | |
| 706 | target: Option.none(), | |
| 707 | arch: Option.some("universal"), | |
| 708 | buildVersion: Option.none(), | |
| 709 | outputDir: Option.none(), | |
| 710 | skipBuild: Option.none(), | |
| 711 | keepStage: Option.none(), | |
| 712 | signed: Option.none(), | |
| 713 | verbose: Option.none(), | |
| 714 | mockUpdates: Option.none(), | |
| 715 | mockUpdateServerPort: Option.none(), | |
| 716 | wslPrebuild: Option.none(), | |
| 717 | }), | |
| 718 | ); | |
| 719 | ||
| 720 | assert.instanceOf(error, UnsupportedDesktopBuildArchitectureError); | |
| 721 | assert.deepStrictEqual(error.supportedArchitectures, ["x64", "arm64"]); | |
| 722 | } | |
| 723 | }), | |
| 724 | ); | |
| 725 | ||
| 676 | 726 | it.effect("preserves explicit false boolean flags over true env defaults", () => |
| 677 | 727 | Effect.gen(function* () { |
| 678 | 728 | const resolved = yield* resolveBuildOptions({ |
scripts/build-desktop-artifact.ts+144| ... | ... | @@ -94,6 +94,26 @@ interface PlatformConfig { |
| 94 | 94 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | export function resolveResourceMonitorRustTargets( | |
| 98 | platform: typeof BuildPlatform.Type, | |
| 99 | arch: typeof BuildArch.Type, | |
| 100 | ): ReadonlyArray<string> { | |
| 101 | if (platform === "mac") { | |
| 102 | if (arch === "universal") { | |
| 103 | return ["aarch64-apple-darwin", "x86_64-apple-darwin"]; | |
| 104 | } | |
| 105 | return [arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"]; | |
| 106 | } | |
| 107 | if (platform === "linux") { | |
| 108 | return [arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"]; | |
| 109 | } | |
| 110 | return [arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"]; | |
| 111 | } | |
| 112 | ||
| 113 | export function resourceMonitorExecutableName(platform: typeof BuildPlatform.Type): string { | |
| 114 | return platform === "win" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; | |
| 115 | } | |
| 116 | ||
| 97 | 117 | const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = { |
| 98 | 118 | mac: { |
| 99 | 119 | cliFlag: "--mac", |
| ... | ... | @@ -189,6 +209,19 @@ export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass<U |
| 189 | 209 | } |
| 190 | 210 | } |
| 191 | 211 | |
| 212 | export class UnsupportedDesktopBuildArchitectureError extends Schema.TaggedErrorClass<UnsupportedDesktopBuildArchitectureError>()( | |
| 213 | "UnsupportedDesktopBuildArchitectureError", | |
| 214 | { | |
| 215 | platform: BuildPlatform, | |
| 216 | arch: BuildArch, | |
| 217 | supportedArchitectures: Schema.Array(BuildArch), | |
| 218 | }, | |
| 219 | ) { | |
| 220 | override get message(): string { | |
| 221 | return `Unsupported architecture '${this.arch}' for ${this.platform}.`; | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 192 | 225 | const InvalidMockUpdateServerPortReason = Schema.Literals([ |
| 193 | 226 | "not-numeric", |
| 194 | 227 | "not-integer", |
| ... | ... | @@ -236,6 +269,20 @@ export class BuildCommandFailedError extends Schema.TaggedErrorClass<BuildComman |
| 236 | 269 | } |
| 237 | 270 | } |
| 238 | 271 | |
| 272 | export class ResourceMonitorBuildOutputMissingError extends Schema.TaggedErrorClass<ResourceMonitorBuildOutputMissingError>()( | |
| 273 | "ResourceMonitorBuildOutputMissingError", | |
| 274 | { | |
| 275 | binaryPath: Schema.String, | |
| 276 | rustTarget: Schema.String, | |
| 277 | platform: BuildPlatform, | |
| 278 | arch: BuildArch, | |
| 279 | }, | |
| 280 | ) { | |
| 281 | override get message(): string { | |
| 282 | return `Resource monitor build for ${this.rustTarget} did not produce ${this.binaryPath}.`; | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 239 | 286 | const desktopIconPlatformNames = { |
| 240 | 287 | mac: "macOS", |
| 241 | 288 | linux: "Linux", |
| ... | ... | @@ -593,6 +640,12 @@ export const DESKTOP_FILE_EXCLUSIONS = [ |
| 593 | 640 | // The Windows primary backend reads the same files through the asar redirect, |
| 594 | 641 | // so nothing is duplicated. |
| 595 | 642 | export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const; |
| 643 | export const DESKTOP_EXTRA_RESOURCES = [ | |
| 644 | { | |
| 645 | from: "apps/desktop/prod-resources/resource-monitor", | |
| 646 | to: "resource-monitor", | |
| 647 | }, | |
| 648 | ] as const; | |
| 596 | 649 | |
| 597 | 650 | export interface MacPasskeySigningConfiguration { |
| 598 | 651 | readonly appId: string; |
| ... | ... | @@ -1044,6 +1097,14 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1044 | 1097 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); |
| 1045 | 1098 | const defaultArch = yield* getDefaultArch(platform); |
| 1046 | 1099 | const arch = mergeOptions(input.arch, env.arch, defaultArch); |
| 1100 | const supportedArchitectures = PLATFORM_CONFIG[platform].archChoices; | |
| 1101 | if (!supportedArchitectures.includes(arch)) { | |
| 1102 | return yield* new UnsupportedDesktopBuildArchitectureError({ | |
| 1103 | platform, | |
| 1104 | arch, | |
| 1105 | supportedArchitectures: [...supportedArchitectures], | |
| 1106 | }); | |
| 1107 | } | |
| 1047 | 1108 | const version = mergeOptions(input.buildVersion, env.version, undefined); |
| 1048 | 1109 | const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) |
| 1049 | 1110 | ? "release-mock" |
| ... | ... | @@ -1117,6 +1178,81 @@ const runCommand = Effect.fn("runCommand")(function* ( |
| 1117 | 1178 | } |
| 1118 | 1179 | }); |
| 1119 | 1180 | |
| 1181 | const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: { | |
| 1182 | readonly repoRoot: string; | |
| 1183 | readonly stageResourcesDir: string; | |
| 1184 | readonly platform: typeof BuildPlatform.Type; | |
| 1185 | readonly arch: typeof BuildArch.Type; | |
| 1186 | readonly verbose: boolean; | |
| 1187 | }) { | |
| 1188 | const fs = yield* FileSystem.FileSystem; | |
| 1189 | const path = yield* Path.Path; | |
| 1190 | const manifestPath = path.join(input.repoRoot, "native/resource-monitor/Cargo.toml"); | |
| 1191 | const executableName = resourceMonitorExecutableName(input.platform); | |
| 1192 | const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch); | |
| 1193 | const builtBinaries: string[] = []; | |
| 1194 | ||
| 1195 | for (const rustTarget of rustTargets) { | |
| 1196 | const spawnCommand = yield* resolveSpawnCommand("cargo", [ | |
| 1197 | "build", | |
| 1198 | "--locked", | |
| 1199 | "--release", | |
| 1200 | "--manifest-path", | |
| 1201 | manifestPath, | |
| 1202 | "--target", | |
| 1203 | rustTarget, | |
| 1204 | ]); | |
| 1205 | yield* runCommand( | |
| 1206 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 1207 | cwd: input.repoRoot, | |
| 1208 | shell: spawnCommand.shell, | |
| 1209 | }), | |
| 1210 | { | |
| 1211 | label: `cargo build resource monitor (${rustTarget})`, | |
| 1212 | verbose: input.verbose, | |
| 1213 | }, | |
| 1214 | ); | |
| 1215 | ||
| 1216 | const binaryPath = path.join( | |
| 1217 | input.repoRoot, | |
| 1218 | "native/resource-monitor/target", | |
| 1219 | rustTarget, | |
| 1220 | "release", | |
| 1221 | executableName, | |
| 1222 | ); | |
| 1223 | if (!(yield* fs.exists(binaryPath))) { | |
| 1224 | return yield* new ResourceMonitorBuildOutputMissingError({ | |
| 1225 | binaryPath, | |
| 1226 | rustTarget, | |
| 1227 | platform: input.platform, | |
| 1228 | arch: input.arch, | |
| 1229 | }); | |
| 1230 | } | |
| 1231 | builtBinaries.push(binaryPath); | |
| 1232 | } | |
| 1233 | ||
| 1234 | const destinationDirectory = path.join(input.stageResourcesDir, "resource-monitor"); | |
| 1235 | const destinationPath = path.join(destinationDirectory, executableName); | |
| 1236 | yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore); | |
| 1237 | yield* fs.makeDirectory(destinationDirectory, { recursive: true }); | |
| 1238 | ||
| 1239 | if (builtBinaries.length === 1) { | |
| 1240 | yield* fs.copyFile(builtBinaries[0]!, destinationPath); | |
| 1241 | } else { | |
| 1242 | yield* runCommand( | |
| 1243 | ChildProcess.make("lipo", ["-create", ...builtBinaries, "-output", destinationPath]), | |
| 1244 | { | |
| 1245 | label: "lipo resource monitor universal binary", | |
| 1246 | verbose: input.verbose, | |
| 1247 | }, | |
| 1248 | ); | |
| 1249 | } | |
| 1250 | ||
| 1251 | if (input.platform !== "win") { | |
| 1252 | yield* fs.chmod(destinationPath, 0o755); | |
| 1253 | } | |
| 1254 | }); | |
| 1255 | ||
| 1120 | 1256 | function generateMacIconSet( |
| 1121 | 1257 | sourcePng: string, |
| 1122 | 1258 | targetIcns: string, |
| ... | ... | @@ -1412,6 +1548,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 1412 | 1548 | // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack |
| 1413 | 1549 | // extracts native libraries, which fff-node finds in app.asar.unpacked. |
| 1414 | 1550 | ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}), |
| 1551 | extraResources: DESKTOP_EXTRA_RESOURCES, | |
| 1415 | 1552 | }; |
| 1416 | 1553 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 1417 | 1554 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| ... | ... | @@ -1691,6 +1828,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1691 | 1828 | yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); |
| 1692 | 1829 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); |
| 1693 | 1830 | yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); |
| 1831 | yield* stageResourceMonitor({ | |
| 1832 | repoRoot, | |
| 1833 | stageResourcesDir, | |
| 1834 | platform: options.platform, | |
| 1835 | arch: options.arch, | |
| 1836 | verbose: options.verbose, | |
| 1837 | }); | |
| 1694 | 1838 | |
| 1695 | 1839 | yield* assertPlatformBuildResources( |
| 1696 | 1840 | options.platform, |