| 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,6 +22,11 @@ jobs: |
| 22 | cache: true | 22 | cache: true |
| 23 | run-install: true | 23 | run-install: true |
| 24 | 24 | ||
| 25 | - name: Setup Rust | ||
| 26 | uses: dtolnay/rust-toolchain@stable | ||
| 27 | with: | ||
| 28 | components: rustfmt | ||
| 29 | |||
| 25 | - name: Ensure Electron runtime is installed | 30 | - name: Ensure Electron runtime is installed |
| 26 | run: vp run --filter @t3tools/desktop ensure:electron | 31 | run: vp run --filter @t3tools/desktop ensure:electron |
| 27 | 32 | ||
| ... | @@ -31,6 +36,9 @@ jobs: | ... | @@ -31,6 +36,9 @@ jobs: |
| 31 | - name: Typecheck | 36 | - name: Typecheck |
| 32 | run: vpr typecheck | 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 | - name: Build desktop pipeline | 42 | - name: Build desktop pipeline |
| 35 | run: vp run build:desktop | 43 | run: vp run build:desktop |
| 36 | 44 | ||
| ... | @@ -55,12 +63,18 @@ jobs: | ... | @@ -55,12 +63,18 @@ jobs: |
| 55 | cache: true | 63 | cache: true |
| 56 | run-install: true | 64 | run-install: true |
| 57 | 65 | ||
| 66 | - name: Setup Rust | ||
| 67 | uses: dtolnay/rust-toolchain@stable | ||
| 68 | |||
| 58 | - name: Ensure Electron runtime is installed | 69 | - name: Ensure Electron runtime is installed |
| 59 | run: vp run --filter @t3tools/desktop ensure:electron | 70 | run: vp run --filter @t3tools/desktop ensure:electron |
| 60 | 71 | ||
| 61 | - name: Test | 72 | - name: Test |
| 62 | run: vp run test | 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 | mobile_native_static_analysis: | 78 | mobile_native_static_analysis: |
| 65 | name: Mobile Native Static Analysis | 79 | name: Mobile Native Static Analysis |
| 66 | runs-on: blacksmith-12vcpu-macos-26 | 80 | runs-on: blacksmith-12vcpu-macos-26 |
.github/workflows/release.yml+51| ... | @@ -326,21 +326,29 @@ jobs: | ... | @@ -326,21 +326,29 @@ jobs: |
| 326 | platform: mac | 326 | platform: mac |
| 327 | target: dmg | 327 | target: dmg |
| 328 | arch: arm64 | 328 | arch: arm64 |
| 329 | rust_target: aarch64-apple-darwin | ||
| 330 | resource_key: darwin-arm64 | ||
| 329 | - label: macOS x64 | 331 | - label: macOS x64 |
| 330 | runner: blacksmith-12vcpu-macos-26 | 332 | runner: blacksmith-12vcpu-macos-26 |
| 331 | platform: mac | 333 | platform: mac |
| 332 | target: dmg | 334 | target: dmg |
| 333 | arch: x64 | 335 | arch: x64 |
| 336 | rust_target: x86_64-apple-darwin | ||
| 337 | resource_key: darwin-x64 | ||
| 334 | - label: Linux x64 | 338 | - label: Linux x64 |
| 335 | runner: blacksmith-32vcpu-ubuntu-2404 | 339 | runner: blacksmith-32vcpu-ubuntu-2404 |
| 336 | platform: linux | 340 | platform: linux |
| 337 | target: AppImage | 341 | target: AppImage |
| 338 | arch: x64 | 342 | arch: x64 |
| 343 | rust_target: x86_64-unknown-linux-gnu | ||
| 344 | resource_key: linux-x64 | ||
| 339 | - label: Windows x64 | 345 | - label: Windows x64 |
| 340 | runner: blacksmith-32vcpu-windows-2025 | 346 | runner: blacksmith-32vcpu-windows-2025 |
| 341 | platform: win | 347 | platform: win |
| 342 | target: nsis | 348 | target: nsis |
| 343 | arch: x64 | 349 | arch: x64 |
| 350 | rust_target: x86_64-pc-windows-msvc | ||
| 351 | resource_key: win32-x64 | ||
| 344 | # - label: Windows arm64 | 352 | # - label: Windows arm64 |
| 345 | # runner: windows-11-arm | 353 | # runner: windows-11-arm |
| 346 | # platform: win | 354 | # platform: win |
| ... | @@ -360,6 +368,11 @@ jobs: | ... | @@ -360,6 +368,11 @@ jobs: |
| 360 | cache: true | 368 | cache: true |
| 361 | run-install: true | 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 | - name: Download relay client tracing config | 376 | - name: Download relay client tracing config |
| 364 | uses: actions/download-artifact@v8 | 377 | uses: actions/download-artifact@v8 |
| 365 | with: | 378 | with: |
| ... | @@ -598,6 +611,19 @@ jobs: | ... | @@ -598,6 +611,19 @@ jobs: |
| 598 | # done | 611 | # done |
| 599 | # fi | 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 | - name: Upload build artifacts | 627 | - name: Upload build artifacts |
| 602 | uses: actions/upload-artifact@v7 | 628 | uses: actions/upload-artifact@v7 |
| 603 | with: | 629 | with: |
| ... | @@ -605,6 +631,13 @@ jobs: | ... | @@ -605,6 +631,13 @@ jobs: |
| 605 | path: release-publish/* | 631 | path: release-publish/* |
| 606 | if-no-files-found: error | 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 | publish_cli: | 641 | publish_cli: |
| 609 | name: Publish CLI to npm | 642 | name: Publish CLI to npm |
| 610 | needs: [preflight, relay_public_config, build] | 643 | needs: [preflight, relay_public_config, build] |
| ... | @@ -659,6 +692,24 @@ jobs: | ... | @@ -659,6 +692,24 @@ jobs: |
| 659 | - name: Build CLI package | 692 | - name: Build CLI package |
| 660 | run: vp run --filter t3 build | 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 | - name: Publish CLI package | 713 | - name: Publish CLI package |
| 663 | run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | 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,6 +30,7 @@ dist-electron/ |
| 30 | .showcase/ | 30 | .showcase/ |
| 31 | apps/mobile/.showcase/ | 31 | apps/mobile/.showcase/ |
| 32 | artifacts/app-store/screenshots/ | 32 | artifacts/app-store/screenshots/ |
| 33 | native/**/target/ | ||
| 33 | node_modules/ | 34 | node_modules/ |
| 34 | .alchemy/ | 35 | .alchemy/ |
| 35 | *.log | 36 | *.log |
apps/desktop/src/app/DesktopAppIdentity.test.ts+1| ... | @@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => | ... | @@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => |
| 55 | }), | 55 | }), |
| 56 | setAppUserModelId: () => Effect.void, | 56 | setAppUserModelId: () => Effect.void, |
| 57 | requestSingleInstanceLock: Effect.succeed(true), | 57 | requestSingleInstanceLock: Effect.succeed(true), |
| 58 | getAppMetrics: Effect.succeed([]), | ||
| 58 | isDefaultProtocolClient: () => Effect.succeed(false), | 59 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 59 | setAsDefaultProtocolClient: () => Effect.succeed(true), | 60 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 60 | setDesktopName: () => Effect.void, | 61 | setDesktopName: () => Effect.void, |
apps/desktop/src/app/DesktopLifecycle.test.ts+1| ... | @@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => { | ... | @@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => { |
| 30 | setAboutPanelOptions: () => Effect.void, | 30 | setAboutPanelOptions: () => Effect.void, |
| 31 | setAppUserModelId: () => Effect.void, | 31 | setAppUserModelId: () => Effect.void, |
| 32 | requestSingleInstanceLock: Effect.succeed(true), | 32 | requestSingleInstanceLock: Effect.succeed(true), |
| 33 | getAppMetrics: Effect.succeed([]), | ||
| 33 | isDefaultProtocolClient: () => Effect.succeed(false), | 34 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 34 | setAsDefaultProtocolClient: () => Effect.succeed(true), | 35 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 35 | setDesktopName: () => Effect.void, | 36 | setDesktopName: () => Effect.void, |
apps/desktop/src/app/DesktopObservability.test.ts+180-14| ... | @@ -49,20 +49,41 @@ const environmentInput = (baseDir: string) => | ... | @@ -49,20 +49,41 @@ const environmentInput = (baseDir: string) => |
| 49 | runningUnderArm64Translation: false, | 49 | runningUnderArm64Translation: false, |
| 50 | }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; | 50 | }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; |
| 51 | 51 | ||
| 52 | const makeEnvironmentLayer = (baseDir: string) => | 52 | const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) => |
| 53 | DesktopEnvironment.layer(environmentInput(baseDir)).pipe( | 53 | DesktopEnvironment.layer(environmentInput(baseDir)).pipe( |
| 54 | Layer.provide( | 54 | Layer.provide( |
| 55 | Layer.mergeAll( | 55 | Layer.mergeAll( |
| 56 | NodeServices.layer, | 56 | NodeServices.layer, |
| 57 | DesktopConfig.layerTest({ | 57 | DesktopConfig.layerTest({ |
| 58 | T3CODE_HOME: baseDir, | 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 | describe("DesktopObservability", () => { | 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 | it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => | 87 | it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => |
| 67 | Effect.gen(function* () { | 88 | Effect.gen(function* () { |
| 68 | const fileSystem = yield* FileSystem.FileSystem; | 89 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | @@ -112,26 +133,34 @@ describe("DesktopObservability", () => { | ... | @@ -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 | Effect.gen(function* () { | 137 | Effect.gen(function* () { |
| 117 | const fileSystem = yield* FileSystem.FileSystem; | 138 | const fileSystem = yield* FileSystem.FileSystem; |
| 118 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | 139 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 119 | prefix: "t3-desktop-backend-output-log-test-", | 140 | prefix: "t3-desktop-backend-output-log-test-", |
| 120 | }); | 141 | }); |
| 121 | const environmentLayer = makeEnvironmentLayer(baseDir); | 142 | const environmentLayer = makeEnvironmentLayer(baseDir, false); |
| 122 | const logPath = yield* Effect.gen(function* () { | 143 | const logPath = yield* Effect.gen(function* () { |
| 123 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 144 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 124 | return environment.path.join(environment.logDir, "server-child.log"); | 145 | return environment.path.join(environment.logDir, "server-child.log"); |
| 125 | }).pipe(Effect.provide(environmentLayer)); | 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 | yield* Effect.gen(function* () { | 152 | yield* Effect.gen(function* () { |
| 128 | const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; | 153 | const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; |
| 129 | const outputLog = yield* factory.forInstance("primary"); | 154 | const outputLog = yield* factory.forInstance("primary"); |
| 130 | yield* outputLog.writeSessionBoundary({ | 155 | yield* outputLog.beginSession({ |
| 131 | phase: "START", | ||
| 132 | details: "pid=123 port=3773 cwd=/repo", | 156 | details: "pid=123 port=3773 cwd=/repo", |
| 133 | }); | 157 | }); |
| 134 | yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); | 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 | }).pipe( | 164 | }).pipe( |
| 136 | Effect.annotateLogs({ runId: "test-run" }), | 165 | Effect.annotateLogs({ runId: "test-run" }), |
| 137 | Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), | 166 | Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), |
| ... | @@ -139,16 +168,18 @@ describe("DesktopObservability", () => { | ... | @@ -139,16 +168,18 @@ describe("DesktopObservability", () => { |
| 139 | 168 | ||
| 140 | const log = yield* fileSystem.readFileString(logPath); | 169 | const log = yield* fileSystem.readFileString(logPath); |
| 141 | const lines = log.trimEnd().split("\n"); | 170 | const lines = log.trimEnd().split("\n"); |
| 142 | const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); | 171 | const start = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); |
| 143 | const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); | 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"); | 175 | assert.equal(lines.length, 3); |
| 146 | assert.equal(boundary.level, "INFO"); | 176 | assert.equal(start.message, "backend child process failure output start"); |
| 147 | assert.equal(boundary.annotations.component, "desktop-backend-child"); | 177 | assert.equal(start.level, "ERROR"); |
| 148 | assert.equal(boundary.annotations.runId, "test-run"); | 178 | assert.equal(start.annotations.component, "desktop-backend-child"); |
| 149 | assert.equal(boundary.annotations.instanceId, "primary"); | 179 | assert.equal(start.annotations.runId, "test-run"); |
| 150 | assert.equal(boundary.annotations.phase, "START"); | 180 | assert.equal(start.annotations.instanceId, "primary"); |
| 151 | assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); | 181 | assert.equal(start.annotations.phase, "START"); |
| 182 | assert.equal(start.annotations.details, "pid=123 port=3773 cwd=/repo"); | ||
| 152 | 183 | ||
| 153 | assert.equal(output.message, "backend child process output"); | 184 | assert.equal(output.message, "backend child process output"); |
| 154 | assert.equal(output.level, "INFO"); | 185 | assert.equal(output.level, "INFO"); |
| ... | @@ -157,6 +188,141 @@ describe("DesktopObservability", () => { | ... | @@ -157,6 +188,141 @@ describe("DesktopObservability", () => { |
| 157 | assert.equal(output.annotations.instanceId, "primary"); | 188 | assert.equal(output.annotations.instanceId, "primary"); |
| 158 | assert.equal(output.annotations.stream, "stdout"); | 189 | assert.equal(output.annotations.stream, "stdout"); |
| 159 | assert.equal(output.annotations.text, "hello server\n"); | 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 | }).pipe( | 326 | }).pipe( |
| 161 | Effect.scoped, | 327 | Effect.scoped, |
| 162 | Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), | 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,7 +24,9 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 24 | const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | 24 | const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; |
| 25 | const DESKTOP_LOG_FILE_MAX_FILES = 10; | 25 | const DESKTOP_LOG_FILE_MAX_FILES = 10; |
| 26 | const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; | 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 | export interface RotatingLogFileWriter { | 31 | export interface RotatingLogFileWriter { |
| 30 | readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; | 32 | readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; |
| ... | @@ -32,14 +34,14 @@ export interface RotatingLogFileWriter { | ... | @@ -32,14 +34,14 @@ export interface RotatingLogFileWriter { |
| 32 | } | 34 | } |
| 33 | 35 | ||
| 34 | export interface DesktopBackendOutputLogShape { | 36 | export interface DesktopBackendOutputLogShape { |
| 35 | readonly writeSessionBoundary: (input: { | 37 | readonly beginSession: (input: { readonly details: string }) => Effect.Effect<void>; |
| 36 | readonly phase: "START" | "END"; | ||
| 37 | readonly details: string; | ||
| 38 | }) => Effect.Effect<void>; | ||
| 39 | readonly writeOutputChunk: ( | 38 | readonly writeOutputChunk: ( |
| 40 | streamName: "stdout" | "stderr", | 39 | streamName: "stdout" | "stderr", |
| 41 | chunk: Uint8Array, | 40 | chunk: Uint8Array, |
| 42 | ) => Effect.Effect<void>; | 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 | // Factory for per-instance backend output logs. `forInstance(id)` returns | 47 | // Factory for per-instance backend output logs. `forInstance(id)` returns |
| ... | @@ -127,10 +129,80 @@ const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( | ... | @@ -127,10 +129,80 @@ const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( |
| 127 | ); | 129 | ); |
| 128 | 130 | ||
| 129 | const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { | 131 | const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { |
| 130 | writeSessionBoundary: () => Effect.void, | 132 | beginSession: () => Effect.void, |
| 131 | writeOutputChunk: () => Effect.void, | 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 | const currentDesktopRunId = Effect.gen(function* () { | 206 | const currentDesktopRunId = Effect.gen(function* () { |
| 135 | const annotations = yield* References.CurrentLogAnnotations; | 207 | const annotations = yield* References.CurrentLogAnnotations; |
| 136 | const runId = annotations.runId; | 208 | const runId = annotations.runId; |
| ... | @@ -345,47 +417,93 @@ const makeBackendOutputLogShape = ( | ... | @@ -345,47 +417,93 @@ const makeBackendOutputLogShape = ( |
| 345 | environment: DesktopEnvironment.DesktopEnvironment["Service"], | 417 | environment: DesktopEnvironment.DesktopEnvironment["Service"], |
| 346 | id: string, | 418 | id: string, |
| 347 | sink: Option.Option<RotatingLogFileWriter>, | 419 | sink: Option.Option<RotatingLogFileWriter>, |
| 348 | ): DesktopBackendOutputLogShape => | 420 | ): Effect.Effect<DesktopBackendOutputLogShape> => |
| 349 | Option.match(sink, { | 421 | Option.match(sink, { |
| 350 | onNone: () => DesktopBackendOutputLogNoop, | 422 | onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), |
| 351 | onSome: (logFile) => | 423 | onSome: (logFile) => |
| 352 | ({ | 424 | Effect.gen(function* () { |
| 353 | writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( | 425 | const sessionRef = yield* Ref.make(Option.none<BackendOutputSession>()); |
| 354 | function* ({ phase, details }) { | 426 | const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( |
| 355 | const runId = yield* currentDesktopRunId; | 427 | function* (session: BackendOutputSession, details: string) { |
| 356 | yield* writeBackendChildLogRecord(logFile, { | 428 | yield* writeBackendChildLogRecord(logFile, { |
| 357 | message: `backend child process session ${phase.toLowerCase()}`, | 429 | message: "backend child process failure output start", |
| 358 | level: "INFO", | 430 | level: "ERROR", |
| 359 | annotations: { | 431 | annotations: { |
| 360 | component: "desktop-backend-child", | 432 | component: "desktop-backend-child", |
| 361 | runId, | 433 | runId: session.runId, |
| 362 | instanceId: id, | 434 | instanceId: id, |
| 363 | phase, | 435 | phase: "START", |
| 364 | details: sanitizeLogValue(details), | 436 | details: session.startDetails, |
| 365 | }, | 437 | }, |
| 366 | }); | 438 | }); |
| 367 | }, | 439 | for (const output of session.chunks) { |
| 368 | ), | 440 | yield* writeBackendChildLogRecord(logFile, { |
| 369 | writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( | 441 | message: "backend child process output", |
| 370 | function* (streamName, chunk) { | 442 | level: output.streamName === "stderr" ? "ERROR" : "INFO", |
| 371 | if (environment.isDevelopment) { | 443 | annotations: { |
| 372 | yield* writeDevelopmentConsoleOutput(streamName, chunk); | 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 | yield* writeBackendChildLogRecord(logFile, { | 452 | yield* writeBackendChildLogRecord(logFile, { |
| 376 | message: "backend child process output", | 453 | message: "backend child process failure output end", |
| 377 | level: streamName === "stderr" ? "ERROR" : "INFO", | 454 | level: "ERROR", |
| 378 | annotations: { | 455 | annotations: { |
| 379 | component: "desktop-backend-child", | 456 | component: "desktop-backend-child", |
| 380 | runId, | 457 | runId: session.runId, |
| 381 | instanceId: id, | 458 | instanceId: id, |
| 382 | stream: streamName, | 459 | phase: "END", |
| 383 | text: textDecoder.decode(chunk), | 460 | details: sanitizeLogValue(details), |
| 384 | }, | 461 | }, |
| 385 | }); | 462 | }); |
| 386 | }, | 463 | }, |
| 387 | ), | 464 | ); |
| 388 | }) satisfies DesktopBackendOutputLogShape, | 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 | const backendOutputLogFactoryLayer = Layer.effect( | 509 | const backendOutputLogFactoryLayer = Layer.effect( |
| ... | @@ -412,10 +530,9 @@ const backendOutputLogFactoryLayer = Layer.effect( | ... | @@ -412,10 +530,9 @@ const backendOutputLogFactoryLayer = Layer.effect( |
| 412 | const cacheKey = backendLogFilePathForInstance(environment, id); | 530 | const cacheKey = backendLogFilePathForInstance(environment, id); |
| 413 | const cached = cache.get(cacheKey); | 531 | const cached = cache.get(cacheKey); |
| 414 | if (cached !== undefined) { | 532 | if (cached !== undefined) { |
| 415 | return Effect.succeed([ | 533 | return makeBackendOutputLogShape(environment, id, cached).pipe( |
| 416 | makeBackendOutputLogShape(environment, id, cached), | 534 | Effect.map((outputLog) => [outputLog, cache] as const), |
| 417 | cache, | 535 | ); |
| 418 | ] as const); | ||
| 419 | } | 536 | } |
| 420 | return makeBackendOutputSinkForInstance(environment, id).pipe( | 537 | return makeBackendOutputSinkForInstance(environment, id).pipe( |
| 421 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 538 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| ... | @@ -424,11 +541,19 @@ const backendOutputLogFactoryLayer = Layer.effect( | ... | @@ -424,11 +541,19 @@ const backendOutputLogFactoryLayer = Layer.effect( |
| 424 | Effect.map((sink) => { | 541 | Effect.map((sink) => { |
| 425 | const next = new Map(cache); | 542 | const next = new Map(cache); |
| 426 | next.set(cacheKey, sink); | 543 | next.set(cacheKey, sink); |
| 427 | return [ | 544 | return { sink, next }; |
| 428 | makeBackendOutputLogShape(environment, id, sink), | ||
| 429 | next as ReadonlyMap<string, Option.Option<RotatingLogFileWriter>>, | ||
| 430 | ] as const; | ||
| 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,6 +52,7 @@ function makeEnvironmentLayer( |
| 52 | baseDir: string, | 52 | baseDir: string, |
| 53 | options?: { | 53 | options?: { |
| 54 | readonly appPath?: string; | 54 | readonly appPath?: string; |
| 55 | readonly dirname?: string; | ||
| 55 | readonly isPackaged?: boolean; | 56 | readonly isPackaged?: boolean; |
| 56 | readonly devServerUrl?: string; | 57 | readonly devServerUrl?: string; |
| 57 | readonly platform?: NodeJS.Platform; | 58 | readonly platform?: NodeJS.Platform; |
| ... | @@ -59,7 +60,7 @@ function makeEnvironmentLayer( | ... | @@ -59,7 +60,7 @@ function makeEnvironmentLayer( |
| 59 | }, | 60 | }, |
| 60 | ) { | 61 | ) { |
| 61 | return DesktopEnvironment.layer({ | 62 | return DesktopEnvironment.layer({ |
| 62 | dirname: "/repo/apps/desktop/src", | 63 | dirname: options?.dirname ?? "/repo/apps/desktop/src", |
| 63 | homeDirectory: baseDir, | 64 | homeDirectory: baseDir, |
| 64 | platform: options?.platform ?? "darwin", | 65 | platform: options?.platform ?? "darwin", |
| 65 | processArch: "x64", | 66 | processArch: "x64", |
| ... | @@ -464,6 +465,8 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -464,6 +465,8 @@ describe("DesktopBackendConfiguration", () => { |
| 464 | // both wslhost-forwarded localhost and the distro's eth0 IP. | 465 | // both wslhost-forwarded localhost and the distro's eth0 IP. |
| 465 | assert.equal(config.bootstrap.host, "0.0.0.0"); | 466 | assert.equal(config.bootstrap.host, "0.0.0.0"); |
| 466 | assert.equal(config.bootstrap.tailscaleServeEnabled, false); | 467 | assert.equal(config.bootstrap.tailscaleServeEnabled, false); |
| 468 | assert.notProperty(config.bootstrap, "desktopTelemetryFd"); | ||
| 469 | assert.notProperty(config.bootstrap, "resourceMonitorPath"); | ||
| 467 | // httpBaseUrl uses the resolved distro IP from the test stub, | 470 | // httpBaseUrl uses the resolved distro IP from the test stub, |
| 468 | // not localhost — the renderer reaches the backend directly to | 471 | // not localhost — the renderer reaches the backend directly to |
| 469 | // avoid relying on wslhost forwarding. | 472 | // avoid relying on wslhost forwarding. |
| ... | @@ -713,6 +716,97 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -713,6 +716,97 @@ describe("DesktopBackendConfiguration", () => { |
| 713 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 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 | it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => | 810 | it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => |
| 717 | withHarness( | 811 | withHarness( |
| 718 | Effect.gen(function* () { | 812 | Effect.gen(function* () { |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+56-2| ... | @@ -141,6 +141,44 @@ const logBackendObservabilitySettingsReadFailure = ( | ... | @@ -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 | const readPersistedBackendObservabilitySettings = Effect.gen(function* () { | 182 | const readPersistedBackendObservabilitySettings = Effect.gen(function* () { |
| 145 | const fileSystem = yield* FileSystem.FileSystem; | 183 | const fileSystem = yield* FileSystem.FileSystem; |
| 146 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 184 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| ... | @@ -326,7 +364,9 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS | ... | @@ -326,7 +364,9 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS |
| 326 | 364 | ||
| 327 | const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( | 365 | const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( |
| 328 | function* ( | 366 | function* ( |
| 329 | input: SharedBootstrapInput, | 367 | input: SharedBootstrapInput & { |
| 368 | readonly resourceMonitorPath: Option.Option<string>; | ||
| 369 | }, | ||
| 330 | ): Effect.fn.Return< | 370 | ): Effect.fn.Return< |
| 331 | DesktopBackendManager.DesktopBackendStartConfig, | 371 | DesktopBackendManager.DesktopBackendStartConfig, |
| 332 | never, | 372 | never, |
| ... | @@ -345,6 +385,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv | ... | @@ -345,6 +385,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 345 | desktopBootstrapToken: input.bootstrapToken, | 385 | desktopBootstrapToken: input.bootstrapToken, |
| 346 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, | 386 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, |
| 347 | tailscaleServePort: backendExposure.tailscaleServePort, | 387 | tailscaleServePort: backendExposure.tailscaleServePort, |
| 388 | desktopTelemetryFd: 4, | ||
| 389 | desktopTelemetryControlFd: 5, | ||
| 390 | ...Option.match(input.resourceMonitorPath, { | ||
| 391 | onNone: () => ({}), | ||
| 392 | onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), | ||
| 393 | }), | ||
| 348 | ...buildObservabilityFragment(input.observabilitySettings), | 394 | ...buildObservabilityFragment(input.observabilitySettings), |
| 349 | }; | 395 | }; |
| 350 | 396 | ||
| ... | @@ -411,6 +457,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl | ... | @@ -411,6 +457,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 411 | // inert. | 457 | // inert. |
| 412 | tailscaleServeEnabled: false, | 458 | tailscaleServeEnabled: false, |
| 413 | tailscaleServePort: 443, | 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 | ...buildObservabilityFragment(input.observabilitySettings), | 464 | ...buildObservabilityFragment(input.observabilitySettings), |
| 415 | }; | 465 | }; |
| 416 | 466 | ||
| ... | @@ -621,7 +671,11 @@ export const make = Effect.gen(function* () { | ... | @@ -621,7 +671,11 @@ export const make = Effect.gen(function* () { |
| 621 | 671 | ||
| 622 | const buildWindowsPrimaryConfig = Effect.gen(function* () { | 672 | const buildWindowsPrimaryConfig = Effect.gen(function* () { |
| 623 | const shared = yield* sharedInputs; | 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 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | 679 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 626 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | 680 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), |
| 627 | ); | 681 | ); |
apps/desktop/src/backend/DesktopBackendManager.test.ts+700-16| ... | @@ -1,14 +1,17 @@ | ... | @@ -1,14 +1,17 @@ |
| 1 | import { | 1 | import { |
| 2 | DesktopBackendBootstrap, | 2 | DesktopBackendBootstrap, |
| 3 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, | 3 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 4 | DesktopTelemetryControlMessage, | ||
| 4 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 5 | import { assert, describe, it } from "@effect/vitest"; | 6 | import { assert, describe, it } from "@effect/vitest"; |
| 6 | import * as Deferred from "effect/Deferred"; | 7 | import * as Deferred from "effect/Deferred"; |
| 7 | import * as Duration from "effect/Duration"; | 8 | import * as Duration from "effect/Duration"; |
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 10 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 11 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 14 | import * as PlatformError from "effect/PlatformError"; | ||
| 12 | import * as Queue from "effect/Queue"; | 15 | import * as Queue from "effect/Queue"; |
| 13 | import * as Ref from "effect/Ref"; | 16 | import * as Ref from "effect/Ref"; |
| 14 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| ... | @@ -21,10 +24,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -21,10 +24,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 21 | 24 | ||
| 22 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | 25 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 23 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 26 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 27 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | ||
| 24 | 28 | ||
| 25 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( | 29 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( |
| 26 | Schema.fromJsonString(DesktopBackendBootstrap), | 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 | const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { | 37 | const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 30 | executablePath: "/electron", | 38 | executablePath: "/electron", |
| ... | @@ -41,6 +49,8 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { | ... | @@ -41,6 +49,8 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 41 | desktopBootstrapToken: "token", | 49 | desktopBootstrapToken: "token", |
| 42 | tailscaleServeEnabled: false, | 50 | tailscaleServeEnabled: false, |
| 43 | tailscaleServePort: 443, | 51 | tailscaleServePort: 443, |
| 52 | desktopTelemetryFd: 4, | ||
| 53 | desktopTelemetryControlFd: 5, | ||
| 44 | }, | 54 | }, |
| 45 | bootstrapDelivery: "fd3", | 55 | bootstrapDelivery: "fd3", |
| 46 | extendEnv: true, | 56 | extendEnv: true, |
| ... | @@ -52,14 +62,16 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { | ... | @@ -52,14 +62,16 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 52 | const configWithObservability: DesktopBackendBootstrapValue = { | 62 | const configWithObservability: DesktopBackendBootstrapValue = { |
| 53 | ...baseConfig.bootstrap, | 63 | ...baseConfig.bootstrap, |
| 54 | tailscaleServeEnabled: true, | 64 | tailscaleServeEnabled: true, |
| 65 | desktopTelemetryFd: 4, | ||
| 55 | otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", | 66 | otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", |
| 56 | }; | 67 | }; |
| 57 | 68 | ||
| 58 | function makeProcess(options?: { | 69 | function makeProcess(options?: { |
| 59 | readonly stdout?: Stream.Stream<Uint8Array>; | 70 | readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; |
| 60 | readonly stderr?: Stream.Stream<Uint8Array>; | 71 | readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; |
| 61 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | 72 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; |
| 62 | readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; | 73 | readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; |
| 74 | readonly getOutputFd?: ChildProcessSpawner.ChildProcessHandle["getOutputFd"]; | ||
| 63 | }): ChildProcessSpawner.ChildProcessHandle { | 75 | }): ChildProcessSpawner.ChildProcessHandle { |
| 64 | return ChildProcessSpawner.makeHandle({ | 76 | return ChildProcessSpawner.makeHandle({ |
| 65 | pid: ChildProcessSpawner.ProcessId(123), | 77 | pid: ChildProcessSpawner.ProcessId(123), |
| ... | @@ -71,7 +83,7 @@ function makeProcess(options?: { | ... | @@ -71,7 +83,7 @@ function makeProcess(options?: { |
| 71 | kill: options?.kill ?? (() => Effect.void), | 83 | kill: options?.kill ?? (() => Effect.void), |
| 72 | stdin: Sink.drain, | 84 | stdin: Sink.drain, |
| 73 | getInputFd: () => Sink.drain, | 85 | getInputFd: () => Sink.drain, |
| 74 | getOutputFd: () => Stream.empty, | 86 | getOutputFd: options?.getOutputFd ?? (() => Stream.empty), |
| 75 | unref: Effect.succeed(Effect.void), | 87 | unref: Effect.succeed(Effect.void), |
| 76 | }); | 88 | }); |
| 77 | } | 89 | } |
| ... | @@ -112,7 +124,14 @@ interface MakeInstanceInput { | ... | @@ -112,7 +124,14 @@ interface MakeInstanceInput { |
| 112 | failure: DesktopBackendManager.PreflightFailure, | 124 | failure: DesktopBackendManager.PreflightFailure, |
| 113 | ) => Effect.Effect<boolean>; | 125 | ) => Effect.Effect<boolean>; |
| 114 | readonly config?: DesktopBackendManager.DesktopBackendStartConfig; | 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 | // Helper that constructs a primary backend instance using the factory | 137 | // Helper that constructs a primary backend instance using the factory |
| ... | @@ -122,8 +141,11 @@ interface MakeInstanceInput { | ... | @@ -122,8 +141,11 @@ interface MakeInstanceInput { |
| 122 | // to drive the instance's lifecycle. | 141 | // to drive the instance's lifecycle. |
| 123 | function makeTestInstance(input: MakeInstanceInput) { | 142 | function makeTestInstance(input: MakeInstanceInput) { |
| 124 | const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { | 143 | const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { |
| 125 | writeSessionBoundary: () => Effect.void, | 144 | beginSession: () => Effect.void, |
| 126 | writeOutputChunk: () => Effect.void, | 145 | writeOutputChunk: () => Effect.void, |
| 146 | persistFailureSnapshot: () => Effect.void, | ||
| 147 | persistFailure: () => Effect.void, | ||
| 148 | discardSession: Effect.void, | ||
| 127 | ...input.backendOutputLog, | 149 | ...input.backendOutputLog, |
| 128 | }; | 150 | }; |
| 129 | const servicesLayer = Layer.mergeAll( | 151 | const servicesLayer = Layer.mergeAll( |
| ... | @@ -135,6 +157,16 @@ function makeTestInstance(input: MakeInstanceInput) { | ... | @@ -135,6 +157,16 @@ function makeTestInstance(input: MakeInstanceInput) { |
| 135 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { | 157 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { |
| 136 | forInstance: () => Effect.succeed(stubLog), | 158 | forInstance: () => Effect.succeed(stubLog), |
| 137 | } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), | 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 | const instance = DesktopBackendManager.makeBackendInstance({ | 172 | const instance = DesktopBackendManager.makeBackendInstance({ |
| ... | @@ -150,11 +182,12 @@ function makeTestInstance(input: MakeInstanceInput) { | ... | @@ -150,11 +182,12 @@ function makeTestInstance(input: MakeInstanceInput) { |
| 150 | } | 182 | } |
| 151 | 183 | ||
| 152 | describe("DesktopBackendManager", () => { | 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 | Effect.scoped( | 186 | Effect.scoped( |
| 155 | Effect.gen(function* () { | 187 | Effect.gen(function* () { |
| 156 | let spawnedCommand: ChildProcess.Command | undefined; | 188 | let spawnedCommand: ChildProcess.Command | undefined; |
| 157 | let bootstrapJson = ""; | 189 | let bootstrapJson = ""; |
| 190 | let telemetryJson = ""; | ||
| 158 | let readyCount = 0; | 191 | let readyCount = 0; |
| 159 | const ready = yield* Deferred.make<void>(); | 192 | const ready = yield* Deferred.make<void>(); |
| 160 | const exited = yield* Queue.unbounded<void>(); | 193 | const exited = yield* Queue.unbounded<void>(); |
| ... | @@ -169,6 +202,10 @@ describe("DesktopBackendManager", () => { | ... | @@ -169,6 +202,10 @@ describe("DesktopBackendManager", () => { |
| 169 | if (fd3?.type === "input" && fd3.stream) { | 202 | if (fd3?.type === "input" && fd3.stream) { |
| 170 | bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); | 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 | return makeProcess({ | 211 | return makeProcess({ |
| ... | @@ -184,12 +221,14 @@ describe("DesktopBackendManager", () => { | ... | @@ -184,12 +221,14 @@ describe("DesktopBackendManager", () => { |
| 184 | bootstrap: configWithObservability, | 221 | bootstrap: configWithObservability, |
| 185 | }, | 222 | }, |
| 186 | spawnerLayer, | 223 | spawnerLayer, |
| 224 | desktopTelemetryStream: Stream.encodeText( | ||
| 225 | Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), | ||
| 226 | ), | ||
| 187 | onReady: Effect.sync(() => { | 227 | onReady: Effect.sync(() => { |
| 188 | readyCount += 1; | 228 | readyCount += 1; |
| 189 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), | 229 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), |
| 190 | backendOutputLog: { | 230 | backendOutputLog: { |
| 191 | writeSessionBoundary: ({ phase }) => | 231 | persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), |
| 192 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | ||
| 193 | }, | 232 | }, |
| 194 | }); | 233 | }); |
| 195 | 234 | ||
| ... | @@ -210,16 +249,400 @@ describe("DesktopBackendManager", () => { | ... | @@ -210,16 +249,400 @@ describe("DesktopBackendManager", () => { |
| 210 | assert.equal(spawnedCommand.options.stderr, "pipe"); | 249 | assert.equal(spawnedCommand.options.stderr, "pipe"); |
| 211 | assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); | 250 | assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); |
| 212 | assert.isDefined(spawnedCommand.options.forceKillAfter); | 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 | assert.equal( | 254 | assert.equal( |
| 214 | Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), | 255 | Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), |
| 215 | 2_000, | 256 | 2_000, |
| 216 | ); | 257 | ); |
| 217 | 258 | ||
| 218 | assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); | 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 | it.effect("retries HTTP readiness before reporting the backend ready", () => | 646 | it.effect("retries HTTP readiness before reporting the backend ready", () => |
| 224 | Effect.scoped( | 647 | Effect.scoped( |
| 225 | Effect.gen(function* () { | 648 | Effect.gen(function* () { |
| ... | @@ -256,8 +679,7 @@ describe("DesktopBackendManager", () => { | ... | @@ -256,8 +679,7 @@ describe("DesktopBackendManager", () => { |
| 256 | readyCount += 1; | 679 | readyCount += 1; |
| 257 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), | 680 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), |
| 258 | backendOutputLog: { | 681 | backendOutputLog: { |
| 259 | writeSessionBoundary: ({ phase }) => | 682 | persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), |
| 260 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | ||
| 261 | }, | 683 | }, |
| 262 | }); | 684 | }); |
| 263 | 685 | ||
| ... | @@ -285,9 +707,15 @@ describe("DesktopBackendManager", () => { | ... | @@ -285,9 +707,15 @@ describe("DesktopBackendManager", () => { |
| 285 | let startCount = 0; | 707 | let startCount = 0; |
| 286 | let closedCount = 0; | 708 | let closedCount = 0; |
| 287 | const closed = yield* Deferred.make<void>(); | 709 | const closed = yield* Deferred.make<void>(); |
| 710 | const teardownStarted = yield* Deferred.make<void>(); | ||
| 711 | const finishTeardown = yield* Deferred.make<void>(); | ||
| 288 | const startedPids = yield* Queue.unbounded<number>(); | 712 | const startedPids = yield* Queue.unbounded<number>(); |
| 289 | const ready = yield* Deferred.make<void>(); | 713 | const ready = yield* Deferred.make<void>(); |
| 290 | const backendReadyFlag = yield* Ref.make(false); | 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 | const spawnerLayer = Layer.succeed( | 720 | const spawnerLayer = Layer.succeed( |
| 293 | ChildProcessSpawner.ChildProcessSpawner, | 721 | ChildProcessSpawner.ChildProcessSpawner, |
| ... | @@ -296,9 +724,16 @@ describe("DesktopBackendManager", () => { | ... | @@ -296,9 +724,16 @@ describe("DesktopBackendManager", () => { |
| 296 | const scope = yield* Scope.Scope; | 724 | const scope = yield* Scope.Scope; |
| 297 | startCount += 1; | 725 | startCount += 1; |
| 298 | yield* Queue.offer(startedPids, 123); | 726 | yield* Queue.offer(startedPids, 123); |
| 299 | const close = Effect.sync(() => { | 727 | const close = Deferred.succeed(teardownStarted, undefined).pipe( |
| 300 | closedCount += 1; | 728 | Effect.andThen(Deferred.await(finishTeardown)), |
| 301 | }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); | 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 | yield* Scope.addFinalizer(scope, close); | 738 | yield* Scope.addFinalizer(scope, close); |
| 304 | 739 | ||
| ... | @@ -316,7 +751,28 @@ describe("DesktopBackendManager", () => { | ... | @@ -316,7 +751,28 @@ describe("DesktopBackendManager", () => { |
| 316 | Effect.andThen(Deferred.succeed(ready, void 0)), | 751 | Effect.andThen(Deferred.succeed(ready, void 0)), |
| 317 | Effect.asVoid, | 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 | assert.isTrue(Option.isNone(yield* instance.currentConfig)); | 777 | assert.isTrue(Option.isNone(yield* instance.currentConfig)); |
| 322 | 778 | ||
| ... | @@ -330,12 +786,21 @@ describe("DesktopBackendManager", () => { | ... | @@ -330,12 +786,21 @@ describe("DesktopBackendManager", () => { |
| 330 | assert.equal(runningSnapshot.ready, true); | 786 | assert.equal(runningSnapshot.ready, true); |
| 331 | assert.deepEqual(runningSnapshot.activePid, Option.some(123)); | 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 | assert.equal(startCount, 1); | 795 | assert.equal(startCount, 1); |
| 335 | assert.equal(closedCount, 1); | 796 | assert.equal(closedCount, 1); |
| 797 | assert.equal(persistedFailureCount, 0); | ||
| 798 | assert.equal(discardedSessionCount, 1); | ||
| 799 | assert.equal(removedTelemetrySources, 1); | ||
| 336 | 800 | ||
| 337 | const stoppedSnapshot = yield* instance.snapshot; | 801 | const stoppedSnapshot = yield* instance.snapshot; |
| 338 | assert.isFalse(yield* Ref.get(backendReadyFlag)); | 802 | assert.isFalse(yield* Ref.get(backendReadyFlag)); |
| 803 | assert.equal(shutdownCount, 1); | ||
| 339 | assert.equal(stoppedSnapshot.desiredRunning, false); | 804 | assert.equal(stoppedSnapshot.desiredRunning, false); |
| 340 | assert.equal(stoppedSnapshot.ready, false); | 805 | assert.equal(stoppedSnapshot.ready, false); |
| 341 | assert.equal(Option.isNone(stoppedSnapshot.activePid), true); | 806 | assert.equal(Option.isNone(stoppedSnapshot.activePid), true); |
| ... | @@ -343,6 +808,213 @@ describe("DesktopBackendManager", () => { | ... | @@ -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 | it.effect("does not notify shutdown before the first start has prior state", () => | 1018 | it.effect("does not notify shutdown before the first start has prior state", () => |
| 347 | Effect.scoped( | 1019 | Effect.scoped( |
| 348 | Effect.gen(function* () { | 1020 | Effect.gen(function* () { |
| ... | @@ -383,6 +1055,7 @@ describe("DesktopBackendManager", () => { | ... | @@ -383,6 +1055,7 @@ describe("DesktopBackendManager", () => { |
| 383 | Effect.scoped( | 1055 | Effect.scoped( |
| 384 | Effect.gen(function* () { | 1056 | Effect.gen(function* () { |
| 385 | const starts = yield* Queue.unbounded<number>(); | 1057 | const starts = yield* Queue.unbounded<number>(); |
| 1058 | const failures = yield* Queue.unbounded<string>(); | ||
| 386 | let startCount = 0; | 1059 | let startCount = 0; |
| 387 | 1060 | ||
| 388 | const spawnerLayer = Layer.succeed( | 1061 | const spawnerLayer = Layer.succeed( |
| ... | @@ -402,11 +1075,15 @@ describe("DesktopBackendManager", () => { | ... | @@ -402,11 +1075,15 @@ describe("DesktopBackendManager", () => { |
| 402 | const instance = yield* makeTestInstance({ | 1075 | const instance = yield* makeTestInstance({ |
| 403 | spawnerLayer, | 1076 | spawnerLayer, |
| 404 | httpClientLayer: httpClientLayer(() => Effect.never), | 1077 | httpClientLayer: httpClientLayer(() => Effect.never), |
| 1078 | backendOutputLog: { | ||
| 1079 | persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid), | ||
| 1080 | }, | ||
| 405 | }); | 1081 | }); |
| 406 | 1082 | ||
| 407 | yield* instance.start; | 1083 | yield* instance.start; |
| 408 | 1084 | ||
| 409 | assert.equal(yield* Queue.take(starts), 1); | 1085 | assert.equal(yield* Queue.take(starts), 1); |
| 1086 | assert.equal(yield* Queue.take(failures), "pid=123 code=1"); | ||
| 410 | 1087 | ||
| 411 | yield* TestClock.adjust(Duration.millis(499)); | 1088 | yield* TestClock.adjust(Duration.millis(499)); |
| 412 | assert.equal(yield* Queue.size(starts), 0); | 1089 | assert.equal(yield* Queue.size(starts), 0); |
| ... | @@ -657,6 +1334,13 @@ describe("DesktopBackendManager", () => { | ... | @@ -657,6 +1334,13 @@ describe("DesktopBackendManager", () => { |
| 657 | yield* instance.start; | 1334 | yield* instance.start; |
| 658 | 1335 | ||
| 659 | assert.equal(yield* Queue.take(starts), 1); | 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 | yield* instance.start; | 1345 | yield* instance.start; |
| 662 | assert.equal(yield* Queue.take(starts), 2); | 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,7 +33,6 @@ import * as FileSystem from "effect/FileSystem"; |
| 33 | import * as Option from "effect/Option"; | 33 | import * as Option from "effect/Option"; |
| 34 | import * as PlatformError from "effect/PlatformError"; | 34 | import * as PlatformError from "effect/PlatformError"; |
| 35 | import * as Ref from "effect/Ref"; | 35 | import * as Ref from "effect/Ref"; |
| 36 | import * as Result from "effect/Result"; | ||
| 37 | import * as Schedule from "effect/Schedule"; | 36 | import * as Schedule from "effect/Schedule"; |
| 38 | import * as Schema from "effect/Schema"; | 37 | import * as Schema from "effect/Schema"; |
| 39 | import * as Semaphore from "effect/Semaphore"; | 38 | import * as Semaphore from "effect/Semaphore"; |
| ... | @@ -46,10 +45,13 @@ import { | ... | @@ -46,10 +45,13 @@ import { |
| 46 | DesktopBackendBootstrap, | 45 | DesktopBackendBootstrap, |
| 47 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, | 46 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 48 | PRIMARY_LOCAL_ENVIRONMENT_ID, | 47 | PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 48 | DesktopTelemetryControlMessage, | ||
| 49 | type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue, | ||
| 49 | } from "@t3tools/contracts"; | 50 | } from "@t3tools/contracts"; |
| 50 | import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; | 51 | import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; |
| 51 | 52 | ||
| 52 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 53 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 54 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | ||
| 53 | 55 | ||
| 54 | const INITIAL_RESTART_DELAY = Duration.millis(500); | 56 | const INITIAL_RESTART_DELAY = Duration.millis(500); |
| 55 | const MAX_RESTART_DELAY = Duration.seconds(10); | 57 | const MAX_RESTART_DELAY = Duration.seconds(10); |
| ... | @@ -62,7 +64,10 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); | ... | @@ -62,7 +64,10 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); |
| 62 | const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); | 64 | const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); |
| 63 | const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); | 65 | const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); |
| 64 | const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); | 66 | const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); |
| 67 | const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); | ||
| 65 | const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; | 68 | const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; |
| 69 | const { logWarning: logBackendProcessWarning } = | ||
| 70 | DesktopObservability.makeComponentLogger("desktop-backend-process"); | ||
| 66 | 71 | ||
| 67 | type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; | 72 | type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; |
| 68 | 73 | ||
| ... | @@ -70,13 +75,17 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; | ... | @@ -70,13 +75,17 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; |
| 70 | 75 | ||
| 71 | export type BackendProcessOutputStream = "stdout" | "stderr"; | 76 | export type BackendProcessOutputStream = "stdout" | "stderr"; |
| 72 | 77 | ||
| 73 | export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; | 78 | export interface BackendProcessContext { |
| 74 | |||
| 75 | export interface DesktopBackendStartConfig { | ||
| 76 | readonly executablePath: string; | 79 | readonly executablePath: string; |
| 77 | readonly args: ReadonlyArray<string>; | ||
| 78 | readonly entryPath: string; | 80 | readonly entryPath: string; |
| 79 | readonly cwd: string; | 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 | readonly env: Record<string, string | undefined>; | 89 | readonly env: Record<string, string | undefined>; |
| 81 | // When true the spawner merges the desktop process.env on top of `env`; | 90 | // When true the spawner merges the desktop process.env on top of `env`; |
| 82 | // when false `env` is passed verbatim. WSL mode opts out so a leaking | 91 | // when false `env` is passed verbatim. WSL mode opts out so a leaking |
| ... | @@ -106,55 +115,122 @@ export interface PreflightFailure { | ... | @@ -106,55 +115,122 @@ export interface PreflightFailure { |
| 106 | interface BackendProcessExit { | 115 | interface BackendProcessExit { |
| 107 | readonly code: Option.Option<number>; | 116 | readonly code: Option.Option<number>; |
| 108 | readonly reason: string; | 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>()( | 120 | const backendProcessContextSchema = { |
| 113 | "BackendTimeoutError", | 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() { | 136 | override get message(): string { |
| 119 | return `Timed out waiting for backend readiness at ${this.url.href}.`; | 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 | "BackendProcessBootstrapEncodeError", | 142 | "BackendProcessBootstrapEncodeError", |
| 125 | { | 143 | { |
| 126 | entryPath: Schema.String, | 144 | ...backendProcessContextSchema, |
| 127 | cause: Schema.Defect(), | 145 | cause: Schema.Defect(), |
| 128 | }, | 146 | }, |
| 129 | ) { | 147 | ) { |
| 130 | override get message() { | 148 | override get message(): string { |
| 131 | return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; | 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 | "BackendProcessSpawnError", | 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 | cause: Schema.Defect(), | 203 | cause: Schema.Defect(), |
| 140 | }, | 204 | }, |
| 141 | ) { | 205 | ) { |
| 142 | override get message() { | 206 | override get message(): string { |
| 143 | return `Failed to spawn the desktop backend process at ${this.executablePath}.`; | 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 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { | 218 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { |
| 219 | readonly desktopTelemetryStream: Stream.Stream<Uint8Array>; | ||
| 220 | readonly onDesktopTelemetryControl?: ( | ||
| 221 | message: DesktopTelemetryControlMessageValue, | ||
| 222 | ) => Effect.Effect<void>; | ||
| 150 | readonly readinessTimeout?: Duration.Duration; | 223 | readonly readinessTimeout?: Duration.Duration; |
| 224 | readonly outputDrainTimeout?: Duration.Duration; | ||
| 151 | readonly onStarted?: (pid: number) => Effect.Effect<void>; | 225 | readonly onStarted?: (pid: number) => Effect.Effect<void>; |
| 226 | readonly onExitObserved?: () => Effect.Effect<void>; | ||
| 152 | readonly onReady?: () => Effect.Effect<void>; | 227 | readonly onReady?: () => Effect.Effect<void>; |
| 153 | readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>; | 228 | readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>; |
| 154 | readonly onOutput?: ( | 229 | readonly onOutput?: ( |
| 155 | streamName: BackendProcessOutputStream, | 230 | streamName: BackendProcessOutputStream, |
| 156 | chunk: Uint8Array, | 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 | export interface DesktopBackendSnapshot { | 236 | export interface DesktopBackendSnapshot { |
| ... | @@ -225,6 +301,8 @@ interface ActiveBackendRun { | ... | @@ -225,6 +301,8 @@ interface ActiveBackendRun { |
| 225 | readonly scope: Scope.Closeable; | 301 | readonly scope: Scope.Closeable; |
| 226 | readonly fiber: Option.Option<Fiber.Fiber<void, never>>; | 302 | readonly fiber: Option.Option<Fiber.Fiber<void, never>>; |
| 227 | readonly pid: Option.Option<number>; | 303 | readonly pid: Option.Option<number>; |
| 304 | readonly exitObserved: boolean; | ||
| 305 | readonly stopRequested: boolean; | ||
| 228 | } | 306 | } |
| 229 | 307 | ||
| 230 | interface BackendManagerState { | 308 | interface BackendManagerState { |
| ... | @@ -266,76 +344,130 @@ const calculateRestartDelay = (attempt: number): Duration.Duration => | ... | @@ -266,76 +344,130 @@ const calculateRestartDelay = (attempt: number): Duration.Duration => |
| 266 | 344 | ||
| 267 | const closeRun = ( | 345 | const closeRun = ( |
| 268 | run: ActiveBackendRun, | 346 | run: ActiveBackendRun, |
| 347 | parentScope: Scope.Scope, | ||
| 269 | options?: { readonly timeout?: Duration.Duration }, | 348 | options?: { readonly timeout?: Duration.Duration }, |
| 270 | ): Effect.Effect<void> => { | 349 | ): Effect.Effect<boolean> => { |
| 271 | const waitForFiber = Option.match(run.fiber, { | 350 | const waitForFiber = Option.match(run.fiber, { |
| 272 | onNone: () => Effect.void, | 351 | onNone: () => Effect.void, |
| 273 | onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), | 352 | onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), |
| 274 | }); | 353 | }); |
| 275 | const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); | 354 | const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); |
| 355 | const timeout = options?.timeout; | ||
| 276 | 356 | ||
| 277 | return ( | 357 | if (!timeout) { |
| 278 | options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close | 358 | return close.pipe(Effect.as(true)); |
| 279 | ).pipe(Effect.ignore); | 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 = ( | 368 | export const waitForHttpReady = ( |
| 283 | baseUrl: URL, | 369 | options: BackendProcessContext & { readonly timeout: Duration.Duration }, |
| 284 | timeout: Duration.Duration, | 370 | ): Effect.Effect<void, BackendReadinessTimeoutError, HttpClient.HttpClient> => { |
| 285 | ): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => { | 371 | const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); |
| 286 | const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); | ||
| 287 | return waitForHttpReadyShared({ | 372 | return waitForHttpReadyShared({ |
| 288 | baseUrl: baseUrl.href, | 373 | baseUrl: options.httpBaseUrl.href, |
| 289 | path: BACKEND_READINESS_PATH, | 374 | path: BACKEND_READINESS_PATH, |
| 290 | timeoutMs: Duration.toMillis(timeout), | 375 | timeoutMs: Duration.toMillis(options.timeout), |
| 291 | intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL), | 376 | intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL), |
| 292 | probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT), | 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 | function drainBackendOutput( | 391 | function drainBackendOutput( |
| 392 | context: BackendProcessContext & { readonly pid: number }, | ||
| 316 | streamName: BackendProcessOutputStream, | 393 | streamName: BackendProcessOutputStream, |
| 317 | stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, | 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 | ): Effect.Effect<void> { | 400 | ): Effect.Effect<void> { |
| 320 | return stream.pipe( | 401 | return stream.pipe( |
| 321 | Stream.runForEach((chunk) => onOutput(streamName, chunk)), | 402 | Stream.mapError( |
| 322 | Effect.ignore, | 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 | const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); | 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 | options: RunBackendProcessOptions, | 436 | options: RunBackendProcessOptions, |
| 330 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { | 437 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { |
| 331 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 438 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 332 | const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( | 439 | const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( |
| 333 | Effect.mapError( | 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 | const onOutput = options.onOutput ?? (() => Effect.void); | 451 | const onOutput = options.onOutput ?? (() => Effect.void); |
| 338 | const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); | 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 | const command = ChildProcess.make(options.executablePath, options.args, { | 471 | const command = ChildProcess.make(options.executablePath, options.args, { |
| 340 | cwd: options.cwd, | 472 | cwd: options.cwd, |
| 341 | env: options.env, | 473 | env: options.env, |
| ... | @@ -350,34 +482,131 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( | ... | @@ -350,34 +482,131 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( |
| 350 | // wsl.exe drops additional file descriptors when forwarding to the Linux | 482 | // wsl.exe drops additional file descriptors when forwarding to the Linux |
| 351 | // side, so the WSL spawn path delivers the bootstrap envelope via stdin | 483 | // side, so the WSL spawn path delivers the bootstrap envelope via stdin |
| 352 | // (`--bootstrap-fd 0`) instead. | 484 | // (`--bootstrap-fd 0`) instead. |
| 353 | ...(options.bootstrapDelivery === "fd3" | 485 | ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}), |
| 354 | ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } | ||
| 355 | : {}), | ||
| 356 | }); | 486 | }); |
| 357 | 487 | ||
| 358 | const handle = yield* spawner | 488 | const handle = yield* spawner.spawn(command).pipe( |
| 359 | .spawn(command) | 489 | Effect.mapError( |
| 360 | .pipe( | 490 | (cause) => |
| 361 | Effect.mapError( | 491 | new BackendProcessSpawnError({ |
| 362 | (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }), | 492 | executablePath: options.executablePath, |
| 363 | ), | 493 | entryPath: options.entryPath, |
| 364 | ); | 494 | cwd: options.cwd, |
| 495 | httpBaseUrl: options.httpBaseUrl, | ||
| 496 | cause, | ||
| 497 | }), | ||
| 498 | ), | ||
| 499 | ); | ||
| 500 | const outputFibers: Array<Fiber.Fiber<void, never>> = []; | ||
| 365 | 501 | ||
| 366 | yield* options.onStarted?.(handle.pid) ?? Effect.void; | 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 | if (options.captureOutput) { | 540 | if (options.captureOutput) { |
| 368 | yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); | 541 | const outputContext = { |
| 369 | yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); | 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( | 566 | yield* waitForHttpReady({ |
| 372 | options.httpBaseUrl, | 567 | executablePath: options.executablePath, |
| 373 | options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | 568 | entryPath: options.entryPath, |
| 374 | ).pipe( | 569 | cwd: options.cwd, |
| 570 | httpBaseUrl: options.httpBaseUrl, | ||
| 571 | timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | ||
| 572 | }).pipe( | ||
| 375 | Effect.tap(() => options.onReady?.() ?? Effect.void), | 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 | Effect.forkScoped, | 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 | // Factory for one pooled backend instance. The returned instance owns | 612 | // Factory for one pooled backend instance. The returned instance owns |
| ... | @@ -394,12 +623,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -394,12 +623,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 394 | | ChildProcessSpawner.ChildProcessSpawner | 623 | | ChildProcessSpawner.ChildProcessSpawner |
| 395 | | HttpClient.HttpClient | 624 | | HttpClient.HttpClient |
| 396 | | DesktopObservability.DesktopBackendOutputLogFactory | 625 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 626 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher | ||
| 397 | | Scope.Scope | 627 | | Scope.Scope |
| 398 | > { | 628 | > { |
| 399 | const parentScope = yield* Scope.Scope; | 629 | const parentScope = yield* Scope.Scope; |
| 400 | const fileSystem = yield* FileSystem.FileSystem; | 630 | const fileSystem = yield* FileSystem.FileSystem; |
| 401 | const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; | 631 | const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; |
| 402 | const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); | 632 | const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); |
| 633 | const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; | ||
| 403 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 634 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 404 | const httpClient = yield* HttpClient.HttpClient; | 635 | const httpClient = yield* HttpClient.HttpClient; |
| 405 | const state = yield* Ref.make(initialState); | 636 | const state = yield* Ref.make(initialState); |
| ... | @@ -444,6 +675,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -444,6 +675,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 444 | Effect.gen(function* () { | 675 | Effect.gen(function* () { |
| 445 | const current = yield* Ref.get(state); | 676 | const current = yield* Ref.get(state); |
| 446 | if (Option.isSome(current.active)) { | 677 | if (Option.isSome(current.active)) { |
| 678 | if (!current.desiredRunning) { | ||
| 679 | yield* Ref.update(state, (latest) => ({ | ||
| 680 | ...latest, | ||
| 681 | desiredRunning: true, | ||
| 682 | })); | ||
| 683 | } | ||
| 447 | return; | 684 | return; |
| 448 | } | 685 | } |
| 449 | 686 | ||
| ... | @@ -462,6 +699,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -462,6 +699,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 462 | Effect.option, | 699 | Effect.option, |
| 463 | ); | 700 | ); |
| 464 | if (Option.isNone(config)) { | 701 | if (Option.isNone(config)) { |
| 702 | if (current.desiredRunning) { | ||
| 703 | yield* scheduleRestart("failed to generate desktop backend configuration"); | ||
| 704 | } | ||
| 465 | return; | 705 | return; |
| 466 | } | 706 | } |
| 467 | const entryExists = yield* fileSystem | 707 | const entryExists = yield* fileSystem |
| ... | @@ -561,6 +801,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -561,6 +801,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 561 | scope: runScope, | 801 | scope: runScope, |
| 562 | fiber: Option.none(), | 802 | fiber: Option.none(), |
| 563 | pid: Option.none(), | 803 | pid: Option.none(), |
| 804 | exitObserved: false, | ||
| 805 | stopRequested: false, | ||
| 564 | } satisfies ActiveBackendRun), | 806 | } satisfies ActiveBackendRun), |
| 565 | nextRunId: latest.nextRunId + 1, | 807 | nextRunId: latest.nextRunId + 1, |
| 566 | }, | 808 | }, |
| ... | @@ -571,54 +813,70 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -571,54 +813,70 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 571 | ) { | 813 | ) { |
| 572 | yield* mutex.withPermits(1)( | 814 | yield* mutex.withPermits(1)( |
| 573 | Effect.gen(function* () { | 815 | Effect.gen(function* () { |
| 574 | const { isCurrentRun, nextState, pid } = yield* Ref.modify( | 816 | const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = |
| 575 | state, | 817 | yield* Ref.modify( |
| 576 | ( | 818 | state, |
| 577 | latest, | 819 | ( |
| 578 | ): readonly [ | 820 | latest, |
| 579 | { | 821 | ): readonly [ |
| 580 | readonly isCurrentRun: boolean; | 822 | { |
| 581 | readonly nextState: BackendManagerState; | 823 | readonly isCurrentRun: boolean; |
| 582 | readonly pid: Option.Option<number>; | 824 | readonly nextState: BackendManagerState; |
| 583 | }, | 825 | readonly pid: Option.Option<number>; |
| 584 | BackendManagerState, | 826 | readonly exitObserved: boolean; |
| 585 | ] => { | 827 | readonly stopRequested: boolean; |
| 586 | const currentRun = Option.getOrUndefined(latest.active); | 828 | readonly wasReady: boolean; |
| 587 | if (currentRun?.id !== runId) { | 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 | return [ | 852 | return [ |
| 589 | { | 853 | { |
| 590 | isCurrentRun: false, | 854 | isCurrentRun: true, |
| 591 | nextState: latest, | 855 | nextState: next, |
| 592 | pid: Option.none<number>(), | 856 | pid: currentRun.pid, |
| 857 | exitObserved: currentRun.exitObserved, | ||
| 858 | stopRequested: currentRun.stopRequested, | ||
| 859 | wasReady: latest.ready, | ||
| 593 | }, | 860 | }, |
| 594 | latest, | 861 | next, |
| 595 | ] as const; | 862 | ] as const; |
| 596 | } | 863 | }, |
| 597 | 864 | ); | |
| 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 | ); | ||
| 613 | 865 | ||
| 614 | if (isCurrentRun) { | 866 | if (isCurrentRun) { |
| 867 | yield* desktopTelemetryPublisher.removeControlSource(spec.id); | ||
| 615 | if (Option.isSome(pid)) { | 868 | if (Option.isSome(pid)) { |
| 616 | yield* backendOutputLog.writeSessionBoundary({ | 869 | if (exitObserved && !stopRequested) { |
| 617 | phase: "END", | 870 | yield* backendOutputLog.persistFailure({ |
| 618 | details: `pid=${pid.value} ${reason}`, | 871 | details: `pid=${pid.value} ${reason}`, |
| 619 | }); | 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 | if (isCurrentRun && nextState.desiredRunning) { | 882 | if (isCurrentRun && nextState.desiredRunning) { |
| ... | @@ -630,16 +888,23 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -630,16 +888,23 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 630 | 888 | ||
| 631 | const program = runBackendProcess({ | 889 | const program = runBackendProcess({ |
| 632 | ...config.value, | 890 | ...config.value, |
| 891 | desktopTelemetryStream: desktopTelemetryPublisher.encoded, | ||
| 892 | onDesktopTelemetryControl: (message) => | ||
| 893 | desktopTelemetryPublisher.handleControlForSource(spec.id, message), | ||
| 633 | onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { | 894 | onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { |
| 634 | yield* updateActiveRun(runId, (run) => ({ | 895 | yield* updateActiveRun(runId, (run) => ({ |
| 635 | ...run, | 896 | ...run, |
| 636 | pid: Option.some(pid), | 897 | pid: Option.some(pid), |
| 637 | })); | 898 | })); |
| 638 | yield* backendOutputLog.writeSessionBoundary({ | 899 | yield* backendOutputLog.beginSession({ |
| 639 | phase: "START", | ||
| 640 | details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, | 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 | onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { | 908 | onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { |
| 644 | const isCurrentRun = yield* Ref.modify(state, (latest) => { | 909 | const isCurrentRun = yield* Ref.modify(state, (latest) => { |
| 645 | const activeRun = Option.getOrUndefined(latest.active); | 910 | const activeRun = Option.getOrUndefined(latest.active); |
| ... | @@ -662,10 +927,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -662,10 +927,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 662 | 927 | ||
| 663 | yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; | 928 | yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; |
| 664 | }), | 929 | }), |
| 665 | onReadinessFailure: (error) => | 930 | onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( |
| 666 | logInstanceWarning("backend readiness check failed during bootstrap", { | 931 | function* (error) { |
| 667 | error: error.message, | 932 | yield* logInstanceWarning("backend readiness check failed during bootstrap", { |
| 668 | }), | 933 | error: error.message, |
| 934 | }); | ||
| 935 | yield* backendOutputLog.persistFailureSnapshot({ | ||
| 936 | details: error.message, | ||
| 937 | }); | ||
| 938 | }, | ||
| 939 | ), | ||
| 669 | onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), | 940 | onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), |
| 670 | }).pipe( | 941 | }).pipe( |
| 671 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 942 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| ... | @@ -750,41 +1021,92 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ... | @@ -750,41 +1021,92 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 750 | const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { | 1021 | const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { |
| 751 | readonly timeout?: Duration.Duration; | 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 | Effect.gen(function* () { | 1025 | Effect.gen(function* () { |
| 755 | const result = yield* Ref.modify(state, (latest) => [ | 1026 | const result = yield* Ref.modify(state, (latest) => { |
| 756 | { | 1027 | const active = Option.map(latest.active, (run) => |
| 757 | active: latest.active, | 1028 | run.exitObserved ? run : { ...run, stopRequested: true }, |
| 758 | restartFiber: latest.restartFiber, | 1029 | ); |
| 759 | }, | 1030 | return [ |
| 760 | { | 1031 | { |
| 761 | ...latest, | 1032 | active, |
| 762 | desiredRunning: false, | 1033 | restartFiber: latest.restartFiber, |
| 763 | ready: false, | 1034 | notifyShutdown: latest.ready, |
| 764 | active: Option.none<ActiveBackendRun>(), | 1035 | }, |
| 765 | restartFiber: Option.none<Fiber.Fiber<void, never>>(), | 1036 | { |
| 766 | }, | 1037 | ...latest, |
| 767 | ]); | 1038 | desiredRunning: false, |
| 768 | // Ignore failures from spec.onShutdown so a downstream throw | 1039 | ready: false, |
| 769 | // can't abort the rest of stop(). Ref.modify above already | 1040 | active, |
| 770 | // flipped state to "no active run / no restart fiber", and the | 1041 | restartFiber: Option.none<Fiber.Fiber<void, never>>(), |
| 771 | // physical cleanup (Fiber.interrupt + closeRun) runs after the | 1042 | }, |
| 772 | // mutex releases. If onShutdown were allowed to propagate, both | 1043 | ] as const; |
| 773 | // would be skipped and the child process + restart fiber would | 1044 | }); |
| 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); | ||
| 777 | return result; | 1045 | return result; |
| 778 | }), | 1046 | }), |
| 779 | ); | 1047 | ); |
| 780 | 1048 | ||
| 1049 | if (notifyShutdown) { | ||
| 1050 | yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); | ||
| 1051 | } | ||
| 781 | yield* Option.match(restartFiber, { | 1052 | yield* Option.match(restartFiber, { |
| 782 | onNone: () => Effect.void, | 1053 | onNone: () => Effect.void, |
| 783 | onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), | 1054 | onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), |
| 784 | }); | 1055 | }); |
| 785 | yield* Option.match(active, { | 1056 | yield* Option.match(active, { |
| 786 | onNone: () => Effect.void, | 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,11 +5,13 @@ import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import * as Ref from "effect/Ref"; | 7 | import * as Ref from "effect/Ref"; |
| 8 | import * as Stream from "effect/Stream"; | ||
| 8 | import { HttpClient } from "effect/unstable/http"; | 9 | import { HttpClient } from "effect/unstable/http"; |
| 9 | import { ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 11 | ||
| 11 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 12 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 13 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 14 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | ||
| 13 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | 15 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 14 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 16 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 15 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | 17 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| ... | @@ -56,10 +58,21 @@ function makePoolLayer( | ... | @@ -56,10 +58,21 @@ function makePoolLayer( |
| 56 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { | 58 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { |
| 57 | forInstance: () => | 59 | forInstance: () => |
| 58 | Effect.succeed({ | 60 | Effect.succeed({ |
| 59 | writeSessionBoundary: () => Effect.void, | 61 | beginSession: () => Effect.void, |
| 60 | writeOutputChunk: () => Effect.void, | 62 | writeOutputChunk: () => Effect.void, |
| 63 | persistFailureSnapshot: () => Effect.void, | ||
| 64 | persistFailure: () => Effect.void, | ||
| 65 | discardSession: Effect.void, | ||
| 61 | } satisfies DesktopObservability.DesktopBackendOutputLogShape), | 66 | } satisfies DesktopObservability.DesktopBackendOutputLogShape), |
| 62 | } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), | 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 | Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { | 76 | Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { |
| 64 | resolvePrimary: Effect.die("unexpected primary config resolve"), | 77 | resolvePrimary: Effect.die("unexpected primary config resolve"), |
| 65 | resolvePrimaryLabel: Ref.get(labelRef), | 78 | resolvePrimaryLabel: Ref.get(labelRef), |
apps/desktop/src/backend/DesktopBackendPool.ts+3-1| ... | @@ -97,6 +97,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | ... | @@ -97,6 +97,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 97 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | 97 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 98 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 98 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 100 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | ||
| 100 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 101 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 101 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | 102 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 102 | 103 | ||
| ... | @@ -176,7 +177,8 @@ export type BackendInstanceFactoryRequirements = | ... | @@ -176,7 +177,8 @@ export type BackendInstanceFactoryRequirements = |
| 176 | | FileSystem.FileSystem | 177 | | FileSystem.FileSystem |
| 177 | | ChildProcessSpawner.ChildProcessSpawner | 178 | | ChildProcessSpawner.ChildProcessSpawner |
| 178 | | HttpClient.HttpClient | 179 | | HttpClient.HttpClient |
| 179 | | DesktopObservability.DesktopBackendOutputLogFactory; | 180 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 181 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher; | ||
| 180 | 182 | ||
| 181 | interface ActiveRegisteredInstance { | 183 | interface ActiveRegisteredInstance { |
| 182 | readonly _tag: "Active"; | 184 | readonly _tag: "Active"; |
apps/desktop/src/electron/ElectronApp.ts+2| ... | @@ -57,6 +57,7 @@ export class ElectronApp extends Context.Service< | ... | @@ -57,6 +57,7 @@ export class ElectronApp extends Context.Service< |
| 57 | ) => Effect.Effect<void>; | 57 | ) => Effect.Effect<void>; |
| 58 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; | 58 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; |
| 59 | readonly requestSingleInstanceLock: Effect.Effect<boolean>; | 59 | readonly requestSingleInstanceLock: Effect.Effect<boolean>; |
| 60 | readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>; | ||
| 60 | readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>; | 61 | readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>; |
| 61 | readonly setAsDefaultProtocolClient: ( | 62 | readonly setAsDefaultProtocolClient: ( |
| 62 | protocol: string, | 63 | protocol: string, |
| ... | @@ -153,6 +154,7 @@ export const make = ElectronApp.of({ | ... | @@ -153,6 +154,7 @@ export const make = ElectronApp.of({ |
| 153 | Electron.app.setAppUserModelId(id); | 154 | Electron.app.setAppUserModelId(id); |
| 154 | }), | 155 | }), |
| 155 | requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), | 156 | requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), |
| 157 | getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), | ||
| 156 | isDefaultProtocolClient: (protocol) => | 158 | isDefaultProtocolClient: (protocol) => |
| 157 | Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), | 159 | Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), |
| 158 | setAsDefaultProtocolClient: (protocol, path, args) => | 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,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; |
| 24 | import * as ElectronApp from "./electron/ElectronApp.ts"; | 24 | import * as ElectronApp from "./electron/ElectronApp.ts"; |
| 25 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; | 25 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; |
| 26 | import * as ElectronMenu from "./electron/ElectronMenu.ts"; | 26 | import * as ElectronMenu from "./electron/ElectronMenu.ts"; |
| 27 | import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; | ||
| 27 | import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; | 28 | import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; |
| 28 | import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; | 29 | import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; |
| 29 | import * as ElectronShell from "./electron/ElectronShell.ts"; | 30 | import * as ElectronShell from "./electron/ElectronShell.ts"; |
| ... | @@ -52,6 +53,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; | ... | @@ -52,6 +53,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; |
| 52 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; | 53 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 53 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; | 54 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 54 | import * as DesktopState from "./app/DesktopState.ts"; | 55 | import * as DesktopState from "./app/DesktopState.ts"; |
| 56 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; | ||
| 55 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 57 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 56 | import * as BrowserSession from "./preview/BrowserSession.ts"; | 58 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 57 | import * as PreviewManager from "./preview/Manager.ts"; | 59 | import * as PreviewManager from "./preview/Manager.ts"; |
| ... | @@ -113,6 +115,7 @@ const electronLayer = Layer.mergeAll( | ... | @@ -113,6 +115,7 @@ const electronLayer = Layer.mergeAll( |
| 113 | ElectronApp.layer, | 115 | ElectronApp.layer, |
| 114 | ElectronDialog.layer, | 116 | ElectronDialog.layer, |
| 115 | ElectronMenu.layer, | 117 | ElectronMenu.layer, |
| 118 | ElectronPowerMonitor.layer, | ||
| 116 | ElectronProtocol.layer, | 119 | ElectronProtocol.layer, |
| 117 | ElectronSafeStorage.layer, | 120 | ElectronSafeStorage.layer, |
| 118 | ElectronShell.layer, | 121 | ElectronShell.layer, |
| ... | @@ -160,6 +163,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( | ... | @@ -160,6 +163,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( |
| 160 | Layer.provideMerge(DesktopAppIdentity.layer), | 163 | Layer.provideMerge(DesktopAppIdentity.layer), |
| 161 | Layer.provideMerge(DesktopBackendConfiguration.layer), | 164 | Layer.provideMerge(DesktopBackendConfiguration.layer), |
| 162 | Layer.provideMerge(DesktopWslEnvironment.layer), | 165 | Layer.provideMerge(DesktopWslEnvironment.layer), |
| 166 | Layer.provideMerge(DesktopTelemetryPublisher.layer), | ||
| 163 | Layer.provideMerge(desktopWindowLayer), | 167 | Layer.provideMerge(desktopWindowLayer), |
| 164 | ); | 168 | ); |
| 165 | 169 |
apps/desktop/src/preview/Manager.test.ts+86| ... | @@ -982,6 +982,92 @@ describe("PreviewManager", () => { | ... | @@ -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 | effectIt.effect("shares background frame capture between recording and picture-in-picture", () => | 1071 | effectIt.effect("shares background frame capture between recording and picture-in-picture", () => |
| 986 | withManager((manager) => | 1072 | withManager((manager) => |
| 987 | Effect.gen(function* () { | 1073 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+55-6| ... | @@ -671,10 +671,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => | 681 | const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => |
| 675 | [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); | 682 | [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); |
| 676 | 683 | ||
| 677 | const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* ( | 684 | const captureDiagnosticMessage = Effect.fnUntraced(function* ( |
| 678 | webContentsId: number, | 685 | webContentsId: number, |
| 679 | method: string, | 686 | method: string, |
| 680 | params: Record<string, unknown>, | 687 | params: Record<string, unknown>, |
| ... | @@ -852,11 +859,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -852,11 +859,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 852 | const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { | 859 | const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { |
| 853 | const semaphore = yield* Semaphore.make(1); | 860 | const semaphore = yield* Semaphore.make(1); |
| 854 | const scope = yield* Scope.fork(parentScope, "sequential"); | 861 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 855 | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( | 862 | const handleDebuggerMessage = Effect.fnUntraced(function* ( |
| 856 | function* (method: string, params: Record<string, unknown>) { | 863 | method: string, |
| 857 | yield* captureDiagnosticMessage(wc.id, method, params); | 864 | params: Record<string, unknown>, |
| 858 | }, | 865 | ) { |
| 859 | ); | 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 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { | 909 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { |
| 861 | runFork(handleDebuggerMessage(method, params)); | 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,6 +40,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 40 | setAboutPanelOptions: () => Effect.void, | 40 | setAboutPanelOptions: () => Effect.void, |
| 41 | setAppUserModelId: () => Effect.void, | 41 | setAppUserModelId: () => Effect.void, |
| 42 | requestSingleInstanceLock: Effect.succeed(true), | 42 | requestSingleInstanceLock: Effect.succeed(true), |
| 43 | getAppMetrics: Effect.succeed([]), | ||
| 43 | isDefaultProtocolClient: () => Effect.succeed(false), | 44 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 44 | setAsDefaultProtocolClient: () => Effect.succeed(true), | 45 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 45 | setDesktopName: () => Effect.void, | 46 | setDesktopName: () => Effect.void, |
apps/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,6 +5,10 @@ import * as Layer from "effect/Layer"; |
| 5 | import { Atom } from "effect/unstable/reactivity"; | 5 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 6 | ||
| 7 | import { runtimeContextLayer } from "../lib/runtime"; | 7 | import { runtimeContextLayer } from "../lib/runtime"; |
| 8 | import { | ||
| 9 | mobileBackgroundActivityObserverLayer, | ||
| 10 | mobileBackgroundActivityReporterLayer, | ||
| 11 | } from "./background-activity"; | ||
| 8 | import { connectionPlatformLayer } from "./platform"; | 12 | import { connectionPlatformLayer } from "./platform"; |
| 9 | 13 | ||
| 10 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( | 14 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( |
| ... | @@ -17,10 +21,22 @@ type ConnectionLayerSource = | ... | @@ -17,10 +21,22 @@ type ConnectionLayerSource = |
| 17 | | typeof Connection.layer | 21 | | typeof Connection.layer |
| 18 | | typeof snapshotLoaderLayer | 22 | | typeof snapshotLoaderLayer |
| 19 | | typeof runtimeContextLayer | 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( | 28 | const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( |
| 23 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), | 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 | export const connectionAtomRuntime: Atom.AtomRuntime< | 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,7 +41,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 41 | const defaultObservabilityConfig = { | 41 | const defaultObservabilityConfig = { |
| 42 | traceMinLevel: "Info", | 42 | traceMinLevel: "Info", |
| 43 | traceTimingEnabled: true, | 43 | traceTimingEnabled: true, |
| 44 | traceBatchWindowMs: 200, | 44 | traceBatchWindowMs: 1_000, |
| 45 | traceMaxBytes: 10 * 1024 * 1024, | 45 | traceMaxBytes: 10 * 1024 * 1024, |
| 46 | traceMaxFiles: 10, | 46 | traceMaxFiles: 10, |
| 47 | otlpTracesUrl: undefined, | 47 | otlpTracesUrl: undefined, |
| ... | @@ -286,6 +286,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -286,6 +286,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 286 | t3Home: baseDir, | 286 | t3Home: baseDir, |
| 287 | noBrowser: true, | 287 | noBrowser: true, |
| 288 | desktopBootstrapToken: "desktop-token", | 288 | desktopBootstrapToken: "desktop-token", |
| 289 | desktopTelemetryFd: 4, | ||
| 290 | desktopTelemetryControlFd: 5, | ||
| 289 | tailscaleServeEnabled: false, | 291 | tailscaleServeEnabled: false, |
| 290 | tailscaleServePort: 443, | 292 | tailscaleServePort: 443, |
| 291 | otlpTracesUrl: "http://localhost:4318/v1/traces", | 293 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| ... | @@ -341,12 +343,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -341,12 +343,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 341 | noBrowser: true, | 343 | noBrowser: true, |
| 342 | startupPresentation: "browser", | 344 | startupPresentation: "browser", |
| 343 | desktopBootstrapToken: "desktop-token", | 345 | desktopBootstrapToken: "desktop-token", |
| 346 | desktopTelemetryFd: 4, | ||
| 347 | desktopTelemetryControlFd: 5, | ||
| 348 | resourceMonitorPath: undefined, | ||
| 344 | autoBootstrapProjectFromCwd: false, | 349 | autoBootstrapProjectFromCwd: false, |
| 345 | logWebSocketEvents: false, | 350 | logWebSocketEvents: false, |
| 346 | tailscaleServeEnabled: false, | 351 | tailscaleServeEnabled: false, |
| 347 | tailscaleServePort: 443, | 352 | tailscaleServePort: 443, |
| 348 | }); | 353 | }); |
| 349 | assert.equal(join(baseDir, "userdata"), resolved.stateDir); | 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,7 +85,7 @@ const EnvServerConfig = Config.all({ |
| 85 | ), | 85 | ), |
| 86 | traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), | 86 | traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), |
| 87 | traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), | 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 | otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( | 89 | otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( |
| 90 | Config.option, | 90 | Config.option, |
| 91 | Config.map(Option.getOrUndefined), | 91 | Config.map(Option.getOrUndefined), |
| ... | @@ -303,6 +303,9 @@ export const resolveServerConfig = ( | ... | @@ -303,6 +303,9 @@ export const resolveServerConfig = ( |
| 303 | () => mode === "desktop", | 303 | () => mode === "desktop", |
| 304 | ); | 304 | ); |
| 305 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; | 305 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; |
| 306 | const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; | ||
| 307 | const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; | ||
| 308 | const resourceMonitorPath = bootstrap?.resourceMonitorPath; | ||
| 306 | const autoBootstrapProjectFromCwd = Option.getOrElse( | 309 | const autoBootstrapProjectFromCwd = Option.getOrElse( |
| 307 | resolveOptionPrecedence( | 310 | resolveOptionPrecedence( |
| 308 | Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), | 311 | Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), |
| ... | @@ -376,6 +379,9 @@ export const resolveServerConfig = ( | ... | @@ -376,6 +379,9 @@ export const resolveServerConfig = ( |
| 376 | noBrowser, | 379 | noBrowser, |
| 377 | startupPresentation, | 380 | startupPresentation, |
| 378 | desktopBootstrapToken, | 381 | desktopBootstrapToken, |
| 382 | desktopTelemetryFd, | ||
| 383 | desktopTelemetryControlFd, | ||
| 384 | resourceMonitorPath, | ||
| 379 | autoBootstrapProjectFromCwd, | 385 | autoBootstrapProjectFromCwd, |
| 380 | logWebSocketEvents, | 386 | logWebSocketEvents, |
| 381 | tailscaleServeEnabled, | 387 | tailscaleServeEnabled, |
apps/server/src/config.ts+6| ... | @@ -76,6 +76,9 @@ export class ServerConfig extends Context.Service< | ... | @@ -76,6 +76,9 @@ export class ServerConfig extends Context.Service< |
| 76 | readonly noBrowser: boolean; | 76 | readonly noBrowser: boolean; |
| 77 | readonly startupPresentation: StartupPresentation; | 77 | readonly startupPresentation: StartupPresentation; |
| 78 | readonly desktopBootstrapToken: string | undefined; | 78 | readonly desktopBootstrapToken: string | undefined; |
| 79 | readonly desktopTelemetryFd?: number | undefined; | ||
| 80 | readonly desktopTelemetryControlFd?: number | undefined; | ||
| 81 | readonly resourceMonitorPath?: string | undefined; | ||
| 79 | readonly autoBootstrapProjectFromCwd: boolean; | 82 | readonly autoBootstrapProjectFromCwd: boolean; |
| 80 | readonly logWebSocketEvents: boolean; | 83 | readonly logWebSocketEvents: boolean; |
| 81 | readonly tailscaleServeEnabled: boolean; | 84 | readonly tailscaleServeEnabled: boolean; |
| ... | @@ -186,6 +189,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( | ... | @@ -186,6 +189,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
| 186 | port: 0, | 189 | port: 0, |
| 187 | host: undefined, | 190 | host: undefined, |
| 188 | desktopBootstrapToken: undefined, | 191 | desktopBootstrapToken: undefined, |
| 192 | desktopTelemetryFd: undefined, | ||
| 193 | desktopTelemetryControlFd: undefined, | ||
| 194 | resourceMonitorPath: undefined, | ||
| 189 | staticDir: undefined, | 195 | staticDir: undefined, |
| 190 | devUrl, | 196 | devUrl, |
| 191 | devAllowedOrigins: [], | 197 | devAllowedOrigins: [], |
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+235-233| ... | @@ -1,291 +1,293 @@ | ... | @@ -1,291 +1,293 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { | ||
| 3 | DesktopHostTelemetrySnapshot, | ||
| 4 | ResourceMonitorSnapshotEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | 6 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 6 | import * as Sink from "effect/Sink"; | ||
| 7 | import * as Stream from "effect/Stream"; | 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 | import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; | 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: { | 34 | function makeTelemetryLayer( |
| 16 | readonly stdout?: string; | 35 | snapshot: ResourceMonitorSnapshotEvent, |
| 17 | readonly stderr?: string; | 36 | desktopSnapshot?: DesktopHostTelemetrySnapshot, |
| 18 | readonly code?: number; | 37 | ) { |
| 19 | }) { | 38 | const nativeLayer = NativeTelemetryClient.layerTest({ |
| 20 | return ChildProcessSpawner.makeHandle({ | 39 | sampleNow: Effect.succeed({ generation: 0, snapshot }), |
| 21 | pid: ChildProcessSpawner.ProcessId(1), | 40 | health: Effect.succeed({ |
| 22 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), | 41 | status: "healthy", |
| 23 | isRunning: Effect.succeed(false), | 42 | hello: Option.none(), |
| 24 | kill: () => Effect.void, | 43 | lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)), |
| 25 | unref: Effect.succeed(Effect.void), | 44 | lastError: Option.none(), |
| 26 | stdin: Sink.drain, | 45 | restartCount: 0, |
| 27 | stdout: Stream.make(encoder.encode(result.stdout ?? "")), | 46 | sampleIntervalMs: 1_000, |
| 28 | stderr: Stream.make(encoder.encode(result.stderr ?? "")), | 47 | }), |
| 29 | all: Stream.empty, | ||
| 30 | getInputFd: () => Sink.drain, | ||
| 31 | getOutputFd: () => Stream.empty, | ||
| 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 | describe("ProcessDiagnostics", () => { | 64 | describe("ProcessDiagnostics", () => { |
| 36 | it.effect("parses POSIX ps rows with full commands", () => | 65 | it.effect("projects live process data from resource telemetry", () => |
| 37 | Effect.sync(() => { | 66 | Effect.gen(function* () { |
| 38 | const rows = ProcessDiagnostics.parsePosixProcessRows( | 67 | const snapshot = makeNativeSnapshot([ |
| 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([ | ||
| 46 | { | 68 | { |
| 47 | pid: 10, | 69 | pid: process.pid, |
| 48 | ppid: 1, | 70 | ppid: 1, |
| 49 | pgid: 10, | 71 | startTimeMs: 1_000, |
| 50 | status: "Ss", | 72 | runTimeMs: 60_000, |
| 73 | name: "node", | ||
| 74 | command: "t3 server", | ||
| 75 | status: "Running", | ||
| 51 | cpuPercent: 0, | 76 | cpuPercent: 0, |
| 52 | rssBytes: 1024 * 1024, | 77 | cpuTimeMs: 100, |
| 53 | elapsed: "01:02.03", | 78 | residentBytes: 1_024, |
| 54 | command: "/usr/bin/node server.js", | 79 | virtualBytes: 2_048, |
| 80 | ioReadBytes: 100, | ||
| 81 | ioWriteBytes: 200, | ||
| 82 | ioSemantics: "storage", | ||
| 55 | }, | 83 | }, |
| 56 | { | 84 | { |
| 57 | pid: 11, | 85 | pid: 4_242, |
| 58 | ppid: 10, | 86 | ppid: process.pid, |
| 59 | pgid: 10, | 87 | startTimeMs: 2_000, |
| 60 | status: "S+", | 88 | runTimeMs: 4_000, |
| 61 | cpuPercent: 12.5, | 89 | name: "agent", |
| 62 | rssBytes: 20480 * 1024, | 90 | command: "codex app-server", |
| 63 | elapsed: "00:04", | 91 | status: "Running", |
| 64 | command: "codex app-server --config /tmp/one two", | 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 | }), | 101 | const telemetryLayer = makeTelemetryLayer(snapshot); |
| 68 | ); | 102 | const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer)); |
| 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 | }); | ||
| 128 | 103 | ||
| 129 | expect(diagnostics.serverPid).toBe(100); | 104 | const diagnostics = yield* Effect.gen(function* () { |
| 130 | expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z"); | 105 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 131 | expect(diagnostics.processCount).toBe(2); | 106 | return yield* processDiagnostics.read; |
| 132 | expect(diagnostics.totalRssBytes).toBe(6_000); | 107 | }).pipe(Effect.provide(layer)); |
| 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 | ); | ||
| 140 | 108 | ||
| 141 | it.effect("preserves ascending sibling order for nested descendants", () => | 109 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); |
| 142 | Effect.sync(() => { | 110 | expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000); |
| 143 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ | 111 | expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5); |
| 144 | serverPid: 100, | 112 | expect(diagnostics.processes[0]?.rssBytes).toBe(2_048); |
| 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]); | ||
| 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 | Effect.gen(function* () { | 117 | Effect.gen(function* () { |
| 186 | const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = | 118 | const snapshot = makeNativeSnapshot([]); |
| 187 | []; | 119 | const telemetryLayer = makeTelemetryLayer(snapshot); |
| 188 | const spawnerLayer = Layer.succeed( | 120 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); |
| 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)); | ||
| 207 | 121 | ||
| 208 | const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | 122 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( |
| 209 | Effect.flatMap((pd) => pd.read), | 123 | Effect.flatMap((processDiagnostics) => |
| 124 | processDiagnostics.signal({ | ||
| 125 | pid: 4_242, | ||
| 126 | startTimeMs: 2_000, | ||
| 127 | signal: "SIGINT", | ||
| 128 | }), | ||
| 129 | ), | ||
| 210 | Effect.provide(layer), | 130 | Effect.provide(layer), |
| 211 | ); | 131 | ); |
| 212 | 132 | ||
| 213 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); | 133 | expect(result).toEqual({ |
| 214 | expect(commands).toEqual([ | 134 | pid: 4242, |
| 215 | { | 135 | signal: "SIGINT", |
| 216 | command: "ps", | 136 | signaled: false, |
| 217 | args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="], | 137 | message: Option.some("Process 4242 no longer matches the selected process identity."), |
| 218 | }, | 138 | }); |
| 219 | ]); | ||
| 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 | Effect.gen(function* () { | 143 | Effect.gen(function* () { |
| 225 | const spawnerLayer = Layer.succeed( | 144 | const snapshot = makeNativeSnapshot([ |
| 226 | ChildProcessSpawner.ChildProcessSpawner, | 145 | { |
| 227 | ChildProcessSpawner.make(() => | 146 | pid: 4_242, |
| 228 | Effect.succeed( | 147 | ppid: process.pid, |
| 229 | mockHandle({ | 148 | startTimeMs: 2_000, |
| 230 | code: 17, | 149 | runTimeMs: 4_000, |
| 231 | stdout: "partial process output", | 150 | name: "agent", |
| 232 | stderr: "process access denied", | 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( | 185 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( |
| 239 | Effect.provide(spawnerLayer), | 186 | Effect.flatMap((processDiagnostics) => |
| 240 | Effect.provideService(HostProcessPlatform, "linux"), | 187 | processDiagnostics.signal({ |
| 241 | Effect.flip, | 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({ | 196 | expect(result).toEqual({ |
| 245 | _tag: "ProcessDiagnosticsQueryFailedError", | 197 | pid: 4_242, |
| 246 | command: "ps", | 198 | signal: "SIGINT", |
| 247 | argCount: 2, | 199 | signaled: false, |
| 248 | cwd: process.cwd(), | 200 | message: Option.some( |
| 249 | exitCode: 17, | 201 | "Could not refresh process 4242; refusing to signal a stale identity.", |
| 250 | stdoutBytes: 22, | 202 | ), |
| 251 | stderrBytes: 21, | ||
| 252 | stdoutTruncated: false, | ||
| 253 | stderrTruncated: false, | ||
| 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 | Effect.gen(function* () { | 208 | Effect.gen(function* () { |
| 263 | const spawnerLayer = Layer.succeed( | 209 | const sampledAtUnixMs = DateTime.toEpochMillis( |
| 264 | ChildProcessSpawner.ChildProcessSpawner, | 210 | DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), |
| 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 | ), | ||
| 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 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | 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 | Effect.provide(layer), | 273 | Effect.provide(layer), |
| 281 | ); | 274 | ); |
| 282 | 275 | ||
| 283 | expect(result).toEqual({ | 276 | expect(result).toEqual({ |
| 284 | pid: 4242, | 277 | pid: 4_242, |
| 285 | signal: "SIGINT", | 278 | signal: "SIGKILL", |
| 286 | signaled: false, | 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,105 +1,20 @@ |
| 1 | import type { | 1 | import type { |
| 2 | ResourceTelemetryProcessCategory, | ||
| 2 | ServerProcessDiagnosticsEntry, | 3 | ServerProcessDiagnosticsEntry, |
| 3 | ServerProcessDiagnosticsResult, | 4 | ServerProcessDiagnosticsResult, |
| 4 | ServerProcessSignal, | 5 | ServerProcessSignal, |
| 5 | ServerSignalProcessResult, | 6 | ServerSignalProcessResult, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | import * as Context from "effect/Context"; | 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"; | 9 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 14 | import * as Schema from "effect/Schema"; | 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 { | 16 | export class ProcessSignalFailed extends Schema.TaggedErrorClass<ProcessSignalFailed>()( |
| 21 | readonly pid: number; | 17 | "ProcessSignalFailed", |
| 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", | ||
| 103 | { | 18 | { |
| 104 | pid: Schema.Number, | 19 | pid: Schema.Number, |
| 105 | signal: Schema.String, | 20 | signal: Schema.String, |
| ... | @@ -111,458 +26,136 @@ class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<Proces | ... | @@ -111,458 +26,136 @@ class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<Proces |
| 111 | } | 26 | } |
| 112 | } | 27 | } |
| 113 | 28 | ||
| 114 | const ProcessDiagnosticsError = Schema.Union([ | 29 | export class ProcessDiagnostics extends Context.Service< |
| 115 | ProcessDiagnosticsQueryTimeoutError, | 30 | ProcessDiagnostics, |
| 116 | ProcessDiagnosticsQueryFailedError, | 31 | { |
| 117 | ProcessDiagnosticsServerProcessSignalError, | 32 | readonly read: Effect.Effect<ServerProcessDiagnosticsResult>; |
| 118 | ProcessDiagnosticsNotDescendantError, | 33 | readonly signal: (input: { |
| 119 | ProcessDiagnosticsSignalFailedError, | 34 | readonly pid: number; |
| 120 | ]); | 35 | readonly startTimeMs: number; |
| 121 | type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; | 36 | readonly signal: ServerProcessSignal; |
| 122 | const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); | 37 | }) => Effect.Effect<ServerSignalProcessResult>; |
| 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 }))); | ||
| 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 { | 51 | function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean { |
| 296 | if (row.ppid !== serverPid) return false; | ||
| 297 | |||
| 298 | const command = row.command.trim(); | ||
| 299 | return ( | 52 | return ( |
| 300 | /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) || | 53 | category === "server-child" || category === "provider-root" || category === "terminal-root" |
| 301 | (/\bpowershell(?:\.exe)?\b/i.test(command) && | ||
| 302 | /\bGet-CimInstance\s+Win32_Process\b/i.test(command)) | ||
| 303 | ); | 54 | ); |
| 304 | } | 55 | } |
| 305 | 56 | ||
| 306 | function makeResult(input: { | 57 | export const make = Effect.fn("makeProcessDiagnostics")(function* () { |
| 307 | readonly serverPid: number; | 58 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 308 | readonly rows: ReadonlyArray<ProcessRow>; | 59 | const refreshedTelemetry = telemetry.refresh.pipe(Effect.catch(() => telemetry.latest)); |
| 309 | readonly readAt: DateTime.Utc; | 60 | const read: ProcessDiagnostics["Service"]["read"] = refreshedTelemetry.pipe( |
| 310 | readonly error?: string; | 61 | Effect.map((snapshot) => { |
| 311 | }): ServerProcessDiagnosticsResult { | 62 | const processes = snapshot.processes |
| 312 | const readAt = input.readAt; | 63 | .filter((entry) => canSignalCategory(entry.category)) |
| 313 | const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); | 64 | .map( |
| 314 | const processes = buildDescendantEntries(rows, input.serverPid); | 65 | (entry): ServerProcessDiagnosticsEntry => ({ |
| 315 | const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0); | 66 | pid: entry.identity.pid, |
| 316 | const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0); | 67 | startTimeMs: entry.identity.startTimeMs, |
| 317 | 68 | ppid: entry.ppid, | |
| 318 | return { | 69 | pgid: Option.none(), |
| 319 | serverPid: input.serverPid, | 70 | status: entry.status || "Unknown", |
| 320 | readAt, | 71 | cpuPercent: entry.cpuPercent, |
| 321 | processCount: processes.length, | 72 | rssBytes: entry.residentBytes, |
| 322 | totalRssBytes, | 73 | elapsed: formatElapsed(entry.runTimeMs), |
| 323 | totalCpuPercent, | 74 | command: entry.command || entry.name || "unknown", |
| 324 | processes, | 75 | depth: Math.max(0, entry.depth - 1), |
| 325 | error: input.error ? Option.some({ message: input.error }) : Option.none(), | 76 | childPids: entry.childPids, |
| 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, | ||
| 407 | }), | 77 | }), |
| 408 | ), | 78 | ); |
| 409 | ); | 79 | return { |
| 410 | }); | 80 | serverPid: process.pid, |
| 411 | 81 | readAt: snapshot.readAt, | |
| 412 | function readPosixProcessRows(): Effect.Effect< | 82 | processCount: processes.length, |
| 413 | ReadonlyArray<ProcessRow>, | 83 | totalRssBytes: processes.reduce((total, entry) => total + entry.rssBytes, 0), |
| 414 | ProcessDiagnosticsError, | 84 | totalCpuPercent: processes.reduce((total, entry) => total + entry.cpuPercent, 0), |
| 415 | ChildProcessSpawner.ChildProcessSpawner | 85 | processes, |
| 416 | > { | 86 | error: Option.map(snapshot.health.native.lastError, (message) => ({ message })), |
| 417 | return runProcess({ | 87 | }; |
| 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 | ); | ||
| 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 | const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")( | 91 | const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")( |
| 538 | function* (input) { | 92 | function* (input) { |
| 539 | return yield* assertDescendantPid(input.pid).pipe( | 93 | if (input.pid === process.pid) { |
| 540 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 94 | return { |
| 541 | Effect.flatMap(() => | 95 | pid: input.pid, |
| 542 | Effect.try({ | 96 | signal: input.signal, |
| 543 | try: () => { | 97 | signaled: false, |
| 544 | process.kill(input.pid, input.signal); | 98 | message: Option.some("Refusing to signal the T3 server process."), |
| 545 | return { | 99 | }; |
| 546 | pid: input.pid, | 100 | } |
| 547 | signal: input.signal, | 101 | const current = yield* telemetry.refresh.pipe(Effect.option); |
| 548 | signaled: true, | 102 | if (Option.isNone(current)) { |
| 549 | message: Option.none(), | 103 | return { |
| 550 | }; | 104 | pid: input.pid, |
| 551 | }, | 105 | signal: input.signal, |
| 552 | catch: (cause) => | 106 | signaled: false, |
| 553 | new ProcessDiagnosticsSignalFailedError({ | 107 | message: Option.some( |
| 554 | pid: input.pid, | 108 | `Could not refresh process ${input.pid}; refusing to signal a stale identity.`, |
| 555 | signal: input.signal, | 109 | ), |
| 556 | cause, | 110 | }; |
| 557 | }), | 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 | ), | 150 | }).pipe( |
| 560 | Effect.catch((error: ProcessDiagnosticsError) => | 151 | Effect.catch((error) => |
| 561 | Effect.succeed({ | 152 | Effect.succeed({ |
| 562 | pid: input.pid, | 153 | pid: input.pid, |
| 563 | signal: input.signal, | 154 | signal: input.signal, |
| 564 | signaled: false, | 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,4 +165,4 @@ export const make = Effect.gen(function* () { |
| 572 | return ProcessDiagnostics.of({ read, signal }); | 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,255 +1,170 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Option from "effect/Option"; | 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 | import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; | 10 | import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; |
| 7 | 11 | ||
| 8 | describe("ProcessResourceMonitor", () => { | 12 | describe("ProcessResourceMonitor", () => { |
| 9 | it.effect("samples the server root process and descendants", () => | 13 | it.effect("projects resource telemetry history into the legacy diagnostics contract", () => |
| 10 | Effect.sync(() => { | 14 | Effect.gen(function* () { |
| 11 | const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | 15 | const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); |
| 12 | const samples = ProcessResourceMonitor.collectMonitoredSamples({ | 16 | const history: ResourceTelemetryHistoryWithLegacyBuckets = { |
| 13 | serverPid: 100, | 17 | readAt, |
| 14 | sampledAt, | 18 | windowMs: 60_000, |
| 15 | sampledAtMs: DateTime.toEpochMillis(sampledAt), | 19 | bucketMs: 10_000, |
| 16 | rows: [ | 20 | sampleIntervalMs: 1_000, |
| 17 | { | 21 | retainedSampleCount: 2, |
| 18 | pid: 100, | 22 | buckets: [ |
| 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 | }, | ||
| 27 | { | 23 | { |
| 28 | pid: 101, | 24 | startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), |
| 29 | ppid: 100, | 25 | endedAt: readAt, |
| 30 | pgid: 100, | 26 | avgCpuPercent: 15, |
| 31 | status: "S", | 27 | maxCpuPercent: 25, |
| 32 | cpuPercent: 10, | 28 | maxRssBytes: 4_096, |
| 33 | rssBytes: 2_000, | 29 | ioReadBytes: 1_024, |
| 34 | elapsed: "00:20", | 30 | ioWriteBytes: 2_048, |
| 35 | command: "codex app-server", | 31 | maxProcessCount: 2, |
| 36 | }, | 32 | }, |
| 33 | ], | ||
| 34 | legacyBackendBuckets: [ | ||
| 37 | { | 35 | { |
| 38 | pid: 102, | 36 | startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), |
| 39 | ppid: 101, | 37 | endedAt: readAt, |
| 40 | pgid: 100, | 38 | avgCpuPercent: 5, |
| 41 | status: "R", | 39 | maxCpuPercent: 8, |
| 42 | cpuPercent: 50, | 40 | maxRssBytes: 4_096, |
| 43 | rssBytes: 3_000, | 41 | ioReadBytes: 1_024, |
| 44 | elapsed: "00:05", | 42 | ioWriteBytes: 2_048, |
| 45 | command: "rg needle", | 43 | maxProcessCount: 1, |
| 46 | }, | 44 | }, |
| 45 | ], | ||
| 46 | topProcesses: [ | ||
| 47 | { | 47 | { |
| 48 | pid: 200, | 48 | identity: { pid: process.pid, startTimeMs: 100 }, |
| 49 | ppid: 1, | 49 | ppid: 1, |
| 50 | pgid: 200, | 50 | depth: 0, |
| 51 | status: "R", | 51 | name: "node", |
| 52 | cpuPercent: 99, | 52 | command: "t3 server", |
| 53 | rssBytes: 9_000, | 53 | category: "server", |
| 54 | elapsed: "00:05", | 54 | firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), |
| 55 | command: "unrelated", | 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 | ppid: 1, | 69 | ppid: 1, |
| 195 | pgid: 100, | 70 | depth: 0, |
| 196 | status: "S", | 71 | name: "electron", |
| 197 | cpuPercent: 1, | 72 | command: "electron", |
| 198 | rssBytes: 1_000, | 73 | category: "electron-main", |
| 199 | elapsed: "01:00", | 74 | firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), |
| 200 | command: "t3 server", | 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 | }); | 88 | health: { |
| 214 | 89 | native: { | |
| 215 | const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ | 90 | status: "degraded", |
| 216 | samples, | 91 | lastSampleAt: Option.some(readAt), |
| 217 | readAt: sampledAt, | 92 | lastError: Option.some("collector stalled"), |
| 218 | readAtMs: DateTime.toEpochMillis(sampledAt), | 93 | }, |
| 219 | windowMs: 60_000, | 94 | desktop: { |
| 220 | bucketMs: 10_000, | 95 | status: "healthy", |
| 221 | lastFailure: null, | 96 | lastSampleAt: Option.some(readAt), |
| 222 | }); | 97 | lastError: Option.none(), |
| 223 | 98 | }, | |
| 224 | expect(result.topProcesses).toHaveLength(36); | 99 | sidecarVersion: Option.some("0.1.0"), |
| 225 | expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); | 100 | sidecarPid: Option.some(9_000), |
| 226 | }), | 101 | restartCount: 1, |
| 227 | ); | 102 | collectionDurationMicros: 250, |
| 228 | 103 | scannedProcessCount: 80, | |
| 229 | it.effect("exposes bounded failure diagnostics while retaining the exact cause", () => | 104 | retainedProcessCount: 2, |
| 230 | Effect.sync(() => { | 105 | inaccessibleProcessCount: 0, |
| 231 | const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | 106 | }, |
| 232 | const cause = new Error("stderr included credential=secret-value"); | 107 | }; |
| 233 | const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({ | 108 | const telemetry: ResourceTelemetry.ResourceTelemetry["Service"] = { |
| 234 | failureTag: "ProcessDiagnosticsQueryFailedError", | 109 | latest: Effect.die("unused"), |
| 235 | cause, | 110 | changes: Stream.empty, |
| 236 | }); | 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({ | 126 | const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe( |
| 239 | samples: [], | 127 | Effect.flatMap((monitor) => |
| 240 | readAt, | 128 | monitor.readHistory({ |
| 241 | readAtMs: DateTime.toEpochMillis(readAt), | 129 | windowMs: 60_000, |
| 242 | windowMs: 60_000, | 130 | bucketMs: 10_000, |
| 243 | bucketMs: 10_000, | 131 | }), |
| 244 | lastFailure: failure, | 132 | ), |
| 245 | }); | 133 | Effect.provide(layer), |
| 134 | ); | ||
| 246 | 135 | ||
| 247 | expect(failure.cause).toBe(cause); | 136 | expect(result.totalCpuSecondsApprox).toBe(1.5); |
| 248 | expect(Option.getOrThrow(result.error)).toEqual({ | 137 | expect(result.topProcesses).toEqual([ |
| 249 | failureTag: "ProcessDiagnosticsQueryFailedError", | 138 | { |
| 250 | message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).", | 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,55 +1,14 @@ |
| 1 | import { | 1 | import type { |
| 2 | ServerProcessResourceHistoryFailureTag, | 2 | ResourceTelemetryProcessCategory, |
| 3 | type ServerProcessResourceHistoryBucket, | 3 | ServerProcessResourceHistoryInput, |
| 4 | type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType, | 4 | ServerProcessResourceHistoryResult, |
| 5 | type ServerProcessResourceHistoryInput, | ||
| 6 | type ServerProcessResourceHistoryResult, | ||
| 7 | type ServerProcessResourceHistorySummary, | ||
| 8 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 9 | import * as Context from "effect/Context"; | 6 | import * as Context from "effect/Context"; |
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 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"; | 11 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.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 | } | ||
| 53 | 12 | ||
| 54 | export class ProcessResourceMonitor extends Context.Service< | 13 | export class ProcessResourceMonitor extends Context.Service< |
| 55 | ProcessResourceMonitor, | 14 | ProcessResourceMonitor, |
| ... | @@ -60,270 +19,69 @@ export class ProcessResourceMonitor extends Context.Service< | ... | @@ -60,270 +19,69 @@ export class ProcessResourceMonitor extends Context.Service< |
| 60 | } | 19 | } |
| 61 | >()("t3/diagnostics/ProcessResourceMonitor") {} | 20 | >()("t3/diagnostics/ProcessResourceMonitor") {} |
| 62 | 21 | ||
| 63 | function dateTimeFromMillis(ms: number): DateTime.Utc { | 22 | function isLegacyBackendCategory(category: ResourceTelemetryProcessCategory): boolean { |
| 64 | return DateTime.makeUnsafe(ms); | 23 | return ( |
| 65 | } | 24 | category === "server" || |
| 66 | 25 | category === "server-child" || | |
| 67 | function sampleKey(row: Pick<ProcessDiagnostics.ProcessRow, "pid" | "command">): string { | 26 | category === "provider-root" || |
| 68 | return `${row.pid}:${row.command}`; | 27 | category === "terminal-root" |
| 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), | ||
| 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( | 31 | export const make = Effect.fn("makeProcessResourceMonitor")(function* () { |
| 136 | samples: ReadonlyArray<ProcessResourceSample>, | 32 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 137 | ): ReadonlyArray<ServerProcessResourceHistorySummary> { | 33 | const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => |
| 138 | const groups = new Map<string, ProcessResourceSample[]>(); | 34 | telemetry.readHistory(input).pipe( |
| 139 | for (const sample of samples) { | 35 | Effect.map((history) => { |
| 140 | const processSamples = groups.get(sample.processKey) ?? []; | 36 | const topProcesses = history.topProcesses |
| 141 | processSamples.push(sample); | 37 | .filter((entry) => isLegacyBackendCategory(entry.category)) |
| 142 | groups.set(sample.processKey, processSamples); | 38 | .map((entry) => ({ |
| 143 | } | 39 | processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`, |
| 144 | 40 | pid: entry.identity.pid, | |
| 145 | return [...groups.entries()] | 41 | ppid: entry.ppid, |
| 146 | .map(([processKey, processSamples]) => { | 42 | command: entry.command || entry.name || "unknown", |
| 147 | const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); | 43 | depth: entry.depth, |
| 148 | const first = sorted[0]!; | 44 | isServerRoot: entry.category === "server", |
| 149 | const latest = sorted[sorted.length - 1]!; | 45 | firstSeenAt: entry.firstSeenAt, |
| 150 | const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0); | 46 | lastSeenAt: entry.lastSeenAt, |
| 151 | const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent)); | 47 | currentCpuPercent: entry.currentCpuPercent, |
| 152 | const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes)); | 48 | avgCpuPercent: entry.avgCpuPercent, |
| 153 | const cpuSecondsApprox = sorted.reduce( | 49 | maxCpuPercent: entry.maxCpuPercent, |
| 154 | (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), | 50 | cpuSecondsApprox: entry.cpuTimeMs / 1_000, |
| 155 | 0, | 51 | currentRssBytes: entry.currentRssBytes, |
| 156 | ); | 52 | maxRssBytes: entry.peakRssBytes, |
| 157 | 53 | sampleCount: entry.sampleCount, | |
| 158 | return { | 54 | })); |
| 159 | processKey, | 55 | return { |
| 160 | pid: latest.pid, | 56 | readAt: history.readAt, |
| 161 | ppid: latest.ppid, | 57 | windowMs: history.windowMs, |
| 162 | command: latest.command, | 58 | bucketMs: history.bucketMs, |
| 163 | depth: latest.depth, | 59 | sampleIntervalMs: history.sampleIntervalMs, |
| 164 | isServerRoot: latest.isServerRoot, | 60 | retainedSampleCount: history.retainedSampleCount, |
| 165 | firstSeenAt: first.sampledAt, | 61 | totalCpuSecondsApprox: topProcesses.reduce( |
| 166 | lastSeenAt: latest.sampledAt, | 62 | (total, entry) => total + entry.cpuSecondsApprox, |
| 167 | currentCpuPercent: latest.cpuPercent, | 63 | 0, |
| 168 | avgCpuPercent: cpuPercentTotal / sorted.length, | 64 | ), |
| 169 | maxCpuPercent, | 65 | buckets: (history.legacyBackendBuckets ?? history.buckets).map((bucket) => ({ |
| 170 | cpuSecondsApprox, | 66 | startedAt: bucket.startedAt, |
| 171 | currentRssBytes: latest.rssBytes, | 67 | endedAt: bucket.endedAt, |
| 172 | maxRssBytes, | 68 | avgCpuPercent: bucket.avgCpuPercent, |
| 173 | sampleCount: sorted.length, | 69 | maxCpuPercent: bucket.maxCpuPercent, |
| 174 | } satisfies ServerProcessResourceHistorySummary; | 70 | maxRssBytes: bucket.maxRssBytes, |
| 175 | }) | 71 | maxProcessCount: bucket.maxProcessCount, |
| 176 | .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox); | 72 | })), |
| 177 | } | 73 | topProcesses, |
| 178 | 74 | error: history.health.native.lastError.pipe( | |
| 179 | function buildBuckets(input: { | 75 | Option.map((message) => ({ |
| 180 | readonly samples: ReadonlyArray<ProcessResourceSample>; | 76 | failureTag: "ProcessDiagnosticsQueryFailedError" as const, |
| 181 | readonly nowMs: number; | 77 | message, |
| 182 | readonly windowMs: number; | 78 | })), |
| 183 | readonly bucketMs: number; | 79 | ), |
| 184 | }): ReadonlyArray<ServerProcessResourceHistoryBucket> { | 80 | }; |
| 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, | ||
| 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 | return ProcessResourceMonitor.of({ readHistory }); | 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,6 +10,7 @@ import * as OtlpSerialization from "effect/unstable/observability/OtlpSerializat |
| 10 | import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; | 10 | import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; |
| 11 | 11 | ||
| 12 | import * as ServerConfig from "../../config.ts"; | 12 | import * as ServerConfig from "../../config.ts"; |
| 13 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 13 | import { ServerLoggerLive } from "../../serverLogger.ts"; | 14 | import { ServerLoggerLive } from "../../serverLogger.ts"; |
| 14 | import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; | 15 | import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; |
| 15 | 16 | ||
| ... | @@ -18,6 +19,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson; | ... | @@ -18,6 +19,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson; |
| 18 | export const ObservabilityLive = Layer.unwrap( | 19 | export const ObservabilityLive = Layer.unwrap( |
| 19 | Effect.gen(function* () { | 20 | Effect.gen(function* () { |
| 20 | const config = yield* ServerConfig.ServerConfig; | 21 | const config = yield* ServerConfig.ServerConfig; |
| 22 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 21 | 23 | ||
| 22 | const traceReferencesLayer = Layer.mergeAll( | 24 | const traceReferencesLayer = Layer.mergeAll( |
| 23 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), | 25 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), |
| ... | @@ -32,6 +34,14 @@ export const ObservabilityLive = Layer.unwrap( | ... | @@ -32,6 +34,14 @@ export const ObservabilityLive = Layer.unwrap( |
| 32 | maxBytes: config.traceMaxBytes, | 34 | maxBytes: config.traceMaxBytes, |
| 33 | maxFiles: config.traceMaxFiles, | 35 | maxFiles: config.traceMaxFiles, |
| 34 | batchWindowMs: config.traceBatchWindowMs, | 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 | const delegate = | 46 | const delegate = |
| 37 | config.otlpTracesUrl === undefined | 47 | config.otlpTracesUrl === undefined |
apps/server/src/provider/Drivers/ClaudeDriver.ts+2-2| ... | @@ -24,6 +24,7 @@ import { HttpClient } from "effect/unstable/http"; | ... | @@ -24,6 +24,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 24 | import { ChildProcessSpawner } from "effect/unstable/process"; | 24 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 25 | 25 | ||
| 26 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; | 26 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; |
| 27 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 27 | import { ServerConfig } from "../../config.ts"; | 28 | import { ServerConfig } from "../../config.ts"; |
| 28 | import { ServerSettingsService } from "../../serverSettings.ts"; | 29 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 29 | import { ProviderDriverError } from "../Errors.ts"; | 30 | import { ProviderDriverError } from "../Errors.ts"; |
| ... | @@ -57,7 +58,6 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from " | ... | @@ -57,7 +58,6 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from " |
| 57 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); | 58 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 58 | 59 | ||
| 59 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); | 60 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 60 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 61 | const CAPABILITIES_PROBE_TTL = Duration.minutes(5); | 61 | const CAPABILITIES_PROBE_TTL = Duration.minutes(5); |
| 62 | 62 | ||
| 63 | function isClaudeNativeCommandPath(commandPath: string): boolean { | 63 | function isClaudeNativeCommandPath(commandPath: string): boolean { |
| ... | @@ -82,6 +82,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ | ... | @@ -82,6 +82,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 82 | }); | 82 | }); |
| 83 | 83 | ||
| 84 | export type ClaudeDriverEnv = | 84 | export type ClaudeDriverEnv = |
| 85 | | BackgroundPolicy.BackgroundPolicy | ||
| 85 | | ChildProcessSpawner.ChildProcessSpawner | 86 | | ChildProcessSpawner.ChildProcessSpawner |
| 86 | | Crypto.Crypto | 87 | | Crypto.Crypto |
| 87 | | FileSystem.FileSystem | 88 | | FileSystem.FileSystem |
| ... | @@ -190,7 +191,6 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -190,7 +191,6 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 190 | Effect.provideService(HttpClient.HttpClient, httpClient), | 191 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 191 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | 192 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), |
| 192 | ), | 193 | ), |
| 193 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 194 | }).pipe( | 194 | }).pipe( |
| 195 | Effect.mapError( | 195 | Effect.mapError( |
| 196 | (cause) => | 196 | (cause) => |
apps/server/src/provider/Drivers/CodexDriver.ts+2-3| ... | @@ -22,7 +22,6 @@ | ... | @@ -22,7 +22,6 @@ |
| 22 | * @module provider/Drivers/CodexDriver | 22 | * @module provider/Drivers/CodexDriver |
| 23 | */ | 23 | */ |
| 24 | import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 24 | import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 25 | import * as Duration from "effect/Duration"; | ||
| 26 | import * as Crypto from "effect/Crypto"; | 25 | import * as Crypto from "effect/Crypto"; |
| 27 | import * as Effect from "effect/Effect"; | 26 | import * as Effect from "effect/Effect"; |
| 28 | import * as FileSystem from "effect/FileSystem"; | 27 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -32,6 +31,7 @@ import { HttpClient } from "effect/unstable/http"; | ... | @@ -32,6 +31,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 32 | import { ChildProcessSpawner } from "effect/unstable/process"; | 31 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 33 | 32 | ||
| 34 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; | 33 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| 34 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 35 | import { ServerConfig } from "../../config.ts"; | 35 | import { ServerConfig } from "../../config.ts"; |
| 36 | import { ServerSettingsService } from "../../serverSettings.ts"; | 36 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 37 | import { ProviderDriverError } from "../Errors.ts"; | 37 | import { ProviderDriverError } from "../Errors.ts"; |
| ... | @@ -60,7 +60,6 @@ import { | ... | @@ -60,7 +60,6 @@ import { |
| 60 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); | 60 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| 61 | 61 | ||
| 62 | const DRIVER_KIND = ProviderDriverKind.make("codex"); | 62 | const DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 63 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 64 | const UPDATE = makePackageManagedProviderMaintenanceResolver({ | 63 | const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 65 | provider: DRIVER_KIND, | 64 | provider: DRIVER_KIND, |
| 66 | npmPackageName: "@openai/codex", | 65 | npmPackageName: "@openai/codex", |
| ... | @@ -74,6 +73,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ | ... | @@ -74,6 +73,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 74 | * registered driver and the runtime satisfies them once. | 73 | * registered driver and the runtime satisfies them once. |
| 75 | */ | 74 | */ |
| 76 | export type CodexDriverEnv = | 75 | export type CodexDriverEnv = |
| 76 | | BackgroundPolicy.BackgroundPolicy | ||
| 77 | | ChildProcessSpawner.ChildProcessSpawner | 77 | | ChildProcessSpawner.ChildProcessSpawner |
| 78 | | Crypto.Crypto | 78 | | Crypto.Crypto |
| 79 | | FileSystem.FileSystem | 79 | | FileSystem.FileSystem |
| ... | @@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 186 | Effect.provideService(HttpClient.HttpClient, httpClient), | 186 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 187 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | 187 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), |
| 188 | ), | 188 | ), |
| 189 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 190 | }).pipe( | 189 | }).pipe( |
| 191 | Effect.mapError( | 190 | Effect.mapError( |
| 192 | (cause) => | 191 | (cause) => |
apps/server/src/provider/Drivers/CursorDriver.ts+3-4| ... | @@ -12,7 +12,6 @@ | ... | @@ -12,7 +12,6 @@ |
| 12 | * @module provider/Drivers/CursorDriver | 12 | * @module provider/Drivers/CursorDriver |
| 13 | */ | 13 | */ |
| 14 | import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 14 | import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 15 | import * as Duration from "effect/Duration"; | ||
| 16 | import * as Crypto from "effect/Crypto"; | 15 | import * as Crypto from "effect/Crypto"; |
| 17 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 18 | import * as FileSystem from "effect/FileSystem"; | 17 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -21,6 +20,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -21,6 +20,7 @@ import * as Schema from "effect/Schema"; |
| 21 | import { HttpClient } from "effect/unstable/http"; | 20 | import { HttpClient } from "effect/unstable/http"; |
| 22 | import { ChildProcessSpawner } from "effect/unstable/process"; | 21 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 23 | 22 | ||
| 23 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 24 | import { ServerConfig } from "../../config.ts"; | 24 | import { ServerConfig } from "../../config.ts"; |
| 25 | import { ServerSettingsService } from "../../serverSettings.ts"; | 25 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 26 | import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; | 26 | import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; |
| ... | @@ -53,7 +53,6 @@ import { | ... | @@ -53,7 +53,6 @@ import { |
| 53 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | 53 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 54 | 54 | ||
| 55 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); | 55 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 56 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 57 | const UPDATE: ProviderMaintenanceCapabilitiesResolver = { | 56 | const UPDATE: ProviderMaintenanceCapabilitiesResolver = { |
| 58 | resolve: (options) => | 57 | resolve: (options) => |
| 59 | makeProviderMaintenanceCapabilities({ | 58 | makeProviderMaintenanceCapabilities({ |
| ... | @@ -66,6 +65,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { | ... | @@ -66,6 +65,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { |
| 66 | }; | 65 | }; |
| 67 | 66 | ||
| 68 | export type CursorDriverEnv = | 67 | export type CursorDriverEnv = |
| 68 | | BackgroundPolicy.BackgroundPolicy | ||
| 69 | | ChildProcessSpawner.ChildProcessSpawner | 69 | | ChildProcessSpawner.ChildProcessSpawner |
| 70 | | Crypto.Crypto | 70 | | Crypto.Crypto |
| 71 | | FileSystem.FileSystem | 71 | | FileSystem.FileSystem |
| ... | @@ -160,8 +160,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -160,8 +160,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 160 | publishSnapshot, | 160 | publishSnapshot, |
| 161 | stampIdentity, | 161 | stampIdentity, |
| 162 | httpClient, | 162 | httpClient, |
| 163 | }), | 163 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), |
| 164 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 165 | }).pipe( | 164 | }).pipe( |
| 166 | Effect.mapError( | 165 | Effect.mapError( |
| 167 | (cause) => | 166 | (cause) => |
apps/server/src/provider/Drivers/GrokDriver.ts+2-3| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 1 | import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | 2 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -8,6 +7,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -8,6 +7,7 @@ import * as Schema from "effect/Schema"; |
| 8 | import { HttpClient } from "effect/unstable/http"; | 7 | import { HttpClient } from "effect/unstable/http"; |
| 9 | import { ChildProcessSpawner } from "effect/unstable/process"; | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 9 | ||
| 10 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 11 | import { ServerConfig } from "../../config.ts"; | 11 | import { ServerConfig } from "../../config.ts"; |
| 12 | import { ServerSettingsService } from "../../serverSettings.ts"; | 12 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 13 | import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; | 13 | import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; |
| ... | @@ -40,7 +40,6 @@ import { | ... | @@ -40,7 +40,6 @@ import { |
| 40 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 40 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 41 | 41 | ||
| 42 | const DRIVER_KIND = ProviderDriverKind.make("grok"); | 42 | const DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 43 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 44 | const UPDATE = makeStaticProviderMaintenanceResolver( | 43 | const UPDATE = makeStaticProviderMaintenanceResolver( |
| 45 | makeManualOnlyProviderMaintenanceCapabilities({ | 44 | makeManualOnlyProviderMaintenanceCapabilities({ |
| 46 | provider: DRIVER_KIND, | 45 | provider: DRIVER_KIND, |
| ... | @@ -49,6 +48,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( | ... | @@ -49,6 +48,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( |
| 49 | ); | 48 | ); |
| 50 | 49 | ||
| 51 | export type GrokDriverEnv = | 50 | export type GrokDriverEnv = |
| 51 | | BackgroundPolicy.BackgroundPolicy | ||
| 52 | | ChildProcessSpawner.ChildProcessSpawner | 52 | | ChildProcessSpawner.ChildProcessSpawner |
| 53 | | Crypto.Crypto | 53 | | Crypto.Crypto |
| 54 | | FileSystem.FileSystem | 54 | | FileSystem.FileSystem |
| ... | @@ -136,7 +136,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ... | @@ -136,7 +136,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 136 | publishSnapshot, | 136 | publishSnapshot, |
| 137 | httpClient, | 137 | httpClient, |
| 138 | }), | 138 | }), |
| 139 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 140 | }).pipe( | 139 | }).pipe( |
| 141 | Effect.mapError( | 140 | Effect.mapError( |
| 142 | (cause) => | 141 | (cause) => |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-3| ... | @@ -13,7 +13,6 @@ | ... | @@ -13,7 +13,6 @@ |
| 13 | * @module provider/Drivers/OpenCodeDriver | 13 | * @module provider/Drivers/OpenCodeDriver |
| 14 | */ | 14 | */ |
| 15 | import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 15 | import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | import * as Duration from "effect/Duration"; | ||
| 17 | import * as Crypto from "effect/Crypto"; | 16 | import * as Crypto from "effect/Crypto"; |
| 18 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 19 | import * as FileSystem from "effect/FileSystem"; | 18 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -23,6 +22,7 @@ import { HttpClient } from "effect/unstable/http"; | ... | @@ -23,6 +22,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 23 | import { ChildProcessSpawner } from "effect/unstable/process"; | 22 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | 23 | ||
| 25 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; | 24 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; |
| 25 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 26 | import { ServerConfig } from "../../config.ts"; | 26 | import { ServerConfig } from "../../config.ts"; |
| 27 | import { ServerSettingsService } from "../../serverSettings.ts"; | 27 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 28 | import { ProviderDriverError } from "../Errors.ts"; | 28 | import { ProviderDriverError } from "../Errors.ts"; |
| ... | @@ -55,7 +55,6 @@ import { | ... | @@ -55,7 +55,6 @@ import { |
| 55 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | 55 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); |
| 56 | 56 | ||
| 57 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); | 57 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 58 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 59 | 58 | ||
| 60 | function isOpenCodeNativeCommandPath(commandPath: string): boolean { | 59 | function isOpenCodeNativeCommandPath(commandPath: string): boolean { |
| 61 | const normalized = normalizeCommandPath(commandPath); | 60 | const normalized = normalizeCommandPath(commandPath); |
| ... | @@ -78,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ | ... | @@ -78,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 78 | }); | 77 | }); |
| 79 | 78 | ||
| 80 | export type OpenCodeDriverEnv = | 79 | export type OpenCodeDriverEnv = |
| 80 | | BackgroundPolicy.BackgroundPolicy | ||
| 81 | | ChildProcessSpawner.ChildProcessSpawner | 81 | | ChildProcessSpawner.ChildProcessSpawner |
| 82 | | Crypto.Crypto | 82 | | Crypto.Crypto |
| 83 | | FileSystem.FileSystem | 83 | | FileSystem.FileSystem |
| ... | @@ -166,7 +166,6 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -166,7 +166,6 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 166 | Effect.provideService(HttpClient.HttpClient, httpClient), | 166 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 167 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | 167 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), |
| 168 | ), | 168 | ), |
| 169 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 170 | }, | 169 | }, |
| 171 | ).pipe( | 170 | ).pipe( |
| 172 | Effect.mapError( | 171 | Effect.mapError( |
apps/server/src/provider/Layers/ClaudeAdapter.ts+4-1| ... | @@ -1354,6 +1354,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1354,6 +1354,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1354 | stream: "native", | 1354 | stream: "native", |
| 1355 | }) | 1355 | }) |
| 1356 | : undefined); | 1356 | : undefined); |
| 1357 | const managedNativeEventLogger = | ||
| 1358 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; | ||
| 1357 | 1359 | ||
| 1358 | const createQuery = | 1360 | const createQuery = |
| 1359 | options?.createQuery ?? | 1361 | options?.createQuery ?? |
| ... | @@ -1387,7 +1389,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1387,7 +1389,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1387 | const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 1389 | const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 1388 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); | 1390 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); |
| 1389 | 1391 | ||
| 1390 | const logNativeSdkMessage = Effect.fn("logNativeSdkMessage")(function* ( | 1392 | const logNativeSdkMessage = Effect.fnUntraced(function* ( |
| 1391 | context: ClaudeSessionContext, | 1393 | context: ClaudeSessionContext, |
| 1392 | message: SDKMessage, | 1394 | message: SDKMessage, |
| 1393 | ) { | 1395 | ) { |
| ... | @@ -3922,6 +3924,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3922,6 +3924,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3922 | Effect.logError("Failed to emit Claude session shutdown event.", { cause }), | 3924 | Effect.logError("Failed to emit Claude session shutdown event.", { cause }), |
| 3923 | ), | 3925 | ), |
| 3924 | Effect.tap(() => Queue.shutdown(runtimeEventQueue)), | 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,7 +1291,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => |
| 1291 | yield* Scope.close(scope, Exit.void); | 1291 | yield* Scope.close(scope, Exit.void); |
| 1292 | scopeClosed = true; | 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 | NodeAssert.equal(NodeFS.existsSync(threadLogPath), true); | 1295 | NodeAssert.equal(NodeFS.existsSync(threadLogPath), true); |
| 1296 | const contents = NodeFS.readFileSync(threadLogPath, "utf8"); | 1296 | const contents = NodeFS.readFileSync(threadLogPath, "utf8"); |
| 1297 | NodeAssert.match(contents, /NTIVE: .*"message":"native flush test"/); | 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,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 | if (!nativeEventLogger) { | 1650 | if (!nativeEventLogger) { |
| 1651 | return; | 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,14 +5,30 @@ import * as NodePath from "node:path"; |
| 5 | 5 | ||
| 6 | import { ThreadId } from "@t3tools/contracts"; | 6 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | import { assert, describe, it } from "@effect/vitest"; | 7 | import { assert, describe, it } from "@effect/vitest"; |
| 8 | import * as Clock from "effect/Clock"; | ||
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Logger from "effect/Logger"; | 11 | import * as Logger from "effect/Logger"; |
| 10 | import * as Schema from "effect/Schema"; | 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 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); | 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 | function parseLogLine(line: string) { | 32 | function parseLogLine(line: string) { |
| 17 | const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); | 33 | const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); |
| 18 | assert.notEqual(match, null); | 34 | assert.notEqual(match, null); |
| ... | @@ -87,8 +103,8 @@ describe("EventNdjsonLogger", () => { | ... | @@ -87,8 +103,8 @@ describe("EventNdjsonLogger", () => { |
| 87 | ); | 103 | ); |
| 88 | yield* logger.close(); | 104 | yield* logger.close(); |
| 89 | 105 | ||
| 90 | const threadOnePath = NodePath.join(tempDir, "thread-1.log"); | 106 | const threadOnePath = ownedLogPath(basePath, "thread-1"); |
| 91 | const threadTwoPath = NodePath.join(tempDir, "thread-2.log"); | 107 | const threadTwoPath = ownedLogPath(basePath, "thread-2"); |
| 92 | assert.equal(NodeFS.existsSync(threadOnePath), true); | 108 | assert.equal(NodeFS.existsSync(threadOnePath), true); |
| 93 | assert.equal(NodeFS.existsSync(threadTwoPath), true); | 109 | assert.equal(NodeFS.existsSync(threadTwoPath), true); |
| 94 | 110 | ||
| ... | @@ -129,7 +145,7 @@ describe("EventNdjsonLogger", () => { | ... | @@ -129,7 +145,7 @@ describe("EventNdjsonLogger", () => { |
| 129 | yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); | 145 | yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); |
| 130 | yield* logger.close(); | 146 | yield* logger.close(); |
| 131 | 147 | ||
| 132 | const globalPath = NodePath.join(tempDir, "_global.log"); | 148 | const globalPath = ownedLogPath(basePath, "_global"); |
| 133 | assert.equal(NodeFS.existsSync(globalPath), true); | 149 | assert.equal(NodeFS.existsSync(globalPath), true); |
| 134 | const lines = NodeFS.readFileSync(globalPath, "utf8") | 150 | const lines = NodeFS.readFileSync(globalPath, "utf8") |
| 135 | .trim() | 151 | .trim() |
| ... | @@ -138,9 +154,9 @@ describe("EventNdjsonLogger", () => { | ... | @@ -138,9 +154,9 @@ describe("EventNdjsonLogger", () => { |
| 138 | assert.equal(lines.length, 2); | 154 | assert.equal(lines.length, 2); |
| 139 | assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); | 155 | assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); |
| 140 | assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); | 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 | assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); | 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 | assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); | 160 | assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); |
| 145 | } finally { | 161 | } finally { |
| 146 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | 162 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); |
| ... | @@ -148,6 +164,197 @@ describe("EventNdjsonLogger", () => { | ... | @@ -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 | it.effect("serializes concurrent first writes for the same segment", () => | 358 | it.effect("serializes concurrent first writes for the same segment", () => |
| 152 | Effect.gen(function* () { | 359 | Effect.gen(function* () { |
| 153 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | 360 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); |
| ... | @@ -172,7 +379,7 @@ describe("EventNdjsonLogger", () => { | ... | @@ -172,7 +379,7 @@ describe("EventNdjsonLogger", () => { |
| 172 | ); | 379 | ); |
| 173 | yield* logger.close(); | 380 | yield* logger.close(); |
| 174 | 381 | ||
| 175 | const globalPath = NodePath.join(tempDir, "_global.log"); | 382 | const globalPath = ownedLogPath(basePath, "_global"); |
| 176 | assert.equal(NodeFS.existsSync(globalPath), true); | 383 | assert.equal(NodeFS.existsSync(globalPath), true); |
| 177 | const lines = NodeFS.readFileSync(globalPath, "utf8") | 384 | const lines = NodeFS.readFileSync(globalPath, "utf8") |
| 178 | .trim() | 385 | .trim() |
| ... | @@ -196,19 +403,19 @@ describe("EventNdjsonLogger", () => { | ... | @@ -196,19 +403,19 @@ describe("EventNdjsonLogger", () => { |
| 196 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | 403 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); |
| 197 | 404 | ||
| 198 | try { | 405 | try { |
| 199 | const logger = yield* makeEventNdjsonLogger(basePath, { | 406 | const store = yield* makeEventNdjsonLogStore(basePath, { |
| 200 | stream: "native", | ||
| 201 | maxBytes: 120, | 407 | maxBytes: 120, |
| 202 | maxFiles: 2, | 408 | maxFiles: 2, |
| 409 | batchWindowMs: 0, | ||
| 203 | }); | 410 | }); |
| 204 | assert.notEqual(logger, undefined); | 411 | const native = store.logger("native"); |
| 205 | if (!logger) { | 412 | const canonical = store.logger("canonical"); |
| 206 | return; | ||
| 207 | } | ||
| 208 | 413 | ||
| 209 | for (let index = 0; index < 10; index += 1) { | 414 | for (let index = 0; index < 10; index += 1) { |
| 415 | const logger = index % 2 === 0 ? native : canonical; | ||
| 210 | yield* logger.write( | 416 | yield* logger.write( |
| 211 | { | 417 | { |
| 418 | type: "session.started", | ||
| 212 | threadId: "provider-thread-rotate", | 419 | threadId: "provider-thread-rotate", |
| 213 | id: `evt-${index}`, | 420 | id: `evt-${index}`, |
| 214 | payload: "x".repeat(40), | 421 | payload: "x".repeat(40), |
| ... | @@ -216,9 +423,9 @@ describe("EventNdjsonLogger", () => { | ... | @@ -216,9 +423,9 @@ describe("EventNdjsonLogger", () => { |
| 216 | ThreadId.make("thread-rotate"), | 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 | const matchingFiles = NodeFS.readdirSync(tempDir) | 429 | const matchingFiles = NodeFS.readdirSync(tempDir) |
| 223 | .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) | 430 | .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) |
| 224 | .toSorted(); | 431 | .toSorted(); |
| ... | @@ -240,4 +447,143 @@ describe("EventNdjsonLogger", () => { | ... | @@ -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,10 +1,9 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 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 | 5 | * Native and canonical views share batching, rotation, and retention state so |
| 6 | * single effect-style text line in a thread-scoped file. Failures are | 6 | * they cannot race while appending to the same thread-scoped file. |
| 7 | * downgraded to warnings so provider runtime behavior is unaffected. | ||
| 8 | */ | 7 | */ |
| 9 | import * as NodeFS from "node:fs"; | 8 | import * as NodeFS from "node:fs"; |
| 10 | import * as NodePath from "node:path"; | 9 | import * as NodePath from "node:path"; |
| ... | @@ -12,45 +11,147 @@ import * as NodePath from "node:path"; | ... | @@ -12,45 +11,147 @@ import * as NodePath from "node:path"; |
| 12 | import type { ThreadId } from "@t3tools/contracts"; | 11 | import type { ThreadId } from "@t3tools/contracts"; |
| 13 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 12 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 14 | import { errorTag } from "@t3tools/shared/observability"; | 13 | import { errorTag } from "@t3tools/shared/observability"; |
| 14 | import * as Clock from "effect/Clock"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 15 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 16 | import * as Exit from "effect/Exit"; | 17 | import * as Exit from "effect/Exit"; |
| 17 | import * as Logger from "effect/Logger"; | ||
| 18 | import * as Schema from "effect/Schema"; | 18 | import * as Schema from "effect/Schema"; |
| 19 | import * as Scope from "effect/Scope"; | 19 | import * as Scope from "effect/Scope"; |
| 20 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 20 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 21 | 21 | ||
| 22 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; | 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 | const DEFAULT_MAX_FILES = 10; | 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 | const GLOBAL_THREAD_SEGMENT = "_global"; | 35 | const GLOBAL_THREAD_SEGMENT = "_global"; |
| 28 | const LOG_SCOPE = "provider-observability"; | 36 | const LOG_SCOPE = "provider-observability"; |
| 29 | const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); | 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 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; | 49 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; |
| 32 | 50 | ||
| 33 | export interface EventNdjsonLogger { | 51 | export interface EventNdjsonLogger { |
| 34 | readonly filePath: string; | 52 | readonly filePath: string; |
| 35 | write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void, never, never>; | 53 | readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>; |
| 36 | close: () => Effect.Effect<void, never, never>; | 54 | readonly close: () => Effect.Effect<void>; |
| 37 | } | 55 | } |
| 38 | 56 | ||
| 39 | export interface EventNdjsonLoggerOptions { | 57 | export interface EventNdjsonLogStore { |
| 40 | readonly stream: EventNdjsonStream; | 58 | readonly filePath: string; |
| 59 | readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; | ||
| 60 | readonly close: () => Effect.Effect<void>; | ||
| 61 | } | ||
| 62 | |||
| 63 | export interface EventNdjsonLogStoreOptions { | ||
| 41 | readonly maxBytes?: number; | 64 | readonly maxBytes?: number; |
| 42 | readonly maxFiles?: number; | 65 | readonly maxFiles?: number; |
| 43 | readonly batchWindowMs?: number; | 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 { | 121 | export interface PendingRecord { |
| 47 | writeMessage: (message: string) => Effect.Effect<void>; | 122 | readonly stream: EventNdjsonStream; |
| 48 | close: () => Effect.Effect<void>; | 123 | readonly threadSegment: string; |
| 124 | readonly line: string; | ||
| 125 | readonly bytes: number; | ||
| 49 | } | 126 | } |
| 50 | 127 | ||
| 51 | interface LoggerState { | 128 | interface StoreState { |
| 52 | readonly threadWriters: Map<string, ThreadWriter>; | 129 | readonly pending: ReadonlyArray<PendingRecord>; |
| 53 | readonly failedSegments: Set<string>; | 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 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { | 157 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { |
| ... | @@ -62,225 +163,454 @@ function resolveThreadSegment(raw: string | null | undefined): string { | ... | @@ -62,225 +163,454 @@ function resolveThreadSegment(raw: string | null | undefined): string { |
| 62 | return normalized ?? GLOBAL_THREAD_SEGMENT; | 163 | return normalized ?? GLOBAL_THREAD_SEGMENT; |
| 63 | } | 164 | } |
| 64 | 165 | ||
| 65 | function formatLoggerMessage(message: unknown): string { | 166 | function resolveStreamLabel(stream: EventNdjsonStream): string { |
| 66 | if (Array.isArray(message)) { | 167 | return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON"; |
| 67 | return message.map((part) => (typeof part === "string" ? part : String(part))).join(" "); | 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> { | 192 | export function writeBatchedMessages( |
| 73 | return Logger.make( | 193 | sink: Pick<RotatingFileSink, "write">, |
| 74 | ({ date, message }) => | 194 | records: ReadonlyArray<PendingRecord>, |
| 75 | `[${date.toISOString()}] ${streamLabel}: ${formatLoggerMessage(message)}\n`, | 195 | maxBytes: number, |
| 76 | ); | 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 { | 223 | function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean { |
| 80 | switch (stream) { | 224 | if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false; |
| 81 | case "native": | 225 | if (fileName.startsWith(filePrefix)) return true; |
| 82 | return "NTIVE"; | 226 | |
| 83 | case "canonical": | 227 | const descriptor = NodeFS.openSync(filePath, "r"); |
| 84 | case "orchestration": | 228 | try { |
| 85 | default: | 229 | const header = Buffer.alloc(256); |
| 86 | return "CANON"; | 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* ( | 237 | function enforceRetention(input: { |
| 91 | event: unknown, | 238 | readonly directory: string; |
| 92 | ): Effect.fn.Return<string | undefined> { | 239 | readonly maxTotalBytes: number; |
| 93 | return yield* encodeUnknownJsonString(event).pipe( | 240 | readonly maxAgeMs: number; |
| 94 | Effect.catch((error) => | 241 | readonly activeFilePaths: ReadonlySet<string>; |
| 95 | logWarning("failed to serialize provider event log record", { | 242 | readonly filePrefix: string; |
| 96 | errorTag: errorTag(error), | 243 | readonly now: number; |
| 97 | }).pipe(Effect.as(undefined)), | 244 | }): RetentionResult { |
| 98 | ), | 245 | const failures: Array<FileOperationFailure> = []; |
| 99 | ); | 246 | const files: Array<{ filePath: string; mtimeMs: number; size: number }> = []; |
| 100 | }); | 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: { | 255 | for (const entry of entries) { |
| 103 | readonly filePath: string; | 256 | if (!entry.isFile()) continue; |
| 104 | readonly maxBytes: number; | 257 | const filePath = NodePath.join(input.directory, entry.name); |
| 105 | readonly maxFiles: number; | ||
| 106 | readonly batchWindowMs: number; | ||
| 107 | readonly streamLabel: string; | ||
| 108 | }): Effect.fn.Return<ThreadWriter | undefined> { | ||
| 109 | const sinkResult = yield* Effect.sync(() => { | ||
| 110 | try { | 258 | try { |
| 111 | return { | 259 | if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue; |
| 112 | ok: true as const, | 260 | const stat = NodeFS.statSync(filePath); |
| 113 | sink: new RotatingFileSink({ | 261 | files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size }); |
| 114 | filePath: input.filePath, | 262 | } catch (cause) { |
| 115 | maxBytes: input.maxBytes, | 263 | failures.push({ filePath, cause }); |
| 116 | maxFiles: input.maxFiles, | ||
| 117 | throwOnError: true, | ||
| 118 | }), | ||
| 119 | }; | ||
| 120 | } catch (error) { | ||
| 121 | return { ok: false as const, error }; | ||
| 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) { | 285 | for (const file of retained.toSorted( |
| 126 | yield* logWarning("failed to initialize provider thread log file", { | 286 | (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath), |
| 127 | filePath: input.filePath, | 287 | )) { |
| 128 | errorTag: errorTag(sinkResult.error), | 288 | if (totalBytes <= input.maxTotalBytes) break; |
| 129 | }); | 289 | remove(file); |
| 130 | return undefined; | ||
| 131 | } | 290 | } |
| 132 | 291 | ||
| 133 | const sink = sinkResult.sink; | 292 | return { failures }; |
| 134 | const scope = yield* Scope.make(); | 293 | } |
| 135 | const lineLogger = makeLineLogger(input.streamLabel); | 294 | |
| 136 | const batchedLogger = yield* Logger.batched(lineLogger, { | 295 | function validateOption(input: { |
| 137 | window: input.batchWindowMs, | 296 | readonly filePath: string; |
| 138 | flush: Effect.fn("makeThreadWriter.flush")(function* (messages) { | 297 | readonly option: string; |
| 139 | const flushResult = yield* Effect.sync(() => { | 298 | readonly value: number; |
| 140 | try { | 299 | readonly minimum: number; |
| 141 | for (const message of messages) { | 300 | }): EventNdjsonLogConfigurationError | undefined { |
| 142 | sink.write(message); | 301 | if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined; |
| 143 | } | 302 | return new EventNdjsonLogConfigurationError(input); |
| 144 | return { ok: true as const }; | 303 | } |
| 145 | } catch (error) { | 304 | |
| 146 | return { ok: false as const, error }; | 305 | function resolveOptions( |
| 147 | } | 306 | filePath: string, |
| 148 | }); | 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) { | 340 | function drainPending(input: { |
| 151 | yield* logWarning("provider event log batch flush failed", { | 341 | readonly directory: string; |
| 152 | filePath: input.filePath, | 342 | readonly options: ResolvedOptions; |
| 153 | errorTag: errorTag(flushResult.error), | 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 | }), | 383 | } |
| 157 | }).pipe(Effect.provideService(Scope.Scope, scope)); | ||
| 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 { | 404 | const retentionDue = |
| 162 | writeMessage(message: string) { | 405 | input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; |
| 163 | return Effect.log(message).pipe(Effect.provide(loggerLayer)); | 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() { | 429 | { |
| 166 | return Scope.close(scope, Exit.void); | 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 | filePath: string, | 451 | filePath: string, |
| 173 | options: EventNdjsonLoggerOptions, | 452 | options: EventNdjsonLogStoreOptions = {}, |
| 174 | ): Effect.fn.Return<EventNdjsonLogger | undefined> { | 453 | ): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> { |
| 175 | const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; | 454 | const resolved = yield* resolveOptions(filePath, options); |
| 176 | const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; | 455 | const directory = NodePath.dirname(filePath); |
| 177 | const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS; | 456 | const filePrefix = providerLogPrefix(filePath); |
| 178 | const streamLabel = resolveStreamLabel(options.stream); | 457 | |
| 179 | 458 | yield* Effect.try({ | |
| 180 | const directoryReady = yield* Effect.sync(() => { | 459 | try: () => NodeFS.mkdirSync(directory, { recursive: true }), |
| 181 | try { | 460 | catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }), |
| 182 | NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true }); | ||
| 183 | return true; | ||
| 184 | } catch (error) { | ||
| 185 | return { ok: false as const, error }; | ||
| 186 | } | ||
| 187 | }); | 461 | }); |
| 188 | if (directoryReady !== true) { | 462 | |
| 189 | yield* logWarning("failed to create provider event log directory", { | 463 | const initializedAt = yield* Clock.currentTimeMillis; |
| 190 | filePath, | 464 | const initialRetention = yield* Effect.sync(() => |
| 191 | errorTag: errorTag(directoryReady.error), | 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>({ | 481 | const stateRef = yield* SynchronizedRef.make<StoreState>({ |
| 197 | threadWriters: new Map(), | 482 | pending: [], |
| 198 | failedSegments: new Set(), | 483 | pendingBytes: 0, |
| 484 | sinks: new Map(), | ||
| 485 | flushScheduled: false, | ||
| 486 | closed: false, | ||
| 487 | lastRetentionAt: initializedAt, | ||
| 199 | }); | 488 | }); |
| 200 | 489 | const timerScope = yield* Scope.make(); | |
| 201 | const resolveThreadWriter = Effect.fn("resolveThreadWriter")(function* ( | 490 | |
| 202 | threadSegment: string, | 491 | const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) { |
| 203 | ): Effect.fn.Return<ThreadWriter | undefined> { | 492 | const startedAt = yield* Clock.currentTimeMillis; |
| 204 | return yield* SynchronizedRef.modifyEffect(stateRef, (state) => { | 493 | const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) => |
| 205 | if (state.failedSegments.has(threadSegment)) { | 494 | Effect.sync(() => |
| 206 | return Effect.succeed([undefined, state] as const); | 495 | drainPending({ |
| 207 | } | 496 | directory, |
| 208 | 497 | options: resolved, | |
| 209 | const existing = state.threadWriters.get(threadSegment); | 498 | state, |
| 210 | if (existing) { | 499 | filePrefix, |
| 211 | return Effect.succeed([existing, state] as const); | 500 | now: startedAt, |
| 212 | } | 501 | timerFired, |
| 213 | 502 | close, | |
| 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; | ||
| 243 | }), | 503 | }), |
| 244 | ); | 504 | ), |
| 245 | }); | 505 | ); |
| 246 | }); | ||
| 247 | 506 | ||
| 248 | const write = Effect.fn("write")(function* (event: unknown, threadId: ThreadId | null) { | 507 | for (const failure of result.failures) { |
| 249 | const threadSegment = resolveThreadSegment(threadId); | 508 | yield* logWarning("provider event log write or retention failed", { |
| 250 | const message = yield* toLogMessage(event); | 509 | filePath: failure.filePath, |
| 251 | if (!message) { | 510 | errorTag: errorTag(failure.cause), |
| 252 | return; | 511 | }); |
| 253 | } | 512 | } |
| 254 | 513 | ||
| 255 | const writer = yield* resolveThreadWriter(threadSegment); | 514 | if (resolved.attribution && result.attributions.length > 0) { |
| 256 | if (!writer) { | 515 | const completedAt = yield* Clock.currentTimeMillis; |
| 257 | return; | 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* () { | 552 | const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>(); |
| 264 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => | 553 | const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { |
| 265 | Effect.gen(function* () { | 554 | const existing = loggerViews.get(stream); |
| 266 | for (const writer of state.threadWriters.values()) { | 555 | if (existing) return existing; |
| 267 | yield* writer.close(); | 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 [ | 595 | const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; |
| 271 | undefined, | 596 | loggerViews.set(stream, view); |
| 272 | { | 597 | return view; |
| 273 | threadWriters: new Map<string, ThreadWriter>(), | 598 | }; |
| 274 | failedSegments: new Set<string>(), | ||
| 275 | }, | ||
| 276 | ] as const; | ||
| 277 | }), | ||
| 278 | ); | ||
| 279 | }); | ||
| 280 | 599 | ||
| 281 | return { | 600 | return { filePath, logger, close } satisfies EventNdjsonLogStore; |
| 282 | filePath, | 601 | }); |
| 283 | write, | 602 | |
| 284 | close, | 603 | export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( |
| 285 | } satisfies EventNdjsonLogger; | 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,6 +1,6 @@ |
| 1 | /** | 1 | /** |
| 2 | * ProviderEventLoggers — single observability service that owns the two | 2 | * ProviderEventLoggers — single observability service that owns the shared |
| 3 | * shared NDJSON streams the provider runtime writes: | 3 | * provider event log store and exposes its two runtime views: |
| 4 | * | 4 | * |
| 5 | * - `native` — provider-protocol events as the SDK emits them, written | 5 | * - `native` — provider-protocol events as the SDK emits them, written |
| 6 | * from inside each `<X>Adapter` factory. | 6 | * from inside each `<X>Adapter` factory. |
| ... | @@ -13,17 +13,14 @@ | ... | @@ -13,17 +13,14 @@ |
| 13 | * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)` | 13 | * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)` |
| 14 | * call site where we can hand an `EventNdjsonLogger` in by hand. | 14 | * call site where we can hand an `EventNdjsonLogger` in by hand. |
| 15 | * - Multiple driver instances per kind (`codex_personal`, `codex_work`) | 15 | * - Multiple driver instances per kind (`codex_personal`, `codex_work`) |
| 16 | * should share one underlying log writer per stream — opening N writers | 16 | * must share one underlying log store — opening N writers against the |
| 17 | * against the same rotating file would race the rotation logic. Owning | 17 | * same rotating file would race the rotation logic. Owning the loggers on |
| 18 | * the loggers on a single tag keeps that invariant intact. | 18 | * a single tag keeps that invariant intact. |
| 19 | * - Tests can swap one (or both) loggers with in-memory recorders by | 19 | * - Tests can swap one (or both) loggers with in-memory recorders by |
| 20 | * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of | 20 | * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of |
| 21 | * juggling per-Layer option threading. | 21 | * juggling per-Layer option threading. |
| 22 | * | 22 | * |
| 23 | * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when | 23 | * Both fields are optional because observability must not prevent startup. |
| 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`. | ||
| 27 | * | 24 | * |
| 28 | * @module provider/Layers/ProviderEventLoggers | 25 | * @module provider/Layers/ProviderEventLoggers |
| 29 | */ | 26 | */ |
| ... | @@ -32,24 +29,23 @@ import * as Effect from "effect/Effect"; | ... | @@ -32,24 +29,23 @@ import * as Effect from "effect/Effect"; |
| 32 | import * as Layer from "effect/Layer"; | 29 | import * as Layer from "effect/Layer"; |
| 33 | 30 | ||
| 34 | import { ServerConfig } from "../../config.ts"; | 31 | import { ServerConfig } from "../../config.ts"; |
| 35 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 32 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; |
| 36 | 33 | import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; | |
| 37 | export interface ProviderEventLoggersShape { | ||
| 38 | readonly native: EventNdjsonLogger | undefined; | ||
| 39 | readonly canonical: EventNdjsonLogger | undefined; | ||
| 40 | } | ||
| 41 | 34 | ||
| 42 | /** | 35 | /** |
| 43 | * Shared logger pair for native + canonical provider event streams. | 36 | * Shared logger pair for native + canonical provider event streams. |
| 44 | * | 37 | * |
| 45 | * Service value is intentionally a struct of two optional loggers rather | 38 | * Service value is intentionally a struct of two optional loggers rather |
| 46 | * than two parallel tags. Construction site is one place | 39 | * than two parallel tags. Construction site is one place |
| 47 | * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read | 40 | * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the |
| 48 | * one tag and pluck the field they need. | 41 | * field they need. |
| 49 | */ | 42 | */ |
| 50 | export class ProviderEventLoggers extends Context.Service< | 43 | export class ProviderEventLoggers extends Context.Service< |
| 51 | ProviderEventLoggers, | 44 | ProviderEventLoggers, |
| 52 | ProviderEventLoggersShape | 45 | { |
| 46 | readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; | ||
| 47 | readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; | ||
| 48 | } | ||
| 53 | >()("t3/provider/Layers/ProviderEventLoggers") {} | 49 | >()("t3/provider/Layers/ProviderEventLoggers") {} |
| 54 | 50 | ||
| 55 | /** | 51 | /** |
| ... | @@ -57,29 +53,38 @@ export class ProviderEventLoggers extends Context.Service< | ... | @@ -57,29 +53,38 @@ export class ProviderEventLoggers extends Context.Service< |
| 57 | * + canonical logging entirely. Keeps the tag non-optional in the type | 53 | * + canonical logging entirely. Keeps the tag non-optional in the type |
| 58 | * system while letting the runtime treat absence as a no-op. | 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 | native: undefined, | 57 | native: undefined, |
| 62 | canonical: undefined, | 58 | canonical: undefined, |
| 63 | }; | 59 | }; |
| 64 | 60 | ||
| 65 | /** | 61 | /** |
| 66 | * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`. | 62 | * Builds both stream views over one shared store. Setup failures are logged |
| 67 | * If the directory create fails for either stream, the corresponding field | 63 | * and downgraded to the no-op service so diagnostics never block startup. |
| 68 | * is `undefined` and writes from that stream become no-ops downstream. | ||
| 69 | */ | 64 | */ |
| 70 | export const ProviderEventLoggersLive = Layer.effect( | 65 | export const make = Effect.gen(function* () { |
| 71 | ProviderEventLoggers, | 66 | const { providerEventLogPath } = yield* ServerConfig; |
| 72 | Effect.gen(function* () { | 67 | const attribution = yield* ResourceAttribution.ResourceAttribution; |
| 73 | const { providerEventLogPath } = yield* ServerConfig; | 68 | const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { |
| 74 | const native = yield* makeEventNdjsonLogger(providerEventLogPath, { | 69 | attribution, |
| 75 | stream: "native", | 70 | }).pipe( |
| 76 | }); | 71 | Effect.catch((error) => |
| 77 | const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { | 72 | Effect.logWarning(error.message, { error }).pipe( |
| 78 | stream: "canonical", | 73 | Effect.annotateLogs({ scope: "provider-observability" }), |
| 79 | }); | 74 | Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined), |
| 80 | return { | 75 | ), |
| 81 | native, | 76 | ), |
| 82 | canonical, | 77 | ); |
| 83 | } satisfies ProviderEventLoggersShape; | 78 | |
| 84 | }), | 79 | if (!store) { |
| 85 | ); | 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,10 +34,13 @@ import { |
| 34 | type ProviderInstanceConfigMap, | 34 | type ProviderInstanceConfigMap, |
| 35 | ProviderInstanceId, | 35 | ProviderInstanceId, |
| 36 | } from "@t3tools/contracts"; | 36 | } from "@t3tools/contracts"; |
| 37 | import * as DateTime from "effect/DateTime"; | ||
| 37 | import * as Effect from "effect/Effect"; | 38 | import * as Effect from "effect/Effect"; |
| 38 | import * as Layer from "effect/Layer"; | 39 | import * as Layer from "effect/Layer"; |
| 40 | import * as Stream from "effect/Stream"; | ||
| 39 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 41 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 40 | 42 | ||
| 43 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 41 | import { ServerConfig } from "../../config.ts"; | 44 | import { ServerConfig } from "../../config.ts"; |
| 42 | import { ServerSettingsService } from "../../serverSettings.ts"; | 45 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 43 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; | 46 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; |
| ... | @@ -56,6 +59,37 @@ const TestHttpClientLive = Layer.succeed( | ... | @@ -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 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ | 93 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ |
| 60 | enabled: false, | 94 | enabled: false, |
| 61 | binaryPath: "codex", | 95 | binaryPath: "codex", |
| ... | @@ -109,6 +143,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | ... | @@ -109,6 +143,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { |
| 109 | prefix: "provider-instance-registry-test", | 143 | prefix: "provider-instance-registry-test", |
| 110 | }).pipe( | 144 | }).pipe( |
| 111 | Layer.provideMerge(NodeServices.layer), | 145 | Layer.provideMerge(NodeServices.layer), |
| 146 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 112 | Layer.provideMerge(ServerSettingsService.layerTest()), | 147 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 113 | Layer.provideMerge(TestHttpClientLive), | 148 | Layer.provideMerge(TestHttpClientLive), |
| 114 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 149 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| ... | @@ -247,6 +282,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -247,6 +282,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 247 | prefix: "provider-instance-registry-all-drivers-test", | 282 | prefix: "provider-instance-registry-all-drivers-test", |
| 248 | }).pipe( | 283 | }).pipe( |
| 249 | Layer.provideMerge(infraLayer), | 284 | Layer.provideMerge(infraLayer), |
| 285 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 250 | Layer.provideMerge(ServerSettingsService.layerTest()), | 286 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 251 | Layer.provideMerge(TestHttpClientLive), | 287 | Layer.provideMerge(TestHttpClientLive), |
| 252 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 288 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+44| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, it, assert } from "@effect/vitest"; | 2 | import { describe, it, assert } from "@effect/vitest"; |
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 5 | import * as Fiber from "effect/Fiber"; | 6 | import * as Fiber from "effect/Fiber"; |
| ... | @@ -32,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ... | @@ -32,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 32 | 33 | ||
| 33 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | 34 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 34 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; | 35 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; |
| 36 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 35 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | 37 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 36 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | 38 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; |
| 37 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; | 39 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; |
| ... | @@ -65,6 +67,7 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; | ... | @@ -65,6 +67,7 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; |
| 65 | // ── Test helpers ──────────────────────────────────────────────────── | 67 | // ── Test helpers ──────────────────────────────────────────────────── |
| 66 | 68 | ||
| 67 | const encoder = new TextEncoder(); | 69 | const encoder = new TextEncoder(); |
| 70 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 68 | 71 | ||
| 69 | const TestHttpClientLive = Layer.succeed( | 72 | const TestHttpClientLive = Layer.succeed( |
| 70 | HttpClient.HttpClient, | 73 | HttpClient.HttpClient, |
| ... | @@ -73,6 +76,35 @@ const TestHttpClientLive = Layer.succeed( | ... | @@ -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 | function selectDescriptor( | 108 | function selectDescriptor( |
| 77 | id: string, | 109 | id: string, |
| 78 | label: string, | 110 | label: string, |
| ... | @@ -297,6 +329,11 @@ function makeMutableServerSettingsService( | ... | @@ -297,6 +329,11 @@ function makeMutableServerSettingsService( |
| 297 | get streamChanges() { | 329 | get streamChanges() { |
| 298 | return Stream.fromPubSub(changes); | 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 | } satisfies ServerSettingsModule.ServerSettingsService["Service"]; | 337 | } satisfies ServerSettingsModule.ServerSettingsService["Service"]; |
| 301 | }); | 338 | }); |
| 302 | } | 339 | } |
| ... | @@ -1000,6 +1037,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1000,6 +1037,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1000 | prefix: "t3-provider-registry-merged-persist-", | 1037 | prefix: "t3-provider-registry-merged-persist-", |
| 1001 | }), | 1038 | }), |
| 1002 | ), | 1039 | ), |
| 1040 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 1003 | Layer.provideMerge(NodeServices.layer), | 1041 | Layer.provideMerge(NodeServices.layer), |
| 1004 | ), | 1042 | ), |
| 1005 | ).pipe(Scope.provide(scope)); | 1043 | ).pipe(Scope.provide(scope)); |
| ... | @@ -1235,6 +1273,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1235,6 +1273,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1235 | prefix: "t3-provider-registry-refresh-failure-", | 1273 | prefix: "t3-provider-registry-refresh-failure-", |
| 1236 | }), | 1274 | }), |
| 1237 | ), | 1275 | ), |
| 1276 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 1238 | Layer.provideMerge(NodeServices.layer), | 1277 | Layer.provideMerge(NodeServices.layer), |
| 1239 | ), | 1278 | ), |
| 1240 | ).pipe(Scope.provide(scope)); | 1279 | ).pipe(Scope.provide(scope)); |
| ... | @@ -1342,6 +1381,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1342,6 +1381,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1342 | prefix: "t3-provider-registry-sync-failure-", | 1381 | prefix: "t3-provider-registry-sync-failure-", |
| 1343 | }), | 1382 | }), |
| 1344 | ), | 1383 | ), |
| 1384 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 1345 | Layer.provideMerge(NodeServices.layer), | 1385 | Layer.provideMerge(NodeServices.layer), |
| 1346 | ), | 1386 | ), |
| 1347 | ).pipe(Scope.provide(scope)); | 1387 | ).pipe(Scope.provide(scope)); |
| ... | @@ -1446,6 +1486,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1446,6 +1486,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1446 | ), | 1486 | ), |
| 1447 | ), | 1487 | ), |
| 1448 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 1488 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1489 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 1449 | // NO spawner mock — `ChildProcessSpawner` is supplied by the | 1490 | // NO spawner mock — `ChildProcessSpawner` is supplied by the |
| 1450 | // outer `NodeServices.layer` on `it.layer(...)` and will | 1491 | // outer `NodeServices.layer` on `it.layer(...)` and will |
| 1451 | // genuinely spawn a subprocess. The missing-binary ENOENT is | 1492 | // genuinely spawn a subprocess. The missing-binary ENOENT is |
| ... | @@ -1545,6 +1586,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1545,6 +1586,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1545 | }), | 1586 | }), |
| 1546 | ), | 1587 | ), |
| 1547 | Layer.provideMerge(NodeServices.layer), | 1588 | Layer.provideMerge(NodeServices.layer), |
| 1589 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 1548 | ); | 1590 | ); |
| 1549 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | 1591 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| 1550 | Scope.provide(scope), | 1592 | Scope.provide(scope), |
| ... | @@ -1660,6 +1702,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1660,6 +1702,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1660 | ), | 1702 | ), |
| 1661 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 1703 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1662 | Layer.provideMerge(NodeServices.layer), | 1704 | Layer.provideMerge(NodeServices.layer), |
| 1705 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 1663 | ); | 1706 | ); |
| 1664 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | 1707 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| 1665 | Scope.provide(scope), | 1708 | Scope.provide(scope), |
| ... | @@ -1720,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1720,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1720 | ), | 1763 | ), |
| 1721 | ), | 1764 | ), |
| 1722 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 1765 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1766 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | ||
| 1723 | Layer.provideMerge( | 1767 | Layer.provideMerge( |
| 1724 | mockCommandSpawnerLayer((command, args) => { | 1768 | mockCommandSpawnerLayer((command, args) => { |
| 1725 | if (command === "cursor-agent") { | 1769 | if (command === "cursor-agent") { |
apps/server/src/provider/makeManagedServerProvider.test.ts+172-5| ... | @@ -1,16 +1,28 @@ | ... | @@ -1,16 +1,28 @@ |
| 1 | import { describe, it, assert } from "@effect/vitest"; | 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 | import { createModelCapabilities } from "@t3tools/shared/model"; | 8 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; | 13 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as PubSub from "effect/PubSub"; | 15 | import * as PubSub from "effect/PubSub"; |
| 8 | import * as Ref from "effect/Ref"; | 16 | import * as Ref from "effect/Ref"; |
| 9 | import * as Stream from "effect/Stream"; | 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 | import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; | 22 | import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; |
| 12 | 23 | ||
| 13 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | 24 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); |
| 25 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 14 | const fastModeCapabilities = createModelCapabilities({ | 26 | const fastModeCapabilities = createModelCapabilities({ |
| 15 | optionDescriptors: [ | 27 | optionDescriptors: [ |
| 16 | { | 28 | { |
| ... | @@ -87,6 +99,43 @@ const refreshedSnapshotSecond: ServerProvider = { | ... | @@ -87,6 +99,43 @@ const refreshedSnapshotSecond: ServerProvider = { |
| 87 | message: "Refreshed provider availability again.", | 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 | const enrichedSnapshotSecond: ServerProvider = { | 139 | const enrichedSnapshotSecond: ServerProvider = { |
| 91 | ...refreshedSnapshotSecond, | 140 | ...refreshedSnapshotSecond, |
| 92 | checkedAt: "2026-04-10T00:00:04.000Z", | 141 | checkedAt: "2026-04-10T00:00:04.000Z", |
| ... | @@ -140,7 +189,125 @@ describe("makeManagedServerProvider", () => { | ... | @@ -140,7 +189,125 @@ describe("makeManagedServerProvider", () => { |
| 140 | assert.deepStrictEqual(latest, refreshedSnapshot); | 189 | assert.deepStrictEqual(latest, refreshedSnapshot); |
| 141 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | 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 | it.effect("reruns the provider check when streamed settings change", () => | 313 | it.effect("reruns the provider check when streamed settings change", () => |
| ... | @@ -185,7 +352,7 @@ describe("makeManagedServerProvider", () => { | ... | @@ -185,7 +352,7 @@ describe("makeManagedServerProvider", () => { |
| 185 | assert.deepStrictEqual(latest, refreshedSnapshotSecond); | 352 | assert.deepStrictEqual(latest, refreshedSnapshotSecond); |
| 186 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | 353 | assert.strictEqual(yield* Ref.get(checkCalls), 2); |
| 187 | }), | 354 | }), |
| 188 | ), | 355 | ).pipe(Effect.provide(AlwaysRunTestLayer)), |
| 189 | ); | 356 | ); |
| 190 | 357 | ||
| 191 | it.effect("streams supplemental snapshot updates after the base provider check completes", () => | 358 | it.effect("streams supplemental snapshot updates after the base provider check completes", () => |
| ... | @@ -223,7 +390,7 @@ describe("makeManagedServerProvider", () => { | ... | @@ -223,7 +390,7 @@ describe("makeManagedServerProvider", () => { |
| 223 | assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); | 390 | assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); |
| 224 | assert.deepStrictEqual(latest, enrichedSnapshot); | 391 | assert.deepStrictEqual(latest, enrichedSnapshot); |
| 225 | }), | 392 | }), |
| 226 | ), | 393 | ).pipe(Effect.provide(AlwaysRunTestLayer)), |
| 227 | ); | 394 | ); |
| 228 | 395 | ||
| 229 | it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => | 396 | it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => |
| ... | @@ -284,6 +451,6 @@ describe("makeManagedServerProvider", () => { | ... | @@ -284,6 +451,6 @@ describe("makeManagedServerProvider", () => { |
| 284 | ]); | 451 | ]); |
| 285 | assert.deepStrictEqual(latest, enrichedSnapshotSecond); | 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,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 | import * as Duration from "effect/Duration"; | 7 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 4 | import * as Equal from "effect/Equal"; | 9 | import * as Equal from "effect/Equal"; |
| 5 | import * as Fiber from "effect/Fiber"; | 10 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as PubSub from "effect/PubSub"; | 11 | import * as PubSub from "effect/PubSub"; |
| 12 | import * as Queue from "effect/Queue"; | ||
| 7 | import * as Ref from "effect/Ref"; | 13 | import * as Ref from "effect/Ref"; |
| 8 | import * as Scope from "effect/Scope"; | 14 | import * as Scope from "effect/Scope"; |
| 9 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 10 | import * as Semaphore from "effect/Semaphore"; | 16 | import * as Semaphore from "effect/Semaphore"; |
| 11 | 17 | ||
| 18 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| 19 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 12 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | 20 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; |
| 13 | import { ServerSettingsError } from "@t3tools/contracts"; | ||
| 14 | 21 | ||
| 15 | interface ProviderSnapshotState { | 22 | interface ProviderSnapshotState { |
| 16 | readonly snapshot: ServerProvider; | 23 | readonly snapshot: ServerProvider; |
| ... | @@ -33,7 +40,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( | ... | @@ -33,7 +40,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 33 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | 40 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; |
| 34 | }) => Effect.Effect<void>; | 41 | }) => Effect.Effect<void>; |
| 35 | readonly refreshInterval?: Duration.Input; | 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 | const refreshSemaphore = yield* Semaphore.make(1); | 50 | const refreshSemaphore = yield* Semaphore.make(1); |
| 38 | const changesPubSub = yield* Effect.acquireRelease( | 51 | const changesPubSub = yield* Effect.acquireRelease( |
| 39 | PubSub.unbounded<ServerProvider>(), | 52 | PubSub.unbounded<ServerProvider>(), |
| ... | @@ -134,13 +147,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( | ... | @@ -134,13 +147,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 134 | return yield* applySnapshot(nextSettings, { forceRefresh: true }); | 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 | yield* Stream.runForEach(input.streamSettings, (nextSettings) => | 186 | yield* Stream.runForEach(input.streamSettings, (nextSettings) => |
| 138 | Effect.asVoid(applySnapshot(nextSettings)), | 187 | Effect.asVoid(applySnapshot(nextSettings)), |
| 139 | ).pipe(Effect.forkScoped); | 188 | ).pipe(Effect.forkScoped); |
| 140 | 189 | ||
| 141 | yield* Effect.forever( | 190 | yield* Effect.forever( |
| 142 | Effect.sleep(input.refreshInterval ?? "60 seconds").pipe( | 191 | getRefreshInterval.pipe( |
| 143 | Effect.flatMap(() => refreshSnapshot()), | 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 | Effect.ignoreCause({ log: true }), | 212 | Effect.ignoreCause({ log: true }), |
| 145 | ), | 213 | ), |
| 146 | ).pipe(Effect.forkScoped); | 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,6 +71,7 @@ import { vi } from "vite-plus/test"; |
| 71 | 71 | ||
| 72 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | 72 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 73 | 73 | ||
| 74 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | ||
| 74 | import * as ServerConfig from "./config.ts"; | 75 | import * as ServerConfig from "./config.ts"; |
| 75 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; | 76 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; |
| 76 | import { makeRoutesLayer } from "./server.ts"; | 77 | import { makeRoutesLayer } from "./server.ts"; |
| ... | @@ -116,6 +117,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; | ... | @@ -116,6 +117,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| 116 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 117 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 117 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 118 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 118 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 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 | import * as Data from "effect/Data"; | 124 | import * as Data from "effect/Data"; |
| 120 | 125 | ||
| 121 | const defaultProjectId = ProjectId.make("project-default"); | 126 | const defaultProjectId = ProjectId.make("project-default"); |
| ... | @@ -355,6 +360,10 @@ const buildAppUnderTest = (options?: { | ... | @@ -355,6 +360,10 @@ const buildAppUnderTest = (options?: { |
| 355 | >; | 360 | >; |
| 356 | relayClient?: Partial<RelayClient.RelayClient["Service"]>; | 361 | relayClient?: Partial<RelayClient.RelayClient["Service"]>; |
| 357 | cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManager["Service"]>; | 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 | Effect.gen(function* () { | 369 | Effect.gen(function* () { |
| ... | @@ -531,6 +540,15 @@ const buildAppUnderTest = (options?: { | ... | @@ -531,6 +540,15 @@ const buildAppUnderTest = (options?: { |
| 531 | ...options.layers.vcsStatusBroadcaster, | 540 | ...options.layers.vcsStatusBroadcaster, |
| 532 | }) | 541 | }) |
| 533 | : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); | 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 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { | 553 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { |
| 536 | disableListenLog: true, | 554 | disableListenLog: true, |
| ... | @@ -745,6 +763,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -745,6 +763,7 @@ const buildAppUnderTest = (options?: { |
| 745 | ); | 763 | ); |
| 746 | 764 | ||
| 747 | const appLayer = servedRoutesLayer.pipe( | 765 | const appLayer = servedRoutesLayer.pipe( |
| 766 | Layer.provide(resourceTelemetryLayer), | ||
| 748 | Layer.provide( | 767 | Layer.provide( |
| 749 | Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ | 768 | Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ |
| 750 | record: () => Effect.void, | 769 | record: () => Effect.void, |
| ... | @@ -767,6 +786,58 @@ const buildAppUnderTest = (options?: { | ... | @@ -767,6 +786,58 @@ const buildAppUnderTest = (options?: { |
| 767 | ...options?.layers?.serverRuntimeStartup, | 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 | Layer.provide( | 841 | Layer.provide( |
| 771 | Layer.mock(ServerEnvironment.ServerEnvironment)({ | 842 | Layer.mock(ServerEnvironment.ServerEnvironment)({ |
| 772 | getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), | 843 | getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), |
| ... | @@ -4395,6 +4466,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4395,6 +4466,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4395 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => | 4486 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => |
| 4399 | Effect.gen(function* () { | 4487 | Effect.gen(function* () { |
| 4400 | const nextProviders = [ | 4488 | const nextProviders = [ |
apps/server/src/server.ts+46-6| ... | @@ -4,6 +4,8 @@ import * as Layer from "effect/Layer"; | ... | @@ -4,6 +4,8 @@ import * as Layer from "effect/Layer"; |
| 4 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | 4 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 5 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | 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 | import * as ServerConfig from "./config.ts"; | 9 | import * as ServerConfig from "./config.ts"; |
| 8 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; | 10 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; |
| 9 | import { | 11 | import { |
| ... | @@ -90,6 +92,11 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | ... | @@ -90,6 +92,11 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 90 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 92 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 91 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 93 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 92 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 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 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | 100 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 94 | import { | 101 | import { |
| 95 | clearPersistedServerRuntimeState, | 102 | clearPersistedServerRuntimeState, |
| ... | @@ -106,6 +113,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale" | ... | @@ -106,6 +113,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale" |
| 106 | // already closes the websocket gracefully. Do not add an artificial drain before | 113 | // already closes the websocket gracefully. Do not add an artificial drain before |
| 107 | // those finalizers get a chance to run. | 114 | // those finalizers get a chance to run. |
| 108 | const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; | 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 | const PtyAdapterLive = Layer.unwrap( | 121 | const PtyAdapterLive = Layer.unwrap( |
| 111 | Effect.gen(function* () { | 122 | Effect.gen(function* () { |
| ... | @@ -119,6 +130,35 @@ const PtyAdapterLive = Layer.unwrap( | ... | @@ -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 | const RelayClientLive = Layer.unwrap( | 162 | const RelayClientLive = Layer.unwrap( |
| 123 | Effect.gen(function* () { | 163 | Effect.gen(function* () { |
| 124 | const config = yield* ServerConfig.ServerConfig; | 164 | const config = yield* ServerConfig.ServerConfig; |
| ... | @@ -184,7 +224,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( | ... | @@ -184,7 +224,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( |
| 184 | // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter | 224 | // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter |
| 185 | // by looking up the default `ProviderInstance` per driver in the instance | 225 | // by looking up the default `ProviderInstance` per driver in the instance |
| 186 | // registry. Adapter construction itself moved inside each driver's | 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 | // NDJSON writers and is provided at the outer runtime layer so both | 228 | // NDJSON writers and is provided at the outer runtime layer so both |
| 189 | // `ProviderService` and the per-instance drivers read the same logger pair. | 229 | // `ProviderService` and the per-instance drivers read the same logger pair. |
| 190 | const ProviderLayerLive = ProviderServiceLive.pipe( | 230 | const ProviderLayerLive = ProviderServiceLive.pipe( |
| ... | @@ -298,6 +338,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( | ... | @@ -298,6 +338,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| 298 | 338 | ||
| 299 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | 339 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 300 | // Core Services | 340 | // Core Services |
| 341 | Layer.provideMerge(ServerSettingsLayerLive), | ||
| 301 | Layer.provideMerge(CheckpointingLayerLive), | 342 | Layer.provideMerge(CheckpointingLayerLive), |
| 302 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), | 343 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 303 | Layer.provideMerge(GitLayerLive), | 344 | Layer.provideMerge(GitLayerLive), |
| ... | @@ -318,14 +359,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -318,14 +359,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 318 | // `ProviderService` (canonical stream, written after event normalization). | 359 | // `ProviderService` (canonical stream, written after event normalization). |
| 319 | // Provided once at the runtime level so every consumer sees the same | 360 | // Provided once at the runtime level so every consumer sees the same |
| 320 | // logger instances. | 361 | // logger instances. |
| 321 | Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive), | 362 | Layer.provideMerge(ProviderEventLoggers.layer), |
| 322 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old | 363 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old |
| 323 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but | 364 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but |
| 324 | // the rewritten registry reads snapshots off the instance registry and | 365 | // the rewritten registry reads snapshots off the instance registry and |
| 325 | // no longer transitively provides it. Exposing it at the runtime level | 366 | // no longer transitively provides it. Exposing it at the runtime level |
| 326 | // keeps a single Live for all opencode consumers. | 367 | // keeps a single Live for all opencode consumers. |
| 327 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 368 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 328 | Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), | ||
| 329 | Layer.provideMerge(WorkspaceLayerLive), | 369 | Layer.provideMerge(WorkspaceLayerLive), |
| 330 | Layer.provideMerge(ProjectFaviconResolverLayerLive), | 370 | Layer.provideMerge(ProjectFaviconResolverLayerLive), |
| 331 | Layer.provideMerge(RepositoryIdentityResolver.layer), | 371 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| ... | @@ -345,8 +385,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -345,8 +385,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 345 | 385 | ||
| 346 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( | 386 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( |
| 347 | // Misc. | 387 | // Misc. |
| 348 | Layer.provideMerge(ProcessDiagnostics.layer), | 388 | Layer.provideMerge(BackgroundLayerLive), |
| 349 | Layer.provideMerge(ProcessResourceMonitor.layer), | 389 | Layer.provideMerge(ResourceDiagnosticsLayerLive), |
| 350 | Layer.provideMerge(TraceDiagnostics.layer), | 390 | Layer.provideMerge(TraceDiagnostics.layer), |
| 351 | Layer.provideMerge(AnalyticsService.layer), | 391 | Layer.provideMerge(AnalyticsService.layer), |
| 352 | Layer.provideMerge(ExternalLauncher.layer), | 392 | Layer.provideMerge(ExternalLauncher.layer), |
| ... | @@ -522,7 +562,7 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -522,7 +562,7 @@ export const makeServerLayer = Layer.unwrap( |
| 522 | Layer.provideMerge(serverRelayBrokerTracingLayer), | 562 | Layer.provideMerge(serverRelayBrokerTracingLayer), |
| 523 | Layer.provideMerge(HttpResponseCompressionLive), | 563 | Layer.provideMerge(HttpResponseCompressionLive), |
| 524 | Layer.provideMerge(HttpServerLive), | 564 | Layer.provideMerge(HttpServerLive), |
| 525 | Layer.provide(ObservabilityLive), | 565 | Layer.provide(ApplicationObservabilityLive), |
| 526 | Layer.provideMerge(FetchHttpClient.layer), | 566 | Layer.provideMerge(FetchHttpClient.layer), |
| 527 | Layer.provideMerge(VcsProcess.layer), | 567 | Layer.provideMerge(VcsProcess.layer), |
| 528 | Layer.provideMerge(PlatformServicesLive), | 568 | Layer.provideMerge(PlatformServicesLive), |
apps/server/src/serverSettings.test.ts+33| ... | @@ -12,8 +12,10 @@ import * as Effect from "effect/Effect"; | ... | @@ -12,8 +12,10 @@ import * as Effect from "effect/Effect"; |
| 12 | import * as Duration from "effect/Duration"; | 12 | import * as Duration from "effect/Duration"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; | ||
| 15 | import * as PlatformError from "effect/PlatformError"; | 16 | import * as PlatformError from "effect/PlatformError"; |
| 16 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 19 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 18 | import * as ServerConfig from "./config.ts"; | 20 | import * as ServerConfig from "./config.ts"; |
| 19 | import * as ServerSettingsModule from "./serverSettings.ts"; | 21 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| ... | @@ -203,6 +205,29 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -203,6 +205,29 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 203 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 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 | it.effect("preserves model when switching providers via textGenerationModelSelection", () => | 231 | it.effect("preserves model when switching providers via textGenerationModelSelection", () => |
| 207 | Effect.gen(function* () { | 232 | Effect.gen(function* () { |
| 208 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 233 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| ... | @@ -590,6 +615,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -590,6 +615,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 590 | serverPassword: "secret-password", | 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 | automaticGitFetchInterval: 10_000, | 626 | automaticGitFetchInterval: 10_000, |
| 594 | }); | 627 | }); |
| 595 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 628 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
apps/server/src/serverSettings.ts+37-15| ... | @@ -131,6 +131,13 @@ export class ServerSettingsService extends Context.Service< | ... | @@ -131,6 +131,13 @@ export class ServerSettingsService extends Context.Service< |
| 131 | 131 | ||
| 132 | /** Stream of settings change events. */ | 132 | /** Stream of settings change events. */ |
| 133 | readonly streamChanges: Stream.Stream<ServerSettings>; | 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 | >()("t3/serverSettings/ServerSettingsService") { | 142 | >()("t3/serverSettings/ServerSettingsService") { |
| 136 | /** @deprecated Import and use `layerTest` from this module. */ | 143 | /** @deprecated Import and use `layerTest` from this module. */ |
| ... | @@ -139,13 +146,17 @@ export class ServerSettingsService extends Context.Service< | ... | @@ -139,13 +146,17 @@ export class ServerSettingsService extends Context.Service< |
| 139 | 146 | ||
| 140 | const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => | 147 | const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 141 | Effect.gen(function* () { | 148 | Effect.gen(function* () { |
| 142 | const { automaticGitFetchInterval, ...overridesForMerge } = overrides; | 149 | const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = |
| 150 | overrides; | ||
| 143 | const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); | 151 | const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); |
| 144 | const initialSettings = yield* normalizeServerSettings({ | 152 | const initialSettings = yield* normalizeServerSettings({ |
| 145 | ...merged, | 153 | ...merged, |
| 146 | ...(automaticGitFetchInterval !== undefined | 154 | ...(automaticGitFetchInterval !== undefined |
| 147 | ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } | 155 | ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } |
| 148 | : {}), | 156 | : {}), |
| 157 | ...(providerHealthRefreshInterval !== undefined | ||
| 158 | ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } | ||
| 159 | : {}), | ||
| 149 | }); | 160 | }); |
| 150 | const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings); | 161 | const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings); |
| 151 | 162 | ||
| ... | @@ -161,6 +172,7 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => | ... | @@ -161,6 +172,7 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 161 | Effect.map(resolveTextGenerationProvider), | 172 | Effect.map(resolveTextGenerationProvider), |
| 162 | ), | 173 | ), |
| 163 | streamChanges: Stream.empty, | 174 | streamChanges: Stream.empty, |
| 175 | subscribeChanges: Effect.succeed(Stream.empty), | ||
| 164 | } satisfies ServerSettingsService["Service"]; | 176 | } satisfies ServerSettingsService["Service"]; |
| 165 | }); | 177 | }); |
| 166 | 178 | ||
| ... | @@ -197,7 +209,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting | ... | @@ -197,7 +209,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 197 | 209 | ||
| 198 | // Values under these keys are compared as a whole — never stripped field-by-field. | 210 | // Values under these keys are compared as a whole — never stripped field-by-field. |
| 199 | const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ | 211 | const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ |
| 212 | "backgroundActivity", | ||
| 200 | "automaticGitFetchInterval", | 213 | "automaticGitFetchInterval", |
| 214 | "providerHealthRefreshInterval", | ||
| 201 | "sourceControlWriterModelSelection", | 215 | "sourceControlWriterModelSelection", |
| 202 | "textGenerationModelSelection", | 216 | "textGenerationModelSelection", |
| 203 | ]); | 217 | ]); |
| ... | @@ -344,6 +358,23 @@ const make = Effect.gen(function* () { | ... | @@ -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 | const persistProviderEnvironmentSecrets = ( | 378 | const persistProviderEnvironmentSecrets = ( |
| 348 | current: ServerSettings, | 379 | current: ServerSettings, |
| 349 | next: ServerSettings, | 380 | next: ServerSettings, |
| ... | @@ -561,20 +592,11 @@ const make = Effect.gen(function* () { | ... | @@ -561,20 +592,11 @@ const make = Effect.gen(function* () { |
| 561 | }), | 592 | }), |
| 562 | ), | 593 | ), |
| 563 | get streamChanges() { | 594 | get streamChanges() { |
| 564 | return Stream.fromPubSub(changesPubSub).pipe( | 595 | return materializeChanges(Stream.fromPubSub(changesPubSub)); |
| 565 | Stream.mapEffect((settings) => | 596 | }, |
| 566 | materializeProviderEnvironmentSecrets(settings).pipe( | 597 | get subscribeChanges() { |
| 567 | Effect.catch((error: ServerSettingsError) => | 598 | return PubSub.subscribe(changesPubSub).pipe( |
| 568 | Effect.logWarning("failed to materialize provider environment secrets", { | 599 | Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))), |
| 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), | ||
| 578 | ); | 600 | ); |
| 579 | }, | 601 | }, |
| 580 | } satisfies ServerSettingsService["Service"]; | 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,6 +2,7 @@ import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Duration from "effect/Duration"; | 6 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 7 | import * as Exit from "effect/Exit"; | 8 | import * as Exit from "effect/Exit"; |
| ... | @@ -14,6 +15,7 @@ import * as Scope from "effect/Scope"; | ... | @@ -14,6 +15,7 @@ import * as Scope from "effect/Scope"; |
| 14 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 15 | import * as TestClock from "effect/testing/TestClock"; | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 16 | import type { | 17 | import type { |
| 18 | BackgroundScope, | ||
| 17 | VcsStatusLocalResult, | 19 | VcsStatusLocalResult, |
| 18 | VcsStatusRemoteResult, | 20 | VcsStatusRemoteResult, |
| 19 | VcsStatusResult, | 21 | VcsStatusResult, |
| ... | @@ -22,8 +24,11 @@ import type { | ... | @@ -22,8 +24,11 @@ import type { |
| 22 | import { GitManagerError } from "@t3tools/contracts"; | 24 | import { GitManagerError } from "@t3tools/contracts"; |
| 23 | 25 | ||
| 24 | import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; | 26 | import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; |
| 27 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| 25 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; | 28 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 26 | 29 | ||
| 30 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 31 | |||
| 27 | const baseLocalStatus: VcsStatusLocalResult = { | 32 | const baseLocalStatus: VcsStatusLocalResult = { |
| 28 | isRepo: true, | 33 | isRepo: true, |
| 29 | sourceControlProvider: { | 34 | sourceControlProvider: { |
| ... | @@ -73,6 +78,7 @@ function makeTestLayer(state: { | ... | @@ -73,6 +78,7 @@ function makeTestLayer(state: { |
| 73 | }) { | 78 | }) { |
| 74 | return VcsStatusBroadcaster.layer.pipe( | 79 | return VcsStatusBroadcaster.layer.pipe( |
| 75 | Layer.provideMerge(NodeServices.layer), | 80 | Layer.provideMerge(NodeServices.layer), |
| 81 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | ||
| 76 | Layer.provide( | 82 | Layer.provide( |
| 77 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | 83 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 78 | localStatus: () => | 84 | localStatus: () => |
| ... | @@ -104,6 +110,37 @@ function makeTestLayer(state: { | ... | @@ -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 | describe("VcsStatusBroadcaster", () => { | 144 | describe("VcsStatusBroadcaster", () => { |
| 108 | it.effect("reuses the cached VCS status across repeated reads", () => { | 145 | it.effect("reuses the cached VCS status across repeated reads", () => { |
| 109 | const state = { | 146 | const state = { |
| ... | @@ -183,6 +220,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -183,6 +220,7 @@ describe("VcsStatusBroadcaster", () => { |
| 183 | }; | 220 | }; |
| 184 | const testLayer = VcsStatusBroadcaster.layer.pipe( | 221 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 185 | Layer.provideMerge(NodeServices.layer), | 222 | Layer.provideMerge(NodeServices.layer), |
| 223 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | ||
| 186 | Layer.provide( | 224 | Layer.provide( |
| 187 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | 225 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 188 | localStatus: () => | 226 | localStatus: () => |
| ... | @@ -290,6 +328,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -290,6 +328,7 @@ describe("VcsStatusBroadcaster", () => { |
| 290 | }; | 328 | }; |
| 291 | const testLayer = VcsStatusBroadcaster.layer.pipe( | 329 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 292 | Layer.provideMerge(NodeServices.layer), | 330 | Layer.provideMerge(NodeServices.layer), |
| 331 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | ||
| 293 | Layer.provide( | 332 | Layer.provide( |
| 294 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | 333 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 295 | localStatus: (input) => | 334 | localStatus: (input) => |
| ... | @@ -453,6 +492,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -453,6 +492,7 @@ describe("VcsStatusBroadcaster", () => { |
| 453 | let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null; | 492 | let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null; |
| 454 | const testLayer = VcsStatusBroadcaster.layer.pipe( | 493 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 455 | Layer.provideMerge(NodeServices.layer), | 494 | Layer.provideMerge(NodeServices.layer), |
| 495 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | ||
| 456 | Layer.provide( | 496 | Layer.provide( |
| 457 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | 497 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 458 | localStatus: () => | 498 | localStatus: () => |
| ... | @@ -641,6 +681,57 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -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 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { | 735 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { |
| 645 | const state = { | 736 | const state = { |
| 646 | currentLocalStatus: baseLocalStatus, | 737 | currentLocalStatus: baseLocalStatus, |
| ... | @@ -654,6 +745,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -654,6 +745,7 @@ describe("VcsStatusBroadcaster", () => { |
| 654 | let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; | 745 | let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; |
| 655 | const testLayer = VcsStatusBroadcaster.layer.pipe( | 746 | const testLayer = VcsStatusBroadcaster.layer.pipe( |
| 656 | Layer.provideMerge(NodeServices.layer), | 747 | Layer.provideMerge(NodeServices.layer), |
| 748 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | ||
| 657 | Layer.provide( | 749 | Layer.provide( |
| 658 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | 750 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 659 | localStatus: () => | 751 | localStatus: () => |
apps/server/src/vcs/VcsStatusBroadcaster.ts+82-28| ... | @@ -22,6 +22,7 @@ import type { | ... | @@ -22,6 +22,7 @@ import type { |
| 22 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 23 | import { mergeGitStatusParts } from "@t3tools/shared/git"; | 23 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 24 | 24 | ||
| 25 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| 25 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; | 26 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 26 | 27 | ||
| 27 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | 28 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); |
| ... | @@ -131,6 +132,7 @@ interface CachedVcsStatus { | ... | @@ -131,6 +132,7 @@ interface CachedVcsStatus { |
| 131 | interface ActiveRemotePoller { | 132 | interface ActiveRemotePoller { |
| 132 | readonly fiber: Fiber.Fiber<void, never>; | 133 | readonly fiber: Fiber.Fiber<void, never>; |
| 133 | readonly subscriberCount: number; | 134 | readonly subscriberCount: number; |
| 135 | readonly demandCwds: Ref.Ref<ReadonlyMap<string, number>>; | ||
| 134 | } | 136 | } |
| 135 | 137 | ||
| 136 | interface StreamStatusOptions { | 138 | interface StreamStatusOptions { |
| ... | @@ -180,6 +182,7 @@ const normalizeCwd = (cwd: string) => | ... | @@ -180,6 +182,7 @@ const normalizeCwd = (cwd: string) => |
| 180 | 182 | ||
| 181 | export const make = Effect.gen(function* () { | 183 | export const make = Effect.gen(function* () { |
| 182 | const workflow = yield* GitWorkflowService.GitWorkflowService; | 184 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 185 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | ||
| 183 | const fs = yield* FileSystem.FileSystem; | 186 | const fs = yield* FileSystem.FileSystem; |
| 184 | const changesPubSub = yield* Effect.acquireRelease( | 187 | const changesPubSub = yield* Effect.acquireRelease( |
| 185 | PubSub.unbounded<VcsStatusChange>(), | 188 | PubSub.unbounded<VcsStatusChange>(), |
| ... | @@ -378,6 +381,7 @@ export const make = Effect.gen(function* () { | ... | @@ -378,6 +381,7 @@ export const make = Effect.gen(function* () { |
| 378 | 381 | ||
| 379 | const makeRemoteRefreshLoop = ( | 382 | const makeRemoteRefreshLoop = ( |
| 380 | cwd: string, | 383 | cwd: string, |
| 384 | demandCwdsRef: Ref.Ref<ReadonlyMap<string, number>>, | ||
| 381 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, | 385 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, |
| 382 | refreshImmediately: boolean, | 386 | refreshImmediately: boolean, |
| 383 | ) => { | 387 | ) => { |
| ... | @@ -394,6 +398,22 @@ export const make = Effect.gen(function* () { | ... | @@ -394,6 +398,22 @@ export const make = Effect.gen(function* () { |
| 394 | return activeInterval; | 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 | const exit = yield* refreshRemoteStatus(cwd, { | 417 | const exit = yield* refreshRemoteStatus(cwd, { |
| 398 | refreshUpstream: !Duration.isZero(configuredInterval), | 418 | refreshUpstream: !Duration.isZero(configuredInterval), |
| 399 | }).pipe(Effect.exit); | 419 | }).pipe(Effect.exit); |
| ... | @@ -444,55 +464,88 @@ export const make = Effect.gen(function* () { | ... | @@ -444,55 +464,88 @@ export const make = Effect.gen(function* () { |
| 444 | 464 | ||
| 445 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( | 465 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( |
| 446 | cwd: string, | 466 | cwd: string, |
| 467 | demandCwd: string, | ||
| 447 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, | 468 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, |
| 448 | refreshImmediately: boolean, | 469 | refreshImmediately: boolean, |
| 449 | ) { | 470 | ) { |
| 450 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { | 471 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { |
| 451 | const existing = activePollers.get(cwd); | 472 | const existing = activePollers.get(cwd); |
| 452 | if (existing) { | 473 | if (existing) { |
| 453 | const nextPollers = new Map(activePollers); | 474 | return Ref.update(existing.demandCwds, (demandCwds) => { |
| 454 | nextPollers.set(cwd, { | 475 | const next = new Map(demandCwds); |
| 455 | ...existing, | 476 | next.set(demandCwd, (next.get(demandCwd) ?? 0) + 1); |
| 456 | subscriberCount: existing.subscriberCount + 1, | 477 | return next; |
| 457 | }); | 478 | }).pipe( |
| 458 | return Effect.succeed([undefined, nextPollers] as const); | 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( | 490 | return Ref.make<ReadonlyMap<string, number>>(new Map([[demandCwd, 1]])).pipe( |
| 462 | Effect.forkIn(broadcasterScope), | 491 | Effect.flatMap((demandCwds) => |
| 463 | Effect.map((fiber) => { | 492 | makeRemoteRefreshLoop( |
| 464 | const nextPollers = new Map(activePollers); | 493 | cwd, |
| 465 | nextPollers.set(cwd, { | 494 | demandCwds, |
| 466 | fiber, | 495 | automaticRemoteRefreshInterval, |
| 467 | subscriberCount: 1, | 496 | refreshImmediately, |
| 468 | }); | 497 | ).pipe( |
| 469 | return [undefined, nextPollers] as const; | 498 | Effect.forkIn(broadcasterScope), |
| 470 | }), | 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 | const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( | 514 | const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( |
| 476 | cwd: string, | 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 | const existing = activePollers.get(cwd); | 519 | const existing = activePollers.get(cwd); |
| 480 | if (!existing) { | 520 | if (!existing) { |
| 481 | return [null, activePollers] as const; | 521 | return Effect.succeed([null, activePollers] as const); |
| 482 | } | 522 | } |
| 483 | 523 | ||
| 484 | if (existing.subscriberCount > 1) { | 524 | if (existing.subscriberCount > 1) { |
| 485 | const nextPollers = new Map(activePollers); | 525 | return Ref.update(existing.demandCwds, (demandCwds) => { |
| 486 | nextPollers.set(cwd, { | 526 | const nextDemandCwds = new Map(demandCwds); |
| 487 | ...existing, | 527 | const count = nextDemandCwds.get(demandCwd) ?? 0; |
| 488 | subscriberCount: existing.subscriberCount - 1, | 528 | if (count <= 1) { |
| 489 | }); | 529 | nextDemandCwds.delete(demandCwd); |
| 490 | return [null, nextPollers] as const; | 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); | 545 | return Effect.succeed([ |
| 494 | nextPollers.delete(cwd); | 546 | existing.fiber, |
| 495 | return [existing.fiber, nextPollers] as const; | 547 | new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)), |
| 548 | ] as const); | ||
| 496 | }); | 549 | }); |
| 497 | 550 | ||
| 498 | if (pollerToInterrupt) { | 551 | if (pollerToInterrupt) { |
| ... | @@ -510,12 +563,13 @@ export const make = Effect.gen(function* () { | ... | @@ -510,12 +563,13 @@ export const make = Effect.gen(function* () { |
| 510 | const initialRemote = cachedStatus?.remote?.value ?? null; | 563 | const initialRemote = cachedStatus?.remote?.value ?? null; |
| 511 | yield* retainRemotePoller( | 564 | yield* retainRemotePoller( |
| 512 | cwd, | 565 | cwd, |
| 566 | input.cwd, | ||
| 513 | options?.automaticRemoteRefreshInterval ?? | 567 | options?.automaticRemoteRefreshInterval ?? |
| 514 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), | 568 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), |
| 515 | cachedStatus?.remote === null || cachedStatus?.remote === undefined, | 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 | return Stream.concat( | 574 | return Stream.concat( |
| 521 | Stream.make({ | 575 | Stream.make({ |
apps/server/src/ws.ts+88-89| ... | @@ -11,12 +11,6 @@ import * as Schema from "effect/Schema"; | ... | @@ -11,12 +11,6 @@ import * as Schema from "effect/Schema"; |
| 11 | import * as Stream from "effect/Stream"; | 11 | import * as Stream from "effect/Stream"; |
| 12 | import { | 12 | import { |
| 13 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, | 13 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, |
| 14 | AuthOrchestrationOperateScope, | ||
| 15 | AuthOrchestrationReadScope, | ||
| 16 | AuthReviewWriteScope, | ||
| 17 | AuthRelayWriteScope, | ||
| 18 | AuthTerminalOperateScope, | ||
| 19 | AuthAccessReadScope, | ||
| 20 | AuthAccessStreamError, | 14 | AuthAccessStreamError, |
| 21 | type AuthAccessStreamEvent, | 15 | type AuthAccessStreamEvent, |
| 22 | type AuthEnvironmentScope, | 16 | type AuthEnvironmentScope, |
| ... | @@ -50,6 +44,7 @@ import { | ... | @@ -50,6 +44,7 @@ import { |
| 50 | FilesystemBrowseError, | 44 | FilesystemBrowseError, |
| 51 | AssetWorkspaceContextNotFoundError, | 45 | AssetWorkspaceContextNotFoundError, |
| 52 | AssetWorkspaceContextResolutionError, | 46 | AssetWorkspaceContextResolutionError, |
| 47 | RpcClientId, | ||
| 53 | EnvironmentAuthorizationError, | 48 | EnvironmentAuthorizationError, |
| 54 | ThreadId, | 49 | ThreadId, |
| 55 | type TerminalAttachStreamEvent, | 50 | type TerminalAttachStreamEvent, |
| ... | @@ -59,6 +54,7 @@ import { | ... | @@ -59,6 +54,7 @@ import { |
| 59 | WS_METHODS, | 54 | WS_METHODS, |
| 60 | WsRpcGroup, | 55 | WsRpcGroup, |
| 61 | } from "@t3tools/contracts"; | 56 | } from "@t3tools/contracts"; |
| 57 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | ||
| 62 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; | 58 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; |
| 63 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | 59 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 64 | 60 | ||
| ... | @@ -98,9 +94,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | ... | @@ -98,9 +94,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 98 | import * as ReviewService from "./review/ReviewService.ts"; | 94 | import * as ReviewService from "./review/ReviewService.ts"; |
| 99 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | 95 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 100 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | 96 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 97 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | ||
| 101 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 98 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 99 | import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; | ||
| 102 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 100 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 103 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 101 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 102 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | ||
| 104 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 103 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 105 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; | 104 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 106 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 105 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| ... | @@ -295,78 +294,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; | ... | @@ -295,78 +294,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; |
| 295 | // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). | 294 | // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). |
| 296 | const SHELL_RESUME_MAX_GAP = 1_000; | 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 | function toAuthAccessStreamEvent( | 297 | function toAuthAccessStreamEvent( |
| 371 | change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, | 298 | change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, |
| 372 | revision: number, | 299 | revision: number, |
| ... | @@ -438,10 +365,28 @@ const makeWsRpcLayer = ( | ... | @@ -438,10 +365,28 @@ const makeWsRpcLayer = ( |
| 438 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | 365 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; |
| 439 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | 366 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 440 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | 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 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 384 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 442 | const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; | 385 | const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; |
| 443 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( | 386 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( |
| 444 | Effect.map((settings) => settings.automaticGitFetchInterval), | 387 | Effect.map( |
| 388 | (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, | ||
| 389 | ), | ||
| 445 | Effect.catch((cause) => | 390 | Effect.catch((cause) => |
| 446 | Effect.logWarning("Failed to read automatic Git fetch interval setting", { | 391 | Effect.logWarning("Failed to read automatic Git fetch interval setting", { |
| 447 | detail: cause.message, | 392 | detail: cause.message, |
| ... | @@ -454,6 +399,7 @@ const makeWsRpcLayer = ( | ... | @@ -454,6 +399,7 @@ const makeWsRpcLayer = ( |
| 454 | const sessions = yield* SessionStore.SessionStore; | 399 | const sessions = yield* SessionStore.SessionStore; |
| 455 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; | 400 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 456 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; | 401 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 402 | const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; | ||
| 457 | const relayClient = yield* RelayClient.RelayClient; | 403 | const relayClient = yield* RelayClient.RelayClient; |
| 458 | const authorizationError = (requiredScope: AuthEnvironmentScope) => | 404 | const authorizationError = (requiredScope: AuthEnvironmentScope) => |
| 459 | new EnvironmentAuthorizationError({ | 405 | new EnvironmentAuthorizationError({ |
| ... | @@ -474,13 +420,6 @@ const makeWsRpcLayer = ( | ... | @@ -474,13 +420,6 @@ const makeWsRpcLayer = ( |
| 474 | currentSession.scopes.includes(requiredScope) | 420 | currentSession.scopes.includes(requiredScope) |
| 475 | ? stream | 421 | ? stream |
| 476 | : Stream.fail(authorizationError(requiredScope)); | 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 | const observeRpcEffect = <A, E, R>( | 423 | const observeRpcEffect = <A, E, R>( |
| 485 | method: string, | 424 | method: string, |
| 486 | effect: Effect.Effect<A, E, R>, | 425 | effect: Effect.Effect<A, E, R>, |
| ... | @@ -488,7 +427,7 @@ const makeWsRpcLayer = ( | ... | @@ -488,7 +427,7 @@ const makeWsRpcLayer = ( |
| 488 | ) => | 427 | ) => |
| 489 | instrumentRpcEffect( | 428 | instrumentRpcEffect( |
| 490 | method, | 429 | method, |
| 491 | authorizeEffect(requiredScopeForMethod(method), effect), | 430 | authorizeEffect(requiredScopeForRpcMethod(method), effect), |
| 492 | traceAttributes, | 431 | traceAttributes, |
| 493 | ); | 432 | ); |
| 494 | const observeRpcStream = <A, E, R>( | 433 | const observeRpcStream = <A, E, R>( |
| ... | @@ -498,7 +437,7 @@ const makeWsRpcLayer = ( | ... | @@ -498,7 +437,7 @@ const makeWsRpcLayer = ( |
| 498 | ) => | 437 | ) => |
| 499 | instrumentRpcStream( | 438 | instrumentRpcStream( |
| 500 | method, | 439 | method, |
| 501 | authorizeStream(requiredScopeForMethod(method), stream), | 440 | authorizeStream(requiredScopeForRpcMethod(method), stream), |
| 502 | traceAttributes, | 441 | traceAttributes, |
| 503 | ); | 442 | ); |
| 504 | const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>( | 443 | const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>( |
| ... | @@ -512,7 +451,7 @@ const makeWsRpcLayer = ( | ... | @@ -512,7 +451,7 @@ const makeWsRpcLayer = ( |
| 512 | ) => | 451 | ) => |
| 513 | instrumentRpcStreamEffect( | 452 | instrumentRpcStreamEffect( |
| 514 | method, | 453 | method, |
| 515 | authorizeEffect(requiredScopeForMethod(method), effect), | 454 | authorizeEffect(requiredScopeForRpcMethod(method), effect), |
| 516 | traceAttributes, | 455 | traceAttributes, |
| 517 | ); | 456 | ); |
| 518 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => | 457 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => |
| ... | @@ -1494,10 +1433,50 @@ const makeWsRpcLayer = ( | ... | @@ -1494,10 +1433,50 @@ const makeWsRpcLayer = ( |
| 1494 | "rpc.aggregate": "server", | 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 | [WS_METHODS.serverSignalProcess]: (input) => | 1448 | [WS_METHODS.serverSignalProcess]: (input) => |
| 1498 | observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { | 1449 | observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { |
| 1499 | "rpc.aggregate": "server", | 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 | [WS_METHODS.cloudGetRelayClientStatus]: (_input) => | 1480 | [WS_METHODS.cloudGetRelayClientStatus]: (_input) => |
| 1502 | observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { | 1481 | observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { |
| 1503 | "rpc.aggregate": "cloud", | 1482 | "rpc.aggregate": "cloud", |
| ... | @@ -2021,6 +2000,26 @@ const makeWsRpcLayer = ( | ... | @@ -2021,6 +2000,26 @@ const makeWsRpcLayer = ( |
| 2021 | }), | 2000 | }), |
| 2022 | { "rpc.aggregate": "auth" }, | 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,6 +37,7 @@ import { Button } from "../ui/button"; |
| 37 | import { ScrollArea } from "../ui/scroll-area"; | 37 | import { ScrollArea } from "../ui/scroll-area"; |
| 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 39 | import { toastManager } from "../ui/toast"; | 39 | import { toastManager } from "../ui/toast"; |
| 40 | import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; | ||
| 40 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; | 41 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; |
| 41 | import { useAtomCommand } from "../../state/use-atom-command"; | 42 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 42 | 43 | ||
| ... | @@ -904,12 +905,16 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -904,12 +905,16 @@ export function DiagnosticsSettingsPanel() { |
| 904 | if (environmentId === null) { | 905 | if (environmentId === null) { |
| 905 | return; | 906 | return; |
| 906 | } | 907 | } |
| 908 | const process = processData?.processes.find((entry) => entry.pid === pid); | ||
| 909 | if (process === undefined) { | ||
| 910 | return; | ||
| 911 | } | ||
| 907 | 912 | ||
| 908 | setSignalingPid(pid); | 913 | setSignalingPid(pid); |
| 909 | void (async () => { | 914 | void (async () => { |
| 910 | const result = await signalServerProcess({ | 915 | const result = await signalServerProcess({ |
| 911 | environmentId, | 916 | environmentId, |
| 912 | input: { pid, signal }, | 917 | input: { pid, startTimeMs: process.startTimeMs, signal }, |
| 913 | }); | 918 | }); |
| 914 | setSignalingPid(null); | 919 | setSignalingPid(null); |
| 915 | if (result._tag === "Failure") { | 920 | if (result._tag === "Failure") { |
| ... | @@ -946,7 +951,7 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -946,7 +951,7 @@ export function DiagnosticsSettingsPanel() { |
| 946 | refreshProcesses(); | 951 | refreshProcesses(); |
| 947 | })(); | 952 | })(); |
| 948 | }, | 953 | }, |
| 949 | [environmentId, refreshProcesses, signalServerProcess], | 954 | [environmentId, processData?.processes, refreshProcesses, signalServerProcess], |
| 950 | ); | 955 | ); |
| 951 | 956 | ||
| 952 | const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; | 957 | const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; |
| ... | @@ -957,7 +962,9 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -957,7 +962,9 @@ export function DiagnosticsSettingsPanel() { |
| 957 | : false; | 962 | : false; |
| 958 | 963 | ||
| 959 | return ( | 964 | return ( |
| 960 | <SettingsPageContainer> | 965 | <SettingsPageContainer className="max-w-6xl gap-10"> |
| 966 | <ResourceTelemetryDiagnostics /> | ||
| 967 | |||
| 961 | <SettingsSection | 968 | <SettingsSection |
| 962 | title="Live Processes" | 969 | title="Live Processes" |
| 963 | headerAction={ | 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,17 +1,126 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | DEFAULT_UNIFIED_SETTINGS, | ||
| 3 | ProviderDriverKind, | 4 | ProviderDriverKind, |
| 4 | ProviderInstanceId, | 5 | ProviderInstanceId, |
| 5 | type ProviderInstanceConfig, | 6 | type ProviderInstanceConfig, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings"; | ||
| 9 | import * as Duration from "effect/Duration"; | ||
| 7 | import { describe, expect, it } from "vite-plus/test"; | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | import { | 11 | import { |
| 12 | backgroundActivitySharedPolicySettings, | ||
| 9 | buildProviderInstanceUpdatePatch, | 13 | buildProviderInstanceUpdatePatch, |
| 10 | formatDiagnosticsDescription, | 14 | formatDiagnosticsDescription, |
| 15 | hasChangedBackgroundActivitySettings, | ||
| 11 | isProjectGroupingEnabled, | 16 | isProjectGroupingEnabled, |
| 12 | projectGroupingModeFromToggle, | 17 | projectGroupingModeFromToggle, |
| 18 | resolveBackgroundActivityProfileOption, | ||
| 13 | } from "./SettingsPanels.logic"; | 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 | describe("project grouping toggle", () => { | 124 | describe("project grouping toggle", () => { |
| 16 | it("enables repository grouping and disables into separate projects", () => { | 125 | it("enables repository grouping and disables into separate projects", () => { |
| 17 | expect(isProjectGroupingEnabled("repository")).toBe(true); | 126 | expect(isProjectGroupingEnabled("repository")).toBe(true); |
apps/web/src/components/settings/SettingsPanels.logic.ts+67| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import type { | 1 | import type { |
| 2 | BackgroundActivityProfile, | ||
| 3 | BackgroundActivitySettings, | ||
| 2 | ProviderDriverKind, | 4 | ProviderDriverKind, |
| 3 | ProviderInstanceConfig, | 5 | ProviderInstanceConfig, |
| 4 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| ... | @@ -7,6 +9,12 @@ import type { | ... | @@ -7,6 +9,12 @@ import type { |
| 7 | UnifiedSettings, | 9 | UnifiedSettings, |
| 8 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 9 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 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 | export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { | 19 | export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { |
| 12 | return mode !== "separate"; | 20 | return mode !== "separate"; |
| ... | @@ -41,6 +49,65 @@ export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingM | ... | @@ -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 | function collapseOtelSignalsUrl(input: { | 111 | function collapseOtelSignalsUrl(input: { |
| 45 | readonly tracesUrl: string; | 112 | readonly tracesUrl: string; |
| 46 | readonly metricsUrl: string; | 113 | readonly metricsUrl: string; |
apps/web/src/components/settings/SettingsPanels.tsx+596-6| ... | @@ -1,10 +1,20 @@ | ... | @@ -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 | import { Link } from "@tanstack/react-router"; | 10 | import { Link } from "@tanstack/react-router"; |
| 3 | import type { CSSProperties } from "react"; | 11 | import type { CSSProperties } from "react"; |
| 4 | import { useCallback, useMemo, useRef, useState } from "react"; | 12 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 5 | import { useAtomValue } from "@effect/atom-react"; | 13 | import { useAtomValue } from "@effect/atom-react"; |
| 6 | import { | 14 | import { |
| 7 | defaultInstanceIdForDriver, | 15 | defaultInstanceIdForDriver, |
| 16 | type BackgroundActivityProfile, | ||
| 17 | type BackgroundActivitySettings, | ||
| 8 | type DesktopUpdateChannel, | 18 | type DesktopUpdateChannel, |
| 9 | PROVIDER_DISPLAY_NAMES, | 19 | PROVIDER_DISPLAY_NAMES, |
| 10 | ProviderDriverKind, | 20 | ProviderDriverKind, |
| ... | @@ -27,6 +37,11 @@ import { | ... | @@ -27,6 +37,11 @@ import { |
| 27 | MAX_GLASS_OPACITY, | 37 | MAX_GLASS_OPACITY, |
| 28 | MIN_GLASS_OPACITY, | 38 | MIN_GLASS_OPACITY, |
| 29 | } from "@t3tools/contracts/settings"; | 39 | } from "@t3tools/contracts/settings"; |
| 40 | import { | ||
| 41 | getBackgroundActivityBaseProfile, | ||
| 42 | getBackgroundActivityPresetSettings, | ||
| 43 | resolveServerBackgroundActivitySettings, | ||
| 44 | } from "@t3tools/shared/backgroundActivitySettings"; | ||
| 30 | import { createModelSelection } from "@t3tools/shared/model"; | 45 | import { createModelSelection } from "@t3tools/shared/model"; |
| 31 | import * as Arr from "effect/Array"; | 46 | import * as Arr from "effect/Array"; |
| 32 | import * as Duration from "effect/Duration"; | 47 | import * as Duration from "effect/Duration"; |
| ... | @@ -72,7 +87,23 @@ import { useProjects } from "../../state/entities"; | ... | @@ -72,7 +87,23 @@ import { useProjects } from "../../state/entities"; |
| 72 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 87 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 73 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | 88 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 74 | import { Button } from "../ui/button"; | 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 | import { DraftInput } from "../ui/draft-input"; | 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 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 107 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 77 | import { Switch } from "../ui/switch"; | 108 | import { Switch } from "../ui/switch"; |
| 78 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 109 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| ... | @@ -88,12 +119,15 @@ import { | ... | @@ -88,12 +119,15 @@ import { |
| 88 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | 119 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 89 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | 120 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 90 | import { | 121 | import { |
| 122 | backgroundActivitySharedPolicySettings, | ||
| 91 | buildProviderInstanceUpdatePatch, | 123 | buildProviderInstanceUpdatePatch, |
| 92 | formatDiagnosticsDescription, | 124 | formatDiagnosticsDescription, |
| 125 | hasChangedBackgroundActivitySettings, | ||
| 93 | isProjectGroupingEnabled, | 126 | isProjectGroupingEnabled, |
| 94 | projectGroupingModeFromToggle, | 127 | projectGroupingModeFromToggle, |
| 95 | readLastEnabledProjectGroupingMode, | 128 | readLastEnabledProjectGroupingMode, |
| 96 | rememberEnabledProjectGroupingMode, | 129 | rememberEnabledProjectGroupingMode, |
| 130 | resolveBackgroundActivityProfileOption, | ||
| 97 | } from "./SettingsPanels.logic"; | 131 | } from "./SettingsPanels.logic"; |
| 98 | import { | 132 | import { |
| 99 | SettingResetButton, | 133 | SettingResetButton, |
| ... | @@ -132,7 +166,129 @@ const TIMESTAMP_FORMAT_LABELS = { | ... | @@ -132,7 +166,129 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 132 | "24-hour": "24-hour", | 166 | "24-hour": "24-hour", |
| 133 | } as const; | 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 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | 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 | function withoutProviderInstanceKey<V>( | 293 | function withoutProviderInstanceKey<V>( |
| 138 | record: Readonly<Record<ProviderInstanceId, V>> | undefined, | 294 | record: Readonly<Record<ProviderInstanceId, V>> | undefined, |
| ... | @@ -408,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -408,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 408 | settings.textGenerationModelSelection ?? null, | 564 | settings.textGenerationModelSelection ?? null, |
| 409 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, | 565 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 410 | ); | 566 | ); |
| 567 | const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); | ||
| 411 | 568 | ||
| 412 | const changedSettingLabels = useMemo( | 569 | const changedSettingLabels = useMemo( |
| 413 | () => [ | 570 | () => [ |
| ... | @@ -441,10 +598,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -441,10 +598,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 441 | DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks | 598 | DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks |
| 442 | ? ["Provider update checks"] | 599 | ? ["Provider update checks"] |
| 443 | : []), | 600 | : []), |
| 444 | ...(Duration.toMillis(settings.automaticGitFetchInterval) !== | 601 | ...(isBackgroundActivityDirty ? ["Background activity"] : []), |
| 445 | Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) | ||
| 446 | ? ["Automatic Git fetch interval"] | ||
| 447 | : []), | ||
| 448 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode | 602 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode |
| 449 | ? ["New thread mode"] | 603 | ? ["New thread mode"] |
| 450 | : []), | 604 | : []), |
| ... | @@ -465,6 +619,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -465,6 +619,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 465 | ], | 619 | ], |
| 466 | [ | 620 | [ |
| 467 | isTextGenerationModelDirty, | 621 | isTextGenerationModelDirty, |
| 622 | isBackgroundActivityDirty, | ||
| 468 | settings.autoOpenPlanSidebar, | 623 | settings.autoOpenPlanSidebar, |
| 469 | settings.confirmThreadArchive, | 624 | settings.confirmThreadArchive, |
| 470 | settings.confirmThreadDelete, | 625 | settings.confirmThreadDelete, |
| ... | @@ -474,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -474,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 474 | settings.diffIgnoreWhitespace, | 629 | settings.diffIgnoreWhitespace, |
| 475 | settings.environmentIdentificationMode, | 630 | settings.environmentIdentificationMode, |
| 476 | settings.glassOpacity, | 631 | settings.glassOpacity, |
| 477 | settings.automaticGitFetchInterval, | ||
| 478 | settings.enableAssistantStreaming, | 632 | settings.enableAssistantStreaming, |
| 479 | settings.enableProviderUpdateChecks, | 633 | settings.enableProviderUpdateChecks, |
| 480 | settings.sidebarProjectGroupingMode, | 634 | settings.sidebarProjectGroupingMode, |
| ... | @@ -507,7 +661,10 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -507,7 +661,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 507 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, | 661 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, |
| 508 | enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, | 662 | enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, |
| 509 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, | 663 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 664 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | ||
| 665 | backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, | ||
| 510 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | 666 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, |
| 667 | providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, | ||
| 511 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, | 668 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, |
| 512 | newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, | 669 | newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, |
| 513 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, | 670 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| ... | @@ -524,6 +681,272 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -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 | export function AppearanceSettingsPanel() { | 950 | export function AppearanceSettingsPanel() { |
| 528 | const { theme, setTheme } = useTheme(); | 951 | const { theme, setTheme } = useTheme(); |
| 529 | const settings = usePrimarySettings(); | 952 | const settings = usePrimarySettings(); |
| ... | @@ -693,6 +1116,7 @@ export function AppearanceSettingsPanel() { | ... | @@ -693,6 +1116,7 @@ export function AppearanceSettingsPanel() { |
| 693 | export function GeneralSettingsPanel() { | 1116 | export function GeneralSettingsPanel() { |
| 694 | const settings = usePrimarySettings(); | 1117 | const settings = usePrimarySettings(); |
| 695 | const updateSettings = useUpdatePrimarySettings(); | 1118 | const updateSettings = useUpdatePrimarySettings(); |
| 1119 | const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); | ||
| 696 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( | 1120 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( |
| 697 | readLastEnabledProjectGroupingMode(), | 1121 | readLastEnabledProjectGroupingMode(), |
| 698 | ); | 1122 | ); |
| ... | @@ -728,6 +1152,19 @@ export function GeneralSettingsPanel() { | ... | @@ -728,6 +1152,19 @@ export function GeneralSettingsPanel() { |
| 728 | settings.textGenerationModelSelection ?? null, | 1152 | settings.textGenerationModelSelection ?? null, |
| 729 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, | 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 | return ( | 1169 | return ( |
| 733 | <SettingsPageContainer> | 1170 | <SettingsPageContainer> |
| ... | @@ -890,6 +1327,88 @@ export function GeneralSettingsPanel() { | ... | @@ -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 | <SettingsRow | 1412 | <SettingsRow |
| 894 | title="Auto-open task panel" | 1413 | title="Auto-open task panel" |
| 895 | description="Open the right-side plan and task panel automatically when steps appear." | 1414 | description="Open the right-side plan and task panel automatically when steps appear." |
| ... | @@ -1207,6 +1726,14 @@ export function ProviderSettingsPanel() { | ... | @@ -1207,6 +1726,14 @@ export function ProviderSettingsPanel() { |
| 1207 | ); | 1726 | ); |
| 1208 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); | 1727 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); |
| 1209 | const textGenInstanceId = textGenerationModelSelection.instanceId; | 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 | const lastCheckedAt = | 1737 | const lastCheckedAt = |
| 1211 | serverProviders.length > 0 | 1738 | serverProviders.length > 0 |
| 1212 | ? serverProviders.reduce( | 1739 | ? serverProviders.reduce( |
| ... | @@ -1508,6 +2035,69 @@ export function ProviderSettingsPanel() { | ... | @@ -1508,6 +2035,69 @@ export function ProviderSettingsPanel() { |
| 1508 | </div> | 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 | {rows.map((row) => { | 2101 | {rows.map((row) => { |
| 1512 | const driverOption = getDriverOption(row.driver); | 2102 | const driverOption = getDriverOption(row.driver); |
| 1513 | const liveProvider = serverProviders.find( | 2103 | const liveProvider = serverProviders.find( |
apps/web/src/components/settings/SourceControlSettings.tsx+79-13| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; | 1 | import { ChevronDownIcon, GitPullRequestIcon, InfoIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | import * as Duration from "effect/Duration"; | 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 4 | import { useState, type ReactNode } from "react"; | 4 | import { useState, type ReactNode } from "react"; |
| 5 | import type { | 5 | import type { |
| 6 | BackgroundActivitySettings, | ||
| 6 | SourceControlProviderKind, | 7 | SourceControlProviderKind, |
| 7 | SourceControlDiscoveryResult, | 8 | SourceControlDiscoveryResult, |
| 8 | SourceControlProviderAuth, | 9 | SourceControlProviderAuth, |
| ... | @@ -10,7 +11,11 @@ import type { | ... | @@ -10,7 +11,11 @@ import type { |
| 10 | VcsDriverKind, | 11 | VcsDriverKind, |
| 11 | VcsDiscoveryItem, | 12 | VcsDiscoveryItem, |
| 12 | } from "@t3tools/contracts"; | 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 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 20 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 16 | import { cn } from "../../lib/utils"; | 21 | import { cn } from "../../lib/utils"; |
| ... | @@ -70,6 +75,11 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { | ... | @@ -70,6 +75,11 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
| 70 | 75 | ||
| 71 | const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; | 76 | const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; |
| 72 | const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; | 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 | function durationToSeconds(duration: Duration.Duration): number { | 84 | function durationToSeconds(duration: Duration.Duration): number { |
| 75 | return Math.round(Duration.toMillis(duration) / 1_000); | 85 | return Math.round(Duration.toMillis(duration) / 1_000); |
| ... | @@ -82,6 +92,50 @@ function normalizeFetchIntervalSeconds(value: number | null): number { | ... | @@ -82,6 +92,50 @@ function normalizeFetchIntervalSeconds(value: number | null): number { |
| 82 | return Math.max(0, Math.round(value)); | 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 | function optionLabel(value: Option.Option<string>): string | null { | 139 | function optionLabel(value: Option.Option<string>): string | null { |
| 86 | return Option.getOrNull(value); | 140 | return Option.getOrNull(value); |
| 87 | } | 141 | } |
| ... | @@ -292,13 +346,16 @@ function DiscoveryItemRow({ | ... | @@ -292,13 +346,16 @@ function DiscoveryItemRow({ |
| 292 | } | 346 | } |
| 293 | 347 | ||
| 294 | function GitFetchIntervalSettings() { | 348 | function GitFetchIntervalSettings() { |
| 295 | const automaticGitFetchInterval = usePrimarySettings( | 349 | const settings = usePrimarySettings(); |
| 296 | (settings) => settings.automaticGitFetchInterval, | ||
| 297 | ); | ||
| 298 | const updateSettings = useUpdatePrimarySettings(); | 350 | const updateSettings = useUpdatePrimarySettings(); |
| 299 | const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); | 351 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); |
| 352 | const automaticGitFetchIntervalSeconds = durationToSeconds( | ||
| 353 | resolvedBackgroundActivity.automaticGitFetchInterval, | ||
| 354 | ); | ||
| 300 | const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( | 355 | const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( |
| 301 | DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | 356 | getBackgroundActivityPresetSettings( |
| 357 | getBackgroundActivityBaseProfile(settings.backgroundActivity), | ||
| 358 | ).automaticGitFetchInterval, | ||
| 302 | ); | 359 | ); |
| 303 | const canResetFetchInterval = | 360 | const canResetFetchInterval = |
| 304 | automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; | 361 | automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; |
| ... | @@ -309,6 +366,11 @@ function GitFetchIntervalSettings() { | ... | @@ -309,6 +366,11 @@ function GitFetchIntervalSettings() { |
| 309 | <div className="min-w-0 space-y-1"> | 366 | <div className="min-w-0 space-y-1"> |
| 310 | <div className="flex min-w-0 items-center gap-1"> | 367 | <div className="flex min-w-0 items-center gap-1"> |
| 311 | <span className="text-xs font-medium text-foreground">Fetch interval</span> | 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 | <span | 374 | <span |
| 313 | className={cn( | 375 | className={cn( |
| 314 | "inline-flex size-5 shrink-0 items-center justify-center transition-opacity", | 376 | "inline-flex size-5 shrink-0 items-center justify-center transition-opacity", |
| ... | @@ -320,9 +382,11 @@ function GitFetchIntervalSettings() { | ... | @@ -320,9 +382,11 @@ function GitFetchIntervalSettings() { |
| 320 | <SettingResetButton | 382 | <SettingResetButton |
| 321 | label="fetch interval" | 383 | label="fetch interval" |
| 322 | onClick={() => | 384 | onClick={() => |
| 323 | updateSettings({ | 385 | updateSettings( |
| 324 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | 386 | backgroundActivityOverrideSettings(settings.backgroundActivity, { |
| 325 | }) | 387 | automaticGitFetchInterval: undefined, |
| 388 | }), | ||
| 389 | ) | ||
| 326 | } | 390 | } |
| 327 | /> | 391 | /> |
| 328 | ) : null} | 392 | ) : null} |
| ... | @@ -341,9 +405,11 @@ function GitFetchIntervalSettings() { | ... | @@ -341,9 +405,11 @@ function GitFetchIntervalSettings() { |
| 341 | size="sm" | 405 | size="sm" |
| 342 | className="w-32" | 406 | className="w-32" |
| 343 | onValueChange={(value) => | 407 | onValueChange={(value) => |
| 344 | updateSettings({ | 408 | updateSettings( |
| 345 | automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), | 409 | backgroundActivityOverrideSettings(settings.backgroundActivity, { |
| 346 | }) | 410 | automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), |
| 411 | }), | ||
| 412 | ) | ||
| 347 | } | 413 | } |
| 348 | > | 414 | > |
| 349 | <NumberFieldGroup> | 415 | <NumberFieldGroup> |
apps/web/src/connection/runtime.ts+19-3| ... | @@ -5,6 +5,10 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,6 +5,10 @@ import * as Layer from "effect/Layer"; |
| 5 | import { Atom } from "effect/unstable/reactivity"; | 5 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 6 | ||
| 7 | import { runtimeContextLayer } from "../lib/runtime"; | 7 | import { runtimeContextLayer } from "../lib/runtime"; |
| 8 | import { | ||
| 9 | backgroundActivityObserverLayer, | ||
| 10 | backgroundActivityReporterLayer, | ||
| 11 | } from "../lib/backgroundActivityReporter"; | ||
| 8 | import { connectionPlatformLayer } from "./platform"; | 12 | import { connectionPlatformLayer } from "./platform"; |
| 9 | 13 | ||
| 10 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( | 14 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( |
| ... | @@ -17,10 +21,22 @@ type ConnectionLayerSource = | ... | @@ -17,10 +21,22 @@ type ConnectionLayerSource = |
| 17 | | typeof Connection.layer | 21 | | typeof Connection.layer |
| 18 | | typeof snapshotLoaderLayer | 22 | | typeof snapshotLoaderLayer |
| 19 | | typeof runtimeContextLayer | 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( | 28 | const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( |
| 23 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), | 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 | export const connectionAtomRuntime: Atom.AtomRuntime< | 42 | export const connectionAtomRuntime: Atom.AtomRuntime< |
apps/web/src/env.ts+2-4| ... | @@ -1,8 +1,6 @@ | ... | @@ -1,8 +1,6 @@ |
| 1 | /** | 1 | /** |
| 2 | * True when running inside the Electron preload bridge, false in a regular browser. | 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 | * code executes, so this is reliable at module load time. | 4 | * code executes, so this is reliable at module load time. |
| 5 | */ | 5 | */ |
| 6 | export const isElectron = | 6 | export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined; |
| 7 | typeof window !== "undefined" && | ||
| 8 | (window.desktopBridge !== undefined || window.nativeApi !== undefined); |
apps/web/src/environments/primary/httpLayer.ts-1| ... | @@ -10,7 +10,6 @@ function isSameOriginBrowserPrimary(): boolean { | ... | @@ -10,7 +10,6 @@ function isSameOriginBrowserPrimary(): boolean { |
| 10 | if ( | 10 | if ( |
| 11 | typeof window === "undefined" || | 11 | typeof window === "undefined" || |
| 12 | window.desktopBridge !== undefined || | 12 | window.desktopBridge !== undefined || |
| 13 | window.nativeApi !== undefined || | ||
| 14 | !window.location.origin.startsWith("http") | 13 | !window.location.origin.startsWith("http") |
| 15 | ) { | 14 | ) { |
| 16 | return false; | 15 | return false; |
apps/web/src/hooks/useSettings.ts-1| ... | @@ -302,7 +302,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -302,7 +302,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 302 | }); | 302 | }); |
| 303 | } | 303 | } |
| 304 | } | 304 | } |
| 305 | |||
| 306 | if (Object.keys(clientPatch).length > 0) { | 305 | if (Object.keys(clientPatch).length > 0) { |
| 307 | persistClientSettings({ | 306 | persistClientSettings({ |
| 308 | ...getClientSettingsSnapshot(), | 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,7 +49,6 @@ beforeEach(() => { |
| 49 | }); | 49 | }); |
| 50 | } | 50 | } |
| 51 | Reflect.deleteProperty(testWindow(), "desktopBridge"); | 51 | Reflect.deleteProperty(testWindow(), "desktopBridge"); |
| 52 | Reflect.deleteProperty(testWindow(), "nativeApi"); | ||
| 53 | Object.defineProperty(testWindow(), "localStorage", { | 52 | Object.defineProperty(testWindow(), "localStorage", { |
| 54 | configurable: true, | 53 | configurable: true, |
| 55 | value: createLocalStorageStub(), | 54 | value: createLocalStorageStub(), |
| ... | @@ -61,16 +60,12 @@ afterEach(() => { | ... | @@ -61,16 +60,12 @@ afterEach(() => { |
| 61 | }); | 60 | }); |
| 62 | 61 | ||
| 63 | describe("LocalApi", () => { | 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 | const { createLocalApi } = await import("./localApi"); | 64 | const { createLocalApi } = await import("./localApi"); |
| 66 | const api = createLocalApi(); | 65 | const api = createLocalApi(); |
| 67 | 66 | ||
| 68 | await expect(api.server.getConfig()).rejects.toThrow( | 67 | expect(api).not.toHaveProperty("server"); |
| 69 | "Local backend API is unavailable before a backend is paired.", | 68 | expect(api.shell).not.toHaveProperty("openInEditor"); |
| 70 | ); | ||
| 71 | await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow( | ||
| 72 | "Local backend API is unavailable before a backend is paired.", | ||
| 73 | ); | ||
| 74 | }); | 69 | }); |
| 75 | 70 | ||
| 76 | it("uses the browser context-menu fallback without a desktop bridge", async () => { | 71 | it("uses the browser context-menu fallback without a desktop bridge", async () => { |
| ... | @@ -120,12 +115,4 @@ describe("LocalApi", () => { | ... | @@ -120,12 +115,4 @@ describe("LocalApi", () => { |
| 120 | await api.persistence.setClientSettings(settings); | 115 | await api.persistence.setClientSettings(settings); |
| 121 | await expect(api.persistence.getClientSettings()).resolves.toEqual(settings); | 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,10 +6,6 @@ import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientP |
| 6 | 6 | ||
| 7 | let cachedApi: LocalApi | undefined; | 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 | function createBrowserLocalApi(): LocalApi { | 9 | function createBrowserLocalApi(): LocalApi { |
| 14 | return { | 10 | return { |
| 15 | dialogs: { | 11 | dialogs: { |
| ... | @@ -25,7 +21,6 @@ function createBrowserLocalApi(): LocalApi { | ... | @@ -25,7 +21,6 @@ function createBrowserLocalApi(): LocalApi { |
| 25 | }, | 21 | }, |
| 26 | }, | 22 | }, |
| 27 | shell: { | 23 | shell: { |
| 28 | openInEditor: () => Promise.reject(unavailableLocalBackendError()), | ||
| 29 | openExternal: async (url) => { | 24 | openExternal: async (url) => { |
| 30 | if (window.desktopBridge) { | 25 | if (window.desktopBridge) { |
| 31 | const opened = await window.desktopBridge.openExternal(url); | 26 | const opened = await window.desktopBridge.openExternal(url); |
| ... | @@ -63,20 +58,6 @@ function createBrowserLocalApi(): LocalApi { | ... | @@ -63,20 +58,6 @@ function createBrowserLocalApi(): LocalApi { |
| 63 | writeBrowserClientSettings(settings); | 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,12 +69,7 @@ export function readLocalApi(): LocalApi | undefined { |
| 88 | if (typeof window === "undefined") return undefined; | 69 | if (typeof window === "undefined") return undefined; |
| 89 | if (cachedApi) return cachedApi; | 70 | if (cachedApi) return cachedApi; |
| 90 | 71 | ||
| 91 | if (window.nativeApi) { | 72 | cachedApi = createLocalApi(); |
| 92 | cachedApi = window.nativeApi; | ||
| 93 | return cachedApi; | ||
| 94 | } | ||
| 95 | |||
| 96 | cachedApi = createBrowserLocalApi(); | ||
| 97 | return cachedApi; | 73 | return cachedApi; |
| 98 | } | 74 | } |
| 99 | 75 |
apps/web/src/vite-env.d.ts+1-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | /// <reference types="vite-plus/client" /> | 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 | interface ImportMetaEnv { | 5 | interface ImportMetaEnv { |
| 6 | readonly VITE_HTTP_URL: string; | 6 | readonly VITE_HTTP_URL: string; |
| ... | @@ -22,7 +22,6 @@ interface ImportMeta { | ... | @@ -22,7 +22,6 @@ interface ImportMeta { |
| 22 | 22 | ||
| 23 | declare global { | 23 | declare global { |
| 24 | interface Window { | 24 | interface Window { |
| 25 | nativeApi?: LocalApi; | ||
| 26 | desktopBridge?: DesktopBridge; | 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,6 +39,10 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS |
| 39 | 39 | ||
| 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). | 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 | ## Event Lifecycle | 46 | ## Event Lifecycle |
| 43 | 47 | ||
| 44 | ### Startup and client connect | 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,11 +20,13 @@ |
| 20 | "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", | 20 | "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", |
| 21 | "build:marketing": "vp run --filter @t3tools/marketing build", | 21 | "build:marketing": "vp run --filter @t3tools/marketing build", |
| 22 | "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", | 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 | "typecheck": "vp run -r --concurrency-limit 2 typecheck", | 24 | "typecheck": "vp run -r --concurrency-limit 2 typecheck", |
| 24 | "tc": "vp run -r --concurrency-limit 2 typecheck", | 25 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 25 | "lint": "vp lint --report-unused-disable-directives", | 26 | "lint": "vp lint --report-unused-disable-directives", |
| 26 | "lint:mobile": "node scripts/mobile-native-static-check.ts", | 27 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 27 | "test": "vp run -r test", | 28 | "test": "vp run -r test", |
| 29 | "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", | ||
| 28 | "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", | 30 | "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", |
| 29 | "fmt": "vp fmt", | 31 | "fmt": "vp fmt", |
| 30 | "fmt:check": "vp fmt --check", | 32 | "fmt:check": "vp fmt --check", |
packages/client-runtime/src/rpc/client.ts+106-87| ... | @@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag = | ... | @@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 49 | | typeof WS_METHODS.subscribeTerminalMetadata | 49 | | typeof WS_METHODS.subscribeTerminalMetadata |
| 50 | | typeof WS_METHODS.subscribePreviewEvents | 50 | | typeof WS_METHODS.subscribePreviewEvents |
| 51 | | typeof WS_METHODS.subscribeDiscoveredLocalServers | 51 | | typeof WS_METHODS.subscribeDiscoveredLocalServers |
| 52 | | typeof WS_METHODS.subscribeResourceTelemetry | ||
| 52 | | typeof WS_METHODS.previewAutomationConnect | 53 | | typeof WS_METHODS.previewAutomationConnect |
| 53 | | typeof WS_METHODS.subscribeVcsStatus | 54 | | typeof WS_METHODS.subscribeVcsStatus |
| 54 | | typeof WS_METHODS.terminalAttach; | 55 | | typeof WS_METHODS.terminalAttach; |
| ... | @@ -62,6 +63,23 @@ export type EnvironmentStreamRpcTag = | ... | @@ -62,6 +63,23 @@ export type EnvironmentStreamRpcTag = |
| 62 | | EnvironmentStreamCommandRpcTag; | 63 | | EnvironmentStreamCommandRpcTag; |
| 63 | 64 | ||
| 64 | export type EnvironmentUnaryRpcTag = Exclude<EnvironmentRpcTag, EnvironmentStreamRpcTag>; | 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 | const isRpcClientError = Schema.is(RpcClientError.RpcClientError); | 83 | const isRpcClientError = Schema.is(RpcClientError.RpcClientError); |
| 66 | 84 | ||
| 67 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; | 85 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; |
| ... | @@ -166,93 +184,94 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | ... | @@ -166,93 +184,94 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( |
| 166 | EnvironmentSupervisor | 184 | EnvironmentSupervisor |
| 167 | > { | 185 | > { |
| 168 | return Stream.unwrap( | 186 | return Stream.unwrap( |
| 169 | EnvironmentSupervisor.pipe( | 187 | Effect.gen(function* () { |
| 170 | Effect.map((supervisor) => { | 188 | const supervisor = yield* EnvironmentSupervisor; |
| 171 | const sessionChanges = SubscriptionRef.changes(supervisor.session); | 189 | const observer = yield* EnvironmentRpcSubscriptionObserver; |
| 172 | const sessions = | 190 | const sessionChanges = SubscriptionRef.changes(supervisor.session); |
| 173 | options?.resubscribe === undefined | 191 | const sessions = |
| 174 | ? sessionChanges | 192 | options?.resubscribe === undefined |
| 175 | : Stream.merge( | 193 | ? sessionChanges |
| 176 | sessionChanges, | 194 | : Stream.merge( |
| 177 | options.resubscribe.pipe( | 195 | sessionChanges, |
| 178 | Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), | 196 | options.resubscribe.pipe( |
| 179 | ), | 197 | Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), |
| 180 | ); | 198 | ), |
| 181 | return sessions.pipe( | 199 | ); |
| 182 | Stream.switchMap( | 200 | return sessions.pipe( |
| 183 | Option.match({ | 201 | Stream.switchMap( |
| 184 | onNone: () => Stream.empty, | 202 | Option.match({ |
| 185 | onSome: (session) => { | 203 | onNone: () => Stream.empty, |
| 186 | const method = session.client[tag] as ( | 204 | onSome: (session) => { |
| 187 | input: EnvironmentRpcInput<TTag>, | 205 | const method = session.client[tag] as ( |
| 188 | ) => Stream.Stream< | 206 | input: EnvironmentRpcInput<TTag>, |
| 189 | EnvironmentRpcStreamValue<TTag>, | 207 | ) => Stream.Stream< |
| 190 | EnvironmentRpcStreamFailure<TTag> | 208 | EnvironmentRpcStreamValue<TTag>, |
| 191 | >; | 209 | EnvironmentRpcStreamFailure<TTag> |
| 192 | const subscribeToSession = (): Stream.Stream< | 210 | >; |
| 193 | EnvironmentRpcStreamValue<TTag>, | 211 | const subscribeToSession = (): Stream.Stream< |
| 194 | EnvironmentRpcStreamFailure<TTag> | 212 | EnvironmentRpcStreamValue<TTag>, |
| 195 | > => | 213 | EnvironmentRpcStreamFailure<TTag> |
| 196 | Stream.suspend(() => | 214 | > => |
| 197 | Stream.unwrap( | 215 | Stream.suspend(() => |
| 198 | makeInput(session).pipe( | 216 | Stream.unwrap( |
| 199 | Effect.map((input) => | 217 | Effect.gen(function* () { |
| 200 | method(input).pipe( | 218 | const input = yield* makeInput(session); |
| 201 | Stream.catchCause((cause) => { | 219 | const completeObservation = yield* observer.observe({ |
| 202 | const hasOnlyExpectedFailures = | 220 | environmentId: supervisor.target.environmentId, |
| 203 | cause.reasons.length > 0 && | 221 | method: tag, |
| 204 | cause.reasons.every((reason) => reason._tag === "Fail"); | 222 | input, |
| 205 | const isTransportFailure = | 223 | }); |
| 206 | hasOnlyExpectedFailures && | 224 | return method(input).pipe( |
| 207 | cause.reasons.every( | 225 | Stream.ensuring(completeObservation), |
| 208 | (reason) => | 226 | Stream.catchCause((cause) => { |
| 209 | reason._tag === "Fail" && isRpcClientError(reason.error), | 227 | const hasOnlyExpectedFailures = |
| 210 | ); | 228 | cause.reasons.length > 0 && |
| 211 | if (isTransportFailure) { | 229 | cause.reasons.every((reason) => reason._tag === "Fail"); |
| 212 | return Stream.fromEffect( | 230 | const isTransportFailure = |
| 213 | Effect.logWarning( | 231 | hasOnlyExpectedFailures && |
| 214 | "Durable RPC subscription lost its transport; waiting for the next session.", | 232 | cause.reasons.every( |
| 215 | { | 233 | (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), |
| 216 | cause: Cause.pretty(cause), | 234 | ); |
| 217 | method: tag, | 235 | if (isTransportFailure) { |
| 218 | environmentId: supervisor.target.environmentId, | 236 | return Stream.fromEffect( |
| 219 | }, | 237 | Effect.logWarning( |
| 220 | ), | 238 | "Durable RPC subscription lost its transport; waiting for the next session.", |
| 221 | ).pipe(Stream.drain); | 239 | { |
| 222 | } | 240 | cause: Cause.pretty(cause), |
| 223 | if ( | 241 | method: tag, |
| 224 | hasOnlyExpectedFailures && | 242 | environmentId: supervisor.target.environmentId, |
| 225 | options?.onExpectedFailure !== undefined | 243 | }, |
| 226 | ) { | 244 | ), |
| 227 | const handled = Stream.fromEffect( | 245 | ).pipe(Stream.drain); |
| 228 | options.onExpectedFailure(cause), | 246 | } |
| 229 | ).pipe(Stream.drain); | 247 | if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { |
| 230 | if (options.retryExpectedFailureAfter === undefined) { | 248 | const handled = Stream.fromEffect( |
| 231 | return handled; | 249 | options.onExpectedFailure(cause), |
| 232 | } | 250 | ).pipe(Stream.drain); |
| 233 | return handled.pipe( | 251 | if (options.retryExpectedFailureAfter === undefined) { |
| 234 | Stream.concat( | 252 | return handled; |
| 235 | Stream.fromEffect( | 253 | } |
| 236 | Effect.sleep(options.retryExpectedFailureAfter), | 254 | return handled.pipe( |
| 237 | ).pipe(Stream.drain), | 255 | Stream.concat( |
| 238 | ), | 256 | Stream.fromEffect( |
| 239 | Stream.concat(subscribeToSession()), | 257 | Effect.sleep(options.retryExpectedFailureAfter), |
| 240 | ); | 258 | ).pipe(Stream.drain), |
| 241 | } | 259 | ), |
| 242 | return Stream.failCause(cause); | 260 | Stream.concat(subscribeToSession()), |
| 243 | }), | 261 | ); |
| 244 | ), | 262 | } |
| 245 | ), | 263 | return Stream.failCause(cause); |
| 246 | ), | 264 | }), |
| 247 | ), | 265 | ); |
| 248 | ); | 266 | }), |
| 249 | return subscribeToSession(); | 267 | ), |
| 250 | }, | 268 | ); |
| 251 | }), | 269 | return subscribeToSession(); |
| 252 | ), | 270 | }, |
| 253 | ); | 271 | }), |
| 254 | }), | 272 | ), |
| 255 | ), | 273 | ); |
| 274 | }), | ||
| 256 | ).pipe( | 275 | ).pipe( |
| 257 | Stream.withSpan("EnvironmentRpc.subscribe", { | 276 | Stream.withSpan("EnvironmentRpc.subscribe", { |
| 258 | attributes: { "rpc.method": tag }, | 277 | attributes: { "rpc.method": tag }, |
packages/client-runtime/src/state/server.ts+18| ... | @@ -298,6 +298,16 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -298,6 +298,16 @@ export function createServerEnvironmentAtoms<R, E>( |
| 298 | label: "environment-data:server:process-resource-history", | 298 | label: "environment-data:server:process-resource-history", |
| 299 | tag: WS_METHODS.serverGetProcessResourceHistory, | 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 | configProjection, | 311 | configProjection, |
| 302 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | 312 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { |
| 303 | label: "environment-data:server:welcome", | 313 | label: "environment-data:server:welcome", |
| ... | @@ -349,5 +359,13 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -349,5 +359,13 @@ export function createServerEnvironmentAtoms<R, E>( |
| 349 | label: "environment-data:server:signal-process", | 359 | label: "environment-data:server:signal-process", |
| 350 | tag: WS_METHODS.serverSignalProcess, | 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,6 +43,8 @@ export const TurnId = makeEntityId("TurnId"); |
| 43 | export type TurnId = typeof TurnId.Type; | 43 | export type TurnId = typeof TurnId.Type; |
| 44 | export const AuthSessionId = makeEntityId("AuthSessionId"); | 44 | export const AuthSessionId = makeEntityId("AuthSessionId"); |
| 45 | export type AuthSessionId = typeof AuthSessionId.Type; | 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 | export const ProviderItemId = makeEntityId("ProviderItemId"); | 49 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 48 | export type ProviderItemId = typeof ProviderItemId.Type; | 50 | export type ProviderItemId = typeof ProviderItemId.Type; |
packages/contracts/src/desktopBootstrap.ts+4-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as Schema from "effect/Schema"; | 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 | export const DesktopBackendBootstrap = Schema.Struct({ | 5 | export const DesktopBackendBootstrap = Schema.Struct({ |
| 6 | mode: Schema.Literal("desktop"), | 6 | mode: Schema.Literal("desktop"), |
| ... | @@ -16,6 +16,9 @@ export const DesktopBackendBootstrap = Schema.Struct({ | ... | @@ -16,6 +16,9 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 16 | tailscaleServePort: PortSchema, | 16 | tailscaleServePort: PortSchema, |
| 17 | otlpTracesUrl: Schema.optional(Schema.String), | 17 | otlpTracesUrl: Schema.optional(Schema.String), |
| 18 | otlpMetricsUrl: Schema.optional(Schema.String), | 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 | export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; | 24 | export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; |
packages/contracts/src/index.ts+2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | export * from "./baseSchemas.ts"; | 1 | export * from "./baseSchemas.ts"; |
| 2 | export * from "./background.ts"; | ||
| 2 | export * from "./auth.ts"; | 3 | export * from "./auth.ts"; |
| 3 | export * from "./environment.ts"; | 4 | export * from "./environment.ts"; |
| 4 | export * from "./environmentHttp.ts"; | 5 | export * from "./environmentHttp.ts"; |
| ... | @@ -26,4 +27,5 @@ export * from "./assets.ts"; | ... | @@ -26,4 +27,5 @@ export * from "./assets.ts"; |
| 26 | export * from "./review.ts"; | 27 | export * from "./review.ts"; |
| 27 | export * from "./preview.ts"; | 28 | export * from "./preview.ts"; |
| 28 | export * from "./previewAutomation.ts"; | 29 | export * from "./previewAutomation.ts"; |
| 30 | export * from "./resourceTelemetry.ts"; | ||
| 29 | export * from "./rpc.ts"; | 31 | export * from "./rpc.ts"; |
packages/contracts/src/ipc.ts+2-43| ... | @@ -31,20 +31,6 @@ import type { | ... | @@ -31,20 +31,6 @@ import type { |
| 31 | ProjectWriteFileInput, | 31 | ProjectWriteFileInput, |
| 32 | ProjectWriteFileResult, | 32 | ProjectWriteFileResult, |
| 33 | } from "./project.ts"; | 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 | import type { | 34 | import type { |
| 49 | TerminalAttachInput, | 35 | TerminalAttachInput, |
| 50 | TerminalAttachStreamEvent, | 36 | TerminalAttachStreamEvent, |
| ... | @@ -57,7 +43,6 @@ import type { | ... | @@ -57,7 +43,6 @@ import type { |
| 57 | TerminalSessionSnapshot, | 43 | TerminalSessionSnapshot, |
| 58 | TerminalWriteInput, | 44 | TerminalWriteInput, |
| 59 | } from "./terminal.ts"; | 45 | } from "./terminal.ts"; |
| 60 | import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; | ||
| 61 | import * as Schema from "effect/Schema"; | 46 | import * as Schema from "effect/Schema"; |
| 62 | import type { | 47 | import type { |
| 63 | DiscoveredLocalServerList, | 48 | DiscoveredLocalServerList, |
| ... | @@ -100,13 +85,11 @@ import type { | ... | @@ -100,13 +85,11 @@ import type { |
| 100 | import { EnvironmentId } from "./baseSchemas.ts"; | 85 | import { EnvironmentId } from "./baseSchemas.ts"; |
| 101 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; | 86 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 102 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; | 87 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 103 | import { EditorId } from "./editor.ts"; | ||
| 104 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 88 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 105 | import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; | 89 | import type { ClientSettings } from "./settings.ts"; |
| 106 | import type { | 90 | import type { |
| 107 | SourceControlCloneRepositoryInput, | 91 | SourceControlCloneRepositoryInput, |
| 108 | SourceControlCloneRepositoryResult, | 92 | SourceControlCloneRepositoryResult, |
| 109 | SourceControlDiscoveryResult, | ||
| 110 | SourceControlPublishRepositoryInput, | 93 | SourceControlPublishRepositoryInput, |
| 111 | SourceControlPublishRepositoryResult, | 94 | SourceControlPublishRepositoryResult, |
| 112 | SourceControlRepositoryInfo, | 95 | SourceControlRepositoryInfo, |
| ... | @@ -1115,7 +1098,7 @@ export interface DesktopPreviewBridge { | ... | @@ -1115,7 +1098,7 @@ export interface DesktopPreviewBridge { |
| 1115 | * APIs bound to the local app shell, not to any particular backend environment. | 1098 | * APIs bound to the local app shell, not to any particular backend environment. |
| 1116 | * | 1099 | * |
| 1117 | * These capabilities describe the desktop/browser host that the user is | 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 | * app-level settings/config access. They must not be used as a proxy for | 1102 | * app-level settings/config access. They must not be used as a proxy for |
| 1120 | * "whatever environment the user is targeting", because in a multi-environment | 1103 | * "whatever environment the user is targeting", because in a multi-environment |
| 1121 | * world the local shell and a selected backend environment are distinct | 1104 | * world the local shell and a selected backend environment are distinct |
| ... | @@ -1127,7 +1110,6 @@ export interface LocalApi { | ... | @@ -1127,7 +1110,6 @@ export interface LocalApi { |
| 1127 | confirm: (message: string) => Promise<boolean>; | 1110 | confirm: (message: string) => Promise<boolean>; |
| 1128 | }; | 1111 | }; |
| 1129 | shell: { | 1112 | shell: { |
| 1130 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | ||
| 1131 | openExternal: (url: string) => Promise<void>; | 1113 | openExternal: (url: string) => Promise<void>; |
| 1132 | }; | 1114 | }; |
| 1133 | contextMenu: { | 1115 | contextMenu: { |
| ... | @@ -1140,29 +1122,6 @@ export interface LocalApi { | ... | @@ -1140,29 +1122,6 @@ export interface LocalApi { |
| 1140 | getClientSettings: () => Promise<ClientSettings | null>; | 1122 | getClientSettings: () => Promise<ClientSettings | null>; |
| 1141 | setClientSettings: (settings: ClientSettings) => Promise<void>; | 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,6 +8,11 @@ import { |
| 8 | AuthAccessStreamEvent, | 8 | AuthAccessStreamEvent, |
| 9 | EnvironmentAuthorizationError, | 9 | EnvironmentAuthorizationError, |
| 10 | } from "./auth.ts"; | 10 | } from "./auth.ts"; |
| 11 | import { | ||
| 12 | BackgroundPolicySnapshot, | ||
| 13 | ClientActivityReportInput, | ||
| 14 | HostPowerSnapshot, | ||
| 15 | } from "./background.ts"; | ||
| 11 | import { | 16 | import { |
| 12 | FilesystemBrowseInput, | 17 | FilesystemBrowseInput, |
| 13 | FilesystemBrowseResult, | 18 | FilesystemBrowseResult, |
| ... | @@ -132,6 +137,12 @@ import { | ... | @@ -132,6 +137,12 @@ import { |
| 132 | ServerUpsertKeybindingInput, | 137 | ServerUpsertKeybindingInput, |
| 133 | ServerUpsertKeybindingResult, | 138 | ServerUpsertKeybindingResult, |
| 134 | } from "./server.ts"; | 139 | } from "./server.ts"; |
| 140 | import { | ||
| 141 | ResourceTelemetryHistory, | ||
| 142 | ResourceTelemetryHistoryInput, | ||
| 143 | ResourceTelemetryRetryResult, | ||
| 144 | ResourceTelemetrySnapshot, | ||
| 145 | } from "./resourceTelemetry.ts"; | ||
| 135 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; | 146 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 136 | import { | 147 | import { |
| 137 | SourceControlCloneRepositoryInput, | 148 | SourceControlCloneRepositoryInput, |
| ... | @@ -215,7 +226,12 @@ export const WS_METHODS = { | ... | @@ -215,7 +226,12 @@ export const WS_METHODS = { |
| 215 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", | 226 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", |
| 216 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", | 227 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", |
| 217 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", | 228 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 229 | serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", | ||
| 230 | serverRetryResourceTelemetry: "server.retryResourceTelemetry", | ||
| 218 | serverSignalProcess: "server.signalProcess", | 231 | serverSignalProcess: "server.signalProcess", |
| 232 | serverReportClientActivity: "server.reportClientActivity", | ||
| 233 | serverReportHostPowerState: "server.reportHostPowerState", | ||
| 234 | serverGetBackgroundPolicy: "server.getBackgroundPolicy", | ||
| 219 | 235 | ||
| 220 | // Cloud environment methods | 236 | // Cloud environment methods |
| 221 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", | 237 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", |
| ... | @@ -235,6 +251,8 @@ export const WS_METHODS = { | ... | @@ -235,6 +251,8 @@ export const WS_METHODS = { |
| 235 | subscribeServerConfig: "subscribeServerConfig", | 251 | subscribeServerConfig: "subscribeServerConfig", |
| 236 | subscribeServerLifecycle: "subscribeServerLifecycle", | 252 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 237 | subscribeAuthAccess: "subscribeAuthAccess", | 253 | subscribeAuthAccess: "subscribeAuthAccess", |
| 254 | subscribeBackgroundPolicy: "subscribeBackgroundPolicy", | ||
| 255 | subscribeResourceTelemetry: "subscribeResourceTelemetry", | ||
| 238 | } as const; | 256 | } as const; |
| 239 | 257 | ||
| 240 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { | 258 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { |
| ... | @@ -326,6 +344,21 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( | ... | @@ -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 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { | 362 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { |
| 330 | payload: ServerSignalProcessInput, | 363 | payload: ServerSignalProcessInput, |
| 331 | success: ServerSignalProcessResult, | 364 | success: ServerSignalProcessResult, |
| ... | @@ -345,6 +378,22 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela | ... | @@ -345,6 +378,22 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela |
| 345 | stream: true, | 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 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( | 397 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( |
| 349 | WS_METHODS.sourceControlLookupRepository, | 398 | WS_METHODS.sourceControlLookupRepository, |
| 350 | { | 399 | { |
| ... | @@ -690,6 +739,20 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, | ... | @@ -690,6 +739,20 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, |
| 690 | stream: true, | 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 | export const WsRpcGroup = RpcGroup.make( | 756 | export const WsRpcGroup = RpcGroup.make( |
| 694 | WsServerProbeRpc, | 757 | WsServerProbeRpc, |
| 695 | WsServerGetConfigRpc, | 758 | WsServerGetConfigRpc, |
| ... | @@ -704,7 +767,12 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -704,7 +767,12 @@ export const WsRpcGroup = RpcGroup.make( |
| 704 | WsServerGetTraceDiagnosticsRpc, | 767 | WsServerGetTraceDiagnosticsRpc, |
| 705 | WsServerGetProcessDiagnosticsRpc, | 768 | WsServerGetProcessDiagnosticsRpc, |
| 706 | WsServerGetProcessResourceHistoryRpc, | 769 | WsServerGetProcessResourceHistoryRpc, |
| 770 | WsServerGetResourceTelemetryHistoryRpc, | ||
| 771 | WsServerRetryResourceTelemetryRpc, | ||
| 707 | WsServerSignalProcessRpc, | 772 | WsServerSignalProcessRpc, |
| 773 | WsServerReportClientActivityRpc, | ||
| 774 | WsServerReportHostPowerStateRpc, | ||
| 775 | WsServerGetBackgroundPolicyRpc, | ||
| 708 | WsCloudGetRelayClientStatusRpc, | 776 | WsCloudGetRelayClientStatusRpc, |
| 709 | WsCloudInstallRelayClientRpc, | 777 | WsCloudInstallRelayClientRpc, |
| 710 | WsSourceControlLookupRepositoryRpc, | 778 | WsSourceControlLookupRepositoryRpc, |
| ... | @@ -754,6 +822,8 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -754,6 +822,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 754 | WsSubscribeServerConfigRpc, | 822 | WsSubscribeServerConfigRpc, |
| 755 | WsSubscribeServerLifecycleRpc, | 823 | WsSubscribeServerLifecycleRpc, |
| 756 | WsSubscribeAuthAccessRpc, | 824 | WsSubscribeAuthAccessRpc, |
| 825 | WsSubscribeBackgroundPolicyRpc, | ||
| 826 | WsSubscribeResourceTelemetryRpc, | ||
| 757 | WsOrchestrationDispatchCommandRpc, | 827 | WsOrchestrationDispatchCommandRpc, |
| 758 | WsOrchestrationGetTurnDiffRpc, | 828 | WsOrchestrationGetTurnDiffRpc, |
| 759 | WsOrchestrationGetFullThreadDiffRpc, | 829 | WsOrchestrationGetFullThreadDiffRpc, |
packages/contracts/src/server.ts+2| ... | @@ -303,6 +303,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type; | ... | @@ -303,6 +303,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type; |
| 303 | 303 | ||
| 304 | export const ServerProcessDiagnosticsEntry = Schema.Struct({ | 304 | export const ServerProcessDiagnosticsEntry = Schema.Struct({ |
| 305 | pid: PositiveInt, | 305 | pid: PositiveInt, |
| 306 | startTimeMs: NonNegativeInt, | ||
| 306 | ppid: NonNegativeInt, | 307 | ppid: NonNegativeInt, |
| 307 | pgid: Schema.Option(Schema.Int), | 308 | pgid: Schema.Option(Schema.Int), |
| 308 | status: TrimmedNonEmptyString, | 309 | status: TrimmedNonEmptyString, |
| ... | @@ -395,6 +396,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis | ... | @@ -395,6 +396,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis |
| 395 | 396 | ||
| 396 | export const ServerSignalProcessInput = Schema.Struct({ | 397 | export const ServerSignalProcessInput = Schema.Struct({ |
| 397 | pid: PositiveInt, | 398 | pid: PositiveInt, |
| 399 | startTimeMs: NonNegativeInt, | ||
| 398 | signal: ServerProcessSignal, | 400 | signal: ServerProcessSignal, |
| 399 | }); | 401 | }); |
| 400 | export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type; | 402 | export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type; |
packages/contracts/src/settings.ts+61| ... | @@ -426,15 +426,66 @@ export const SourceControlWritingStyleSettings = Schema.Struct({ | ... | @@ -426,15 +426,66 @@ export const SourceControlWritingStyleSettings = Schema.Struct({ |
| 426 | export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type; | 426 | export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type; |
| 427 | 427 | ||
| 428 | export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); | 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 | export const ServerSettings = Schema.Struct({ | 470 | export const ServerSettings = Schema.Struct({ |
| 431 | enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 471 | enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 432 | enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 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 | automaticGitFetchInterval: Schema.DurationFromMillis.pipe( | 476 | automaticGitFetchInterval: Schema.DurationFromMillis.pipe( |
| 434 | Schema.withDecodingDefault( | 477 | Schema.withDecodingDefault( |
| 435 | Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), | 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 | defaultThreadEnvMode: ThreadEnvMode.pipe( | 489 | defaultThreadEnvMode: ThreadEnvMode.pipe( |
| 439 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), | 490 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), |
| 440 | ), | 491 | ), |
| ... | @@ -576,7 +627,17 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -576,7 +627,17 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 576 | // Server settings | 627 | // Server settings |
| 577 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), | 628 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| 578 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), | 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 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), | 638 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 639 | providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), | ||
| 640 | backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), | ||
| 580 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), | 641 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 581 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), | 642 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 582 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), | 643 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), |
packages/shared/package.json+4| ... | @@ -79,6 +79,10 @@ | ... | @@ -79,6 +79,10 @@ |
| 79 | "types": "./src/serverSettings.ts", | 79 | "types": "./src/serverSettings.ts", |
| 80 | "import": "./src/serverSettings.ts" | 80 | "import": "./src/serverSettings.ts" |
| 81 | }, | 81 | }, |
| 82 | "./backgroundActivitySettings": { | ||
| 83 | "types": "./src/backgroundActivitySettings.ts", | ||
| 84 | "import": "./src/backgroundActivitySettings.ts" | ||
| 85 | }, | ||
| 82 | "./String": { | 86 | "./String": { |
| 83 | "types": "./src/String.ts", | 87 | "types": "./src/String.ts", |
| 84 | "import": "./src/String.ts" | 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,6 +127,25 @@ describe("RotatingFileSink", () => { |
| 127 | expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); | 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 | it("preserves backup pruning failures", () => { | 149 | it("preserves backup pruning failures", () => { |
| 131 | const directory = makeTempDirectory(); | 150 | const directory = makeTempDirectory(); |
| 132 | const filePath = NodePath.join(directory, "log.ndjson"); | 151 | const filePath = NodePath.join(directory, "log.ndjson"); |
packages/shared/src/logging.ts-4| ... | @@ -93,10 +93,6 @@ export class RotatingFileSink { | ... | @@ -93,10 +93,6 @@ export class RotatingFileSink { |
| 93 | 93 | ||
| 94 | NodeFS.appendFileSync(this.filePath, buffer); | 94 | NodeFS.appendFileSync(this.filePath, buffer); |
| 95 | this.currentSize += buffer.length; | 95 | this.currentSize += buffer.length; |
| 96 | |||
| 97 | if (this.currentSize > this.maxBytes) { | ||
| 98 | this.rotate(); | ||
| 99 | } | ||
| 100 | } catch (cause) { | 96 | } catch (cause) { |
| 101 | if (isRotatingFileSinkError(cause)) { | 97 | if (isRotatingFileSinkError(cause)) { |
| 102 | throw cause; | 98 | throw cause; |
packages/shared/src/observability.test.ts+95-1| ... | @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; |
| 8 | import * as Logger from "effect/Logger"; | 8 | import * as Logger from "effect/Logger"; |
| 9 | import * as Order from "effect/Order"; | 9 | import * as Order from "effect/Order"; |
| 10 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| 11 | import * as Ref from "effect/Ref"; | ||
| 11 | import * as References from "effect/References"; | 12 | import * as References from "effect/References"; |
| 12 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 13 | import * as Tracer from "effect/Tracer"; | 14 | import * as Tracer from "effect/Tracer"; |
| ... | @@ -19,6 +20,7 @@ import { | ... | @@ -19,6 +20,7 @@ import { |
| 19 | makeLocalFileTracer, | 20 | makeLocalFileTracer, |
| 20 | makeTraceSink, | 21 | makeTraceSink, |
| 21 | type TraceRecord, | 22 | type TraceRecord, |
| 23 | type TraceSinkFlushStats, | ||
| 22 | } from "./observability.ts"; | 24 | } from "./observability.ts"; |
| 23 | 25 | ||
| 24 | describe("errorTag", () => { | 26 | describe("errorTag", () => { |
| ... | @@ -176,6 +178,34 @@ describe("observability", () => { | ... | @@ -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 | it.effect("rotates the trace file when the configured max size is exceeded", () => | 209 | it.effect("rotates the trace file when the configured max size is exceeded", () => |
| 180 | Effect.scoped( | 210 | Effect.scoped( |
| 181 | Effect.gen(function* () { | 211 | Effect.gen(function* () { |
| ... | @@ -186,7 +216,7 @@ describe("observability", () => { | ... | @@ -186,7 +216,7 @@ describe("observability", () => { |
| 186 | 216 | ||
| 187 | const sink = yield* makeTraceSink({ | 217 | const sink = yield* makeTraceSink({ |
| 188 | filePath: tracePath, | 218 | filePath: tracePath, |
| 189 | maxBytes: 180, | 219 | maxBytes: 500, |
| 190 | maxFiles: 2, | 220 | maxFiles: 2, |
| 191 | batchWindowMs: 10_000, | 221 | batchWindowMs: 10_000, |
| 192 | }); | 222 | }); |
| ... | @@ -217,6 +247,70 @@ describe("observability", () => { | ... | @@ -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 | it.effect("drops only the invalid trace record when serialization fails", () => | 314 | it.effect("drops only the invalid trace record when serialization fails", () => |
| 221 | Effect.scoped( | 315 | Effect.scoped( |
| 222 | Effect.gen(function* () { | 316 | Effect.gen(function* () { |
packages/shared/src/observability.ts+63-7| ... | @@ -8,7 +8,8 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; | ... | @@ -8,7 +8,8 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; |
| 8 | 8 | ||
| 9 | import { RotatingFileSink } from "./logging.ts"; | 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 | export type TraceAttributes = Readonly<Record<string, unknown>>; | 14 | export type TraceAttributes = Readonly<Record<string, unknown>>; |
| 14 | 15 | ||
| ... | @@ -109,6 +110,13 @@ export interface TraceSinkOptions { | ... | @@ -109,6 +110,13 @@ export interface TraceSinkOptions { |
| 109 | readonly maxBytes: number; | 110 | readonly maxBytes: number; |
| 110 | readonly maxFiles: number; | 111 | readonly maxFiles: number; |
| 111 | readonly batchWindowMs: number; | 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 | export interface TraceSink { | 122 | export interface TraceSink { |
| ... | @@ -275,26 +283,73 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac | ... | @@ -275,26 +283,73 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac |
| 275 | filePath: options.filePath, | 283 | filePath: options.filePath, |
| 276 | maxBytes: options.maxBytes, | 284 | maxBytes: options.maxBytes, |
| 277 | maxFiles: options.maxFiles, | 285 | maxFiles: options.maxFiles, |
| 286 | throwOnError: true, | ||
| 278 | }); | 287 | }); |
| 279 | 288 | ||
| 280 | let buffer: Array<string> = []; | 289 | let buffer: Array<string> = []; |
| 290 | let pendingFlushStats: TraceSinkFlushStats = { | ||
| 291 | logicalWriteBytes: 0, | ||
| 292 | count: 0, | ||
| 293 | durationMs: 0, | ||
| 294 | }; | ||
| 281 | 295 | ||
| 282 | const flushUnsafe = () => { | 296 | const flushUnsafe = () => { |
| 283 | if (buffer.length === 0) { | 297 | if (buffer.length === 0) { |
| 284 | return; | 298 | return; |
| 285 | } | 299 | } |
| 286 | 300 | ||
| 287 | const chunk = buffer.join(""); | 301 | const records = buffer; |
| 288 | buffer = []; | 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 { | 321 | const chunk = records.slice(persistedCount, nextIndex).join(""); |
| 291 | sink.write(chunk); | 322 | const startedAt = performance.now(); |
| 292 | } catch { | 323 | try { |
| 293 | buffer.unshift(chunk); | 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 | yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); | 354 | yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); |
| 300 | yield* Effect.forkScoped( | 355 | yield* Effect.forkScoped( |
| ... | @@ -399,6 +454,7 @@ export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( | ... | @@ -399,6 +454,7 @@ export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( |
| 399 | maxBytes: options.maxBytes, | 454 | maxBytes: options.maxBytes, |
| 400 | maxFiles: options.maxFiles, | 455 | maxFiles: options.maxFiles, |
| 401 | batchWindowMs: options.batchWindowMs, | 456 | batchWindowMs: options.batchWindowMs, |
| 457 | ...(options.onFlush ? { onFlush: options.onFlush } : {}), | ||
| 402 | })); | 458 | })); |
| 403 | 459 | ||
| 404 | const delegate = | 460 | const delegate = |
packages/shared/src/serverSettings.test.ts+217| ... | @@ -4,7 +4,9 @@ import { | ... | @@ -4,7 +4,9 @@ import { |
| 4 | ProviderInstanceId, | 4 | ProviderInstanceId, |
| 5 | type ServerProvider, | 5 | type ServerProvider, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import * as Duration from "effect/Duration"; | ||
| 7 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; | ||
| 8 | import { createModelSelection } from "./model.ts"; | 10 | import { createModelSelection } from "./model.ts"; |
| 9 | import { | 11 | import { |
| 10 | applyServerSettingsPatch, | 12 | applyServerSettingsPatch, |
| ... | @@ -295,4 +297,219 @@ describe("serverSettings helpers", () => { | ... | @@ -295,4 +297,219 @@ describe("serverSettings helpers", () => { |
| 295 | config: { homePath: "~/.codex" }, | 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,6 +12,12 @@ import * as Schema from "effect/Schema"; |
| 12 | import { deepMerge } from "./Struct.ts"; | 12 | import { deepMerge } from "./Struct.ts"; |
| 13 | import { fromLenientJson } from "./schemaJson.ts"; | 13 | import { fromLenientJson } from "./schemaJson.ts"; |
| 14 | import { createModelSelection } from "./model.ts"; | 14 | import { createModelSelection } from "./model.ts"; |
| 15 | import { | ||
| 16 | getBackgroundActivityBaseProfile, | ||
| 17 | normalizeBackgroundActivitySettings, | ||
| 18 | normalizeServerBackgroundActivitySettings, | ||
| 19 | resolveBackgroundActivitySettings, | ||
| 20 | } from "./backgroundActivitySettings.ts"; | ||
| 15 | 21 | ||
| 16 | const ServerSettingsJson = fromLenientJson(ServerSettings); | 22 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 17 | const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); | 23 | const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); |
| ... | @@ -120,10 +126,64 @@ export function applyServerSettingsPatch( | ... | @@ -120,10 +126,64 @@ export function applyServerSettingsPatch( |
| 120 | patch: ServerSettingsPatch, | 126 | patch: ServerSettingsPatch, |
| 121 | ): ServerSettings { | 127 | ): ServerSettings { |
| 122 | const selectionPatch = patch.textGenerationModelSelection; | 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 | const next = deepMerge(current, patchForMerge); | 171 | const next = deepMerge(current, patchForMerge); |
| 125 | const nextWithReplacements = { | 172 | const nextWithReplacementsBase = { |
| 126 | ...next, | 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 | ...(patch.providerInstances !== undefined | 187 | ...(patch.providerInstances !== undefined |
| 128 | ? { providerInstances: patch.providerInstances } | 188 | ? { providerInstances: patch.providerInstances } |
| 129 | : {}), | 189 | : {}), |
| ... | @@ -131,6 +191,20 @@ export function applyServerSettingsPatch( | ... | @@ -131,6 +191,20 @@ export function applyServerSettingsPatch( |
| 131 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } | 191 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } |
| 132 | : {}), | 192 | : {}), |
| 133 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), | 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 | if (!selectionPatch) { | 209 | if (!selectionPatch) { |
| 136 | return nextWithReplacements; | 210 | return nextWithReplacements; |
scripts/build-desktop-artifact.test.ts+50| ... | @@ -15,9 +15,11 @@ import { | ... | @@ -15,9 +15,11 @@ import { |
| 15 | createBuildConfig, | 15 | createBuildConfig, |
| 16 | DESKTOP_ELECTRON_LANGUAGES, | 16 | DESKTOP_ELECTRON_LANGUAGES, |
| 17 | DESKTOP_FILE_EXCLUSIONS, | 17 | DESKTOP_FILE_EXCLUSIONS, |
| 18 | DESKTOP_EXTRA_RESOURCES, | ||
| 18 | InvalidMacPasskeyRpDomainError, | 19 | InvalidMacPasskeyRpDomainError, |
| 19 | InvalidMacPasskeyPublishableKeyError, | 20 | InvalidMacPasskeyPublishableKeyError, |
| 20 | InvalidMockUpdateServerPortError, | 21 | InvalidMockUpdateServerPortError, |
| 22 | UnsupportedDesktopBuildArchitectureError, | ||
| 21 | isMacPasskeySigningConfigurationError, | 23 | isMacPasskeySigningConfigurationError, |
| 22 | LinuxIconResizeError, | 24 | LinuxIconResizeError, |
| 23 | MacPasskeySigningConfigurationResolutionError, | 25 | MacPasskeySigningConfigurationResolutionError, |
| ... | @@ -32,6 +34,8 @@ import { | ... | @@ -32,6 +34,8 @@ import { |
| 32 | resolveDesktopProductName, | 34 | resolveDesktopProductName, |
| 33 | resolveDesktopUpdateChannel, | 35 | resolveDesktopUpdateChannel, |
| 34 | resolveDesktopWebAssetBrand, | 36 | resolveDesktopWebAssetBrand, |
| 37 | resolveResourceMonitorRustTargets, | ||
| 38 | resourceMonitorExecutableName, | ||
| 35 | resolveGitHubPublishConfig, | 39 | resolveGitHubPublishConfig, |
| 36 | resolveMockUpdateServerPort, | 40 | resolveMockUpdateServerPort, |
| 37 | resolveMockUpdateServerUrl, | 41 | resolveMockUpdateServerUrl, |
| ... | @@ -543,6 +547,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -543,6 +547,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 543 | }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), | 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 | it("promotes target fff binaries to direct staged dependencies", () => { | 570 | it("promotes target fff binaries to direct staged dependencies", () => { |
| 547 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { | 571 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { |
| 548 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", | 572 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", |
| ... | @@ -673,6 +697,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -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 | it.effect("preserves explicit false boolean flags over true env defaults", () => | 726 | it.effect("preserves explicit false boolean flags over true env defaults", () => |
| 677 | Effect.gen(function* () { | 727 | Effect.gen(function* () { |
| 678 | const resolved = yield* resolveBuildOptions({ | 728 | const resolved = yield* resolveBuildOptions({ |
scripts/build-desktop-artifact.ts+144| ... | @@ -94,6 +94,26 @@ interface PlatformConfig { | ... | @@ -94,6 +94,26 @@ interface PlatformConfig { |
| 94 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; | 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 | const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = { | 117 | const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = { |
| 98 | mac: { | 118 | mac: { |
| 99 | cliFlag: "--mac", | 119 | cliFlag: "--mac", |
| ... | @@ -189,6 +209,19 @@ export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass<U | ... | @@ -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 | const InvalidMockUpdateServerPortReason = Schema.Literals([ | 225 | const InvalidMockUpdateServerPortReason = Schema.Literals([ |
| 193 | "not-numeric", | 226 | "not-numeric", |
| 194 | "not-integer", | 227 | "not-integer", |
| ... | @@ -236,6 +269,20 @@ export class BuildCommandFailedError extends Schema.TaggedErrorClass<BuildComman | ... | @@ -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 | const desktopIconPlatformNames = { | 286 | const desktopIconPlatformNames = { |
| 240 | mac: "macOS", | 287 | mac: "macOS", |
| 241 | linux: "Linux", | 288 | linux: "Linux", |
| ... | @@ -593,6 +640,12 @@ export const DESKTOP_FILE_EXCLUSIONS = [ | ... | @@ -593,6 +640,12 @@ export const DESKTOP_FILE_EXCLUSIONS = [ |
| 593 | // The Windows primary backend reads the same files through the asar redirect, | 640 | // The Windows primary backend reads the same files through the asar redirect, |
| 594 | // so nothing is duplicated. | 641 | // so nothing is duplicated. |
| 595 | export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const; | 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 | export interface MacPasskeySigningConfiguration { | 650 | export interface MacPasskeySigningConfiguration { |
| 598 | readonly appId: string; | 651 | readonly appId: string; |
| ... | @@ -1044,6 +1097,14 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( | ... | @@ -1044,6 +1097,14 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1044 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); | 1097 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); |
| 1045 | const defaultArch = yield* getDefaultArch(platform); | 1098 | const defaultArch = yield* getDefaultArch(platform); |
| 1046 | const arch = mergeOptions(input.arch, env.arch, defaultArch); | 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 | const version = mergeOptions(input.buildVersion, env.version, undefined); | 1108 | const version = mergeOptions(input.buildVersion, env.version, undefined); |
| 1048 | const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) | 1109 | const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) |
| 1049 | ? "release-mock" | 1110 | ? "release-mock" |
| ... | @@ -1117,6 +1178,81 @@ const runCommand = Effect.fn("runCommand")(function* ( | ... | @@ -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 | function generateMacIconSet( | 1256 | function generateMacIconSet( |
| 1121 | sourcePng: string, | 1257 | sourcePng: string, |
| 1122 | targetIcns: string, | 1258 | targetIcns: string, |
| ... | @@ -1412,6 +1548,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -1412,6 +1548,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 1412 | // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack | 1548 | // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack |
| 1413 | // extracts native libraries, which fff-node finds in app.asar.unpacked. | 1549 | // extracts native libraries, which fff-node finds in app.asar.unpacked. |
| 1414 | ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}), | 1550 | ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}), |
| 1551 | extraResources: DESKTOP_EXTRA_RESOURCES, | ||
| 1415 | }; | 1552 | }; |
| 1416 | const updateChannel = resolveDesktopUpdateChannel(version); | 1553 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 1417 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); | 1554 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| ... | @@ -1691,6 +1828,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -1691,6 +1828,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1691 | yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); | 1828 | yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); |
| 1692 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); | 1829 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); |
| 1693 | yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); | 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 | yield* assertPlatformBuildResources( | 1839 | yield* assertPlatformBuildResources( |
| 1696 | options.platform, | 1840 | options.platform, |