From e7b0045dee2427a3c5872e432d14cc9c63676933 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 29 Jul 2026 13:39:24 +0200 Subject: [PATCH 01/15] Update model version from claude-opus-4-8 to claude-opus-5 (#4832) (cherry picked from commit c3e8fb67d73955862ac312c801d45f2cc00c7178) --- .macroscope/check-run-agents/effect-service-conventions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 09f4db079655..542e9028d36f 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -1,6 +1,6 @@ --- title: Effect Service Conventions -model: claude-opus-4-8 +model: claude-opus-5 effort: high input: full_diff tools: From 3f1fca8b53926cdef80f4f6d0517a656ad239824 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 29 Jul 2026 14:42:01 +0200 Subject: [PATCH 02/15] Reduce idle work and disk churn with native resource diagnostics (#2679) Co-authored-by: codex (cherry picked from commit 49c0d96edf280f1fe6750a83161a3a2cff0f25ef) --- .github/workflows/ci.yml | 14 + .github/workflows/release.yml | 51 + .gitignore | 1 + .../src/app/DesktopAppIdentity.test.ts | 1 + apps/desktop/src/app/DesktopLifecycle.test.ts | 1 + .../src/app/DesktopObservability.test.ts | 194 ++- apps/desktop/src/app/DesktopObservability.ts | 203 ++- .../DesktopBackendConfiguration.test.ts | 96 +- .../backend/DesktopBackendConfiguration.ts | 58 +- .../src/backend/DesktopBackendManager.test.ts | 716 +++++++++- .../src/backend/DesktopBackendManager.ts | 610 ++++++-- .../src/backend/DesktopBackendPool.test.ts | 15 +- .../desktop/src/backend/DesktopBackendPool.ts | 4 +- apps/desktop/src/electron/ElectronApp.ts | 2 + .../src/electron/ElectronPowerMonitor.ts | 89 ++ apps/desktop/src/main.ts | 4 + apps/desktop/src/preview/Manager.test.ts | 86 ++ apps/desktop/src/preview/Manager.ts | 61 +- .../DesktopTelemetryPublisher.test.ts | 388 +++++ .../telemetry/DesktopTelemetryPublisher.ts | 379 +++++ .../src/window/DesktopApplicationMenu.test.ts | 1 + .../connection/background-activity-scopes.ts | 92 ++ .../connection/background-activity.test.ts | 77 + .../src/connection/background-activity.ts | 116 ++ apps/mobile/src/connection/runtime.ts | 22 +- apps/server/src/auth/RpcAuthorization.test.ts | 47 + apps/server/src/auth/RpcAuthorization.ts | 111 ++ .../src/background/BackgroundPolicy.test.ts | 386 +++++ .../server/src/background/BackgroundPolicy.ts | 349 +++++ .../src/background/HostPowerMonitor.test.ts | 188 +++ .../server/src/background/HostPowerMonitor.ts | 101 ++ apps/server/src/cli/config.test.ts | 9 +- apps/server/src/cli/config.ts | 8 +- apps/server/src/config.ts | 6 + .../diagnostics/ProcessDiagnostics.test.ts | 468 +++--- .../src/diagnostics/ProcessDiagnostics.ts | 639 ++------- .../ProcessResourceMonitor.test.ts | 375 ++--- .../src/diagnostics/ProcessResourceMonitor.ts | 366 +---- .../src/observability/Layers/Observability.ts | 10 + apps/server/src/provider/Drivers/AmpDriver.ts | 6 +- .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../src/provider/Drivers/CodexDriver.ts | 5 +- .../src/provider/Drivers/CopilotDriver.ts | 6 +- .../src/provider/Drivers/CursorDriver.ts | 4 + .../src/provider/Drivers/DroidDriver.ts | 6 +- .../src/provider/Drivers/GeminiCliDriver.ts | 6 +- .../server/src/provider/Drivers/GrokDriver.ts | 5 +- .../server/src/provider/Drivers/KiloDriver.ts | 6 +- .../src/provider/Drivers/OpenCodeDriver.ts | 5 +- .../src/provider/Layers/ClaudeAdapter.ts | 5 +- .../src/provider/Layers/CodexAdapter.test.ts | 2 +- .../src/provider/Layers/CodexAdapter.ts | 2 +- .../provider/Layers/EventNdjsonLogger.test.ts | 376 ++++- .../src/provider/Layers/EventNdjsonLogger.ts | 730 +++++++--- .../provider/Layers/ProviderEventLoggers.ts | 81 +- .../ProviderInstanceRegistryLive.test.ts | 36 + .../provider/Layers/ProviderRegistry.test.ts | 44 + .../makeManagedServerProvider.test.ts | 177 ++- .../src/provider/makeManagedServerProvider.ts | 78 +- .../DesktopTelemetryReceiver.test.ts | 105 ++ .../DesktopTelemetryReceiver.ts | 662 +++++++++ .../src/resourceTelemetry/Model.test.ts | 581 ++++++++ apps/server/src/resourceTelemetry/Model.ts | 608 ++++++++ .../NativeTelemetryClient.test.ts | 241 ++++ .../NativeTelemetryClient.ts | 1024 +++++++++++++ .../resourceTelemetry/ResourceAttribution.ts | 71 + .../ResourceMonitorBinary.test.ts | 124 ++ .../ResourceMonitorBinary.ts | 226 +++ .../ResourceTelemetry.test.ts | 647 +++++++++ .../resourceTelemetry/ResourceTelemetry.ts | 504 +++++++ .../ResourceTelemetryHistory.test.ts | 357 +++++ .../ResourceTelemetryHistory.ts | 290 ++++ apps/server/src/server.test.ts | 88 ++ apps/server/src/server.ts | 52 +- apps/server/src/serverSettings.test.ts | 33 + apps/server/src/serverSettings.ts | 52 +- .../src/utils/subscribeBeforeSnapshot.test.ts | 84 ++ .../src/utils/subscribeBeforeSnapshot.ts | 37 + .../src/vcs/VcsStatusBroadcaster.test.ts | 92 ++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 110 +- apps/server/src/ws.ts | 177 ++- .../settings/DiagnosticsSettings.tsx | 13 +- ...ResourceTelemetryDiagnostics.logic.test.ts | 90 ++ .../ResourceTelemetryDiagnostics.logic.ts | 60 + .../settings/ResourceTelemetryDiagnostics.tsx | 1268 +++++++++++++++++ .../settings/SettingsPanels.logic.test.ts | 109 ++ .../settings/SettingsPanels.logic.ts | 67 + .../components/settings/SettingsPanels.tsx | 602 +++++++- .../settings/SourceControlSettings.tsx | 92 +- apps/web/src/connection/runtime.ts | 22 +- apps/web/src/env.ts | 6 +- .../web/src/environments/primary/httpLayer.ts | 1 - apps/web/src/hooks/useSettings.ts | 1 - .../lib/backgroundActivityReporter.test.ts | 63 + .../web/src/lib/backgroundActivityReporter.ts | 254 ++++ apps/web/src/lib/resourceTelemetryState.ts | 51 + apps/web/src/localApi.test.ts | 19 +- apps/web/src/localApi.ts | 26 +- apps/web/src/vite-env.d.ts | 3 +- docs/architecture/overview.md | 4 + docs/architecture/resource-telemetry.md | 391 +++++ native/resource-monitor/Cargo.lock | 343 +++++ native/resource-monitor/Cargo.toml | 17 + native/resource-monitor/src/main.rs | 1160 +++++++++++++++ package.json | 2 + packages/client-runtime/src/rpc/client.ts | 193 +-- packages/client-runtime/src/state/server.ts | 18 + packages/contracts/src/background.test.ts | 18 + packages/contracts/src/background.ts | 110 ++ packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/desktopBootstrap.ts | 5 +- packages/contracts/src/index.ts | 2 + packages/contracts/src/ipc.ts | 45 +- packages/contracts/src/resourceTelemetry.ts | 430 ++++++ packages/contracts/src/rpc.ts | 70 + packages/contracts/src/server.ts | 2 + packages/contracts/src/settings.ts | 61 + packages/shared/package.json | 4 + .../shared/src/backgroundActivitySettings.ts | 270 ++++ packages/shared/src/logging.test.ts | 19 + packages/shared/src/logging.ts | 4 - packages/shared/src/observability.test.ts | 96 +- packages/shared/src/observability.ts | 70 +- packages/shared/src/serverSettings.test.ts | 217 +++ packages/shared/src/serverSettings.ts | 78 +- scripts/build-desktop-artifact.test.ts | 50 + scripts/build-desktop-artifact.ts | 144 ++ 127 files changed, 18505 insertions(+), 2160 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronPowerMonitor.ts create mode 100644 apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts create mode 100644 apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts create mode 100644 apps/mobile/src/connection/background-activity-scopes.ts create mode 100644 apps/mobile/src/connection/background-activity.test.ts create mode 100644 apps/mobile/src/connection/background-activity.ts create mode 100644 apps/server/src/auth/RpcAuthorization.test.ts create mode 100644 apps/server/src/auth/RpcAuthorization.ts create mode 100644 apps/server/src/background/BackgroundPolicy.test.ts create mode 100644 apps/server/src/background/BackgroundPolicy.ts create mode 100644 apps/server/src/background/HostPowerMonitor.test.ts create mode 100644 apps/server/src/background/HostPowerMonitor.ts create mode 100644 apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts create mode 100644 apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts create mode 100644 apps/server/src/resourceTelemetry/Model.test.ts create mode 100644 apps/server/src/resourceTelemetry/Model.ts create mode 100644 apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts create mode 100644 apps/server/src/resourceTelemetry/NativeTelemetryClient.ts create mode 100644 apps/server/src/resourceTelemetry/ResourceAttribution.ts create mode 100644 apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts create mode 100644 apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts create mode 100644 apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts create mode 100644 apps/server/src/resourceTelemetry/ResourceTelemetry.ts create mode 100644 apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts create mode 100644 apps/server/src/resourceTelemetry/ResourceTelemetryHistory.ts create mode 100644 apps/server/src/utils/subscribeBeforeSnapshot.test.ts create mode 100644 apps/server/src/utils/subscribeBeforeSnapshot.ts create mode 100644 apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts create mode 100644 apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts create mode 100644 apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx create mode 100644 apps/web/src/lib/backgroundActivityReporter.test.ts create mode 100644 apps/web/src/lib/backgroundActivityReporter.ts create mode 100644 apps/web/src/lib/resourceTelemetryState.ts create mode 100644 docs/architecture/resource-telemetry.md create mode 100644 native/resource-monitor/Cargo.lock create mode 100644 native/resource-monitor/Cargo.toml create mode 100644 native/resource-monitor/src/main.rs create mode 100644 packages/contracts/src/background.test.ts create mode 100644 packages/contracts/src/background.ts create mode 100644 packages/contracts/src/resourceTelemetry.ts create mode 100644 packages/shared/src/backgroundActivitySettings.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51ad473019c6..debbf312e897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,11 @@ jobs: cache: true run-install: true + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Cache Electron binary uses: actions/cache@v5 with: @@ -49,6 +54,9 @@ jobs: - name: Typecheck run: vpr typecheck + - name: Check resource monitor formatting + run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check + - name: Build desktop pipeline run: vp run build:desktop @@ -75,6 +83,9 @@ jobs: cache: true run-install: true + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Cache Electron binary uses: actions/cache@v5 with: @@ -154,6 +165,9 @@ jobs: src/components/chat/CompactComposerControlsMenu.browser.tsx \ src/components/settings/SettingsPanels.browser.tsx + - name: Test resource monitor + run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + mobile_native_static_analysis: name: Mobile Native Static Analysis runs-on: macos-26 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 384c59807312..b43a140948ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -357,21 +357,29 @@ jobs: platform: mac target: dmg arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 - label: macOS x64 runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 + rust_target: x86_64-apple-darwin + resource_key: darwin-x64 - label: Linux x64 runner: blacksmith-32vcpu-ubuntu-2404 platform: linux target: AppImage arch: x64 + rust_target: x86_64-unknown-linux-gnu + resource_key: linux-x64 - label: Windows x64 runner: blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 # - label: Windows arm64 # runner: windows-11-arm # platform: win @@ -391,6 +399,11 @@ jobs: cache: true run-install: true + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + - name: Download relay client tracing config continue-on-error: true uses: actions/download-artifact@v8 @@ -634,6 +647,19 @@ jobs: # done # fi + - name: Collect resource monitor + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ matrix.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" + target_dir="resource-monitor-publish/${{ matrix.resource_key }}" + mkdir -p "$target_dir" + cp "$source_path" "$target_dir/$binary_name" + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: @@ -641,6 +667,13 @@ jobs: path: release-publish/* if-no-files-found: error + - name: Upload resource monitor + uses: actions/upload-artifact@v7 + with: + name: resource-monitor-${{ matrix.resource_key }} + path: resource-monitor-publish/${{ matrix.resource_key }}/* + if-no-files-found: error + publish_cli: name: Publish CLI to npm needs: [preflight, relay_public_config, build] @@ -701,6 +734,24 @@ jobs: - name: Build CLI package run: vp run --filter t3 build + - name: Download resource monitors + uses: actions/download-artifact@v8 + with: + pattern: resource-monitor-* + path: ${{ runner.temp }}/resource-monitors + + - name: Bundle resource monitors into CLI package + shell: bash + run: | + set -euo pipefail + for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do + resource_key="${artifact_dir##*/resource-monitor-}" + target_dir="apps/server/dist/resource-monitor/${resource_key}" + mkdir -p "$target_dir" + cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" + chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true + done + - name: Publish CLI package run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose diff --git a/.gitignore b/.gitignore index 19556a3de8c4..414c4f286e11 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ dist-electron/ .showcase/ apps/mobile/.showcase/ artifacts/app-store/screenshots/ +native/**/target/ node_modules/ .alchemy/ *.log diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 50f82ac30261..2eb540f05263 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => }), setAppUserModelId: () => Effect.void, requestSingleInstanceLock: Effect.succeed(true), + getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 107ee21d2328..e5ce72f8e48a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => { setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, requestSingleInstanceLock: Effect.succeed(true), + getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 9438175f6027..cd90e7951b8d 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -49,20 +49,41 @@ const environmentInput = (baseDir: string) => runningUnderArm64Translation: false, }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; -const makeEnvironmentLayer = (baseDir: string) => +const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) => DesktopEnvironment.layer(environmentInput(baseDir)).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + VITE_DEV_SERVER_URL: isDevelopment ? "http://127.0.0.1:5733" : undefined, }), ), ), ); describe("DesktopObservability", () => { + it("advances a retained output offset instead of repeatedly copying a full head chunk", () => { + const maxBufferedBytes = 1024 * 1024; + const initial = DesktopObservability.appendBoundedOutputChunk( + { + runId: "test-run", + startDetails: "pid=123", + chunks: [], + byteLength: 0, + }, + "stderr", + new Uint8Array(maxBufferedBytes), + ); + const initialBackingBuffer = initial.chunks[0]?.chunk.buffer; + + const next = DesktopObservability.appendBoundedOutputChunk(initial, "stderr", Uint8Array.of(1)); + + assert.equal(next.chunks[0]?.chunk.buffer, initialBackingBuffer); + assert.equal(next.chunks[0]?.offset, 1); + assert.equal(next.byteLength, maxBufferedBytes); + }); + it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -112,26 +133,34 @@ describe("DesktopObservability", () => { ), ); - it.effect("persists backend child output as structured JSON records in development", () => + it.effect("buffers backend child output and persists it only when a failure is reported", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-output-log-test-", }); - const environmentLayer = makeEnvironmentLayer(baseDir); + const environmentLayer = makeEnvironmentLayer(baseDir, false); const logPath = yield* Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; return environment.path.join(environment.logDir, "server-child.log"); }).pipe(Effect.provide(environmentLayer)); + const tracePath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop.trace.ndjson"); + }).pipe(Effect.provide(environmentLayer)); yield* Effect.gen(function* () { const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; const outputLog = yield* factory.forInstance("primary"); - yield* outputLog.writeSessionBoundary({ - phase: "START", + yield* outputLog.beginSession({ details: "pid=123 port=3773 cwd=/repo", }); yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); + assert.isFalse(yield* fileSystem.exists(logPath)); + yield* outputLog.persistFailure({ details: "code=1" }); + yield* outputLog.beginSession({ details: "pid=456" }); + yield* outputLog.writeOutputChunk("stderr", new TextEncoder().encode("normal shutdown\n")); + yield* outputLog.discardSession; }).pipe( Effect.annotateLogs({ runId: "test-run" }), Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), @@ -139,16 +168,18 @@ describe("DesktopObservability", () => { const log = yield* fileSystem.readFileString(logPath); const lines = log.trimEnd().split("\n"); - const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); + const start = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); + const end = yield* decodeDesktopBackendChildLogRecord(lines[2] ?? ""); - assert.equal(boundary.message, "backend child process session start"); - assert.equal(boundary.level, "INFO"); - assert.equal(boundary.annotations.component, "desktop-backend-child"); - assert.equal(boundary.annotations.runId, "test-run"); - assert.equal(boundary.annotations.instanceId, "primary"); - assert.equal(boundary.annotations.phase, "START"); - assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); + assert.equal(lines.length, 3); + assert.equal(start.message, "backend child process failure output start"); + assert.equal(start.level, "ERROR"); + assert.equal(start.annotations.component, "desktop-backend-child"); + assert.equal(start.annotations.runId, "test-run"); + assert.equal(start.annotations.instanceId, "primary"); + assert.equal(start.annotations.phase, "START"); + assert.equal(start.annotations.details, "pid=123 port=3773 cwd=/repo"); assert.equal(output.message, "backend child process output"); assert.equal(output.level, "INFO"); @@ -157,6 +188,141 @@ describe("DesktopObservability", () => { assert.equal(output.annotations.instanceId, "primary"); assert.equal(output.annotations.stream, "stdout"); assert.equal(output.annotations.text, "hello server\n"); + + assert.equal(end.message, "backend child process failure output end"); + assert.equal(end.level, "ERROR"); + assert.equal(end.annotations.instanceId, "primary"); + assert.equal(end.annotations.phase, "END"); + assert.equal(end.annotations.details, "code=1"); + + const traceRecords = (yield* fileSystem.readFileString(tracePath)) + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeTraceRecordLine(line)); + assert.isFalse( + traceRecords.some( + (record) => record.name === "desktop.observability.backendOutput.writeOutputChunk", + ), + ); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + + it.effect("keeps buffering output after a non-terminal failure snapshot", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-snapshot-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, false); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.gen(function* () { + const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; + const outputLog = yield* factory.forInstance("primary"); + yield* outputLog.beginSession({ details: "pid=123" }); + yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("before timeout\n")); + yield* outputLog.persistFailureSnapshot({ details: "readiness timeout" }); + yield* outputLog.writeOutputChunk("stderr", new TextEncoder().encode("after timeout\n")); + yield* outputLog.persistFailure({ details: "code=1" }); + }).pipe( + Effect.annotateLogs({ runId: "test-run" }), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ); + + const records = yield* Effect.forEach( + (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"), + (line) => decodeDesktopBackendChildLogRecord(line), + ); + assert.equal( + records.some((record) => record.annotations.text === "after timeout\n"), + true, + ); + assert.equal(records.at(-1)?.annotations.details, "code=1"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + + it.effect("retains only the last mebibyte of backend child output", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-bound-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, false); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + const maxBufferedBytes = 1024 * 1024; + const discardedPrefixBytes = 128; + const output = new Uint8Array(maxBufferedBytes + discardedPrefixBytes); + output.fill("x".charCodeAt(0)); + output.fill("y".charCodeAt(0), 0, discardedPrefixBytes); + + yield* Effect.scoped( + Effect.gen(function* () { + const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; + const outputLog = yield* factory.forInstance("primary"); + yield* outputLog.beginSession({ details: "pid=123" }); + yield* outputLog.writeOutputChunk("stderr", output); + yield* outputLog.persistFailure({ details: "code=1" }); + }).pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"); + const record = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); + const text = record.annotations.text; + assert.equal(typeof text, "string"); + if (typeof text !== "string") { + return; + } + assert.equal(new TextEncoder().encode(text).byteLength, maxBufferedBytes); + assert.isFalse(text.includes("y")); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + + it.effect("bounds the number of retained backend child output chunks", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-chunks-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, false); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.scoped( + Effect.gen(function* () { + const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; + const outputLog = yield* factory.forInstance("primary"); + yield* outputLog.beginSession({ details: "pid=123" }); + for (let index = 0; index < 300; index += 1) { + yield* outputLog.writeOutputChunk("stderr", Uint8Array.of(index % 128)); + } + yield* outputLog.persistFailure({ details: "code=1" }); + }).pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"); + assert.equal(lines.length, 258); }).pipe( Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 868c5aa3725f..c393f4ccd9a9 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -24,7 +24,9 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; -const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; +const DESKTOP_TRACE_BATCH_WINDOW_MS = 1_000; +const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES = 1024 * 1024; +const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS = 256; export interface RotatingLogFileWriter { readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; @@ -32,14 +34,14 @@ export interface RotatingLogFileWriter { } export interface DesktopBackendOutputLogShape { - readonly writeSessionBoundary: (input: { - readonly phase: "START" | "END"; - readonly details: string; - }) => Effect.Effect; + readonly beginSession: (input: { readonly details: string }) => Effect.Effect; readonly writeOutputChunk: ( streamName: "stdout" | "stderr", chunk: Uint8Array, ) => Effect.Effect; + readonly persistFailureSnapshot: (input: { readonly details: string }) => Effect.Effect; + readonly persistFailure: (input: { readonly details: string }) => Effect.Effect; + readonly discardSession: Effect.Effect; } // Factory for per-instance backend output logs. `forInstance(id)` returns @@ -127,10 +129,80 @@ const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( ); const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { - writeSessionBoundary: () => Effect.void, + beginSession: () => Effect.void, writeOutputChunk: () => Effect.void, + persistFailureSnapshot: () => Effect.void, + persistFailure: () => Effect.void, + discardSession: Effect.void, }; +interface BufferedBackendOutputChunk { + readonly streamName: "stdout" | "stderr"; + readonly chunk: Uint8Array; + readonly offset: number; +} + +interface BackendOutputSession { + readonly runId: string; + readonly startDetails: string; + readonly chunks: ReadonlyArray; + readonly byteLength: number; +} + +export function appendBoundedOutputChunk( + session: BackendOutputSession, + streamName: "stdout" | "stderr", + chunk: Uint8Array, +): BackendOutputSession { + if (chunk.byteLength === 0) { + return session; + } + + const retainedChunk = + chunk.byteLength > DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES + ? chunk.slice(chunk.byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES) + : chunk.slice(); + const chunks = [...session.chunks, { streamName, chunk: retainedChunk, offset: 0 }]; + let byteLength = session.byteLength + retainedChunk.byteLength; + let overflow = Math.max(0, byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES); + let firstRetainedIndex = 0; + + while (overflow > 0) { + const first = chunks[firstRetainedIndex]; + if (!first) break; + const retainedByteLength = first.chunk.byteLength - first.offset; + if (retainedByteLength <= overflow) { + overflow -= retainedByteLength; + byteLength -= retainedByteLength; + firstRetainedIndex += 1; + continue; + } + + chunks[firstRetainedIndex] = { + ...first, + offset: first.offset + overflow, + }; + byteLength -= overflow; + overflow = 0; + } + + const excessChunks = Math.max( + 0, + chunks.length - firstRetainedIndex - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS, + ); + for (let index = firstRetainedIndex; index < firstRetainedIndex + excessChunks; index += 1) { + const chunk = chunks[index]; + byteLength -= chunk ? chunk.chunk.byteLength - chunk.offset : 0; + } + firstRetainedIndex += excessChunks; + + return { + ...session, + chunks: chunks.slice(firstRetainedIndex), + byteLength, + }; +} + const currentDesktopRunId = Effect.gen(function* () { const annotations = yield* References.CurrentLogAnnotations; const runId = annotations.runId; @@ -345,47 +417,93 @@ const makeBackendOutputLogShape = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, sink: Option.Option, -): DesktopBackendOutputLogShape => +): Effect.Effect => Option.match(sink, { - onNone: () => DesktopBackendOutputLogNoop, + onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), onSome: (logFile) => - ({ - writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( - function* ({ phase, details }) { - const runId = yield* currentDesktopRunId; + Effect.gen(function* () { + const sessionRef = yield* Ref.make(Option.none()); + const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( + function* (session: BackendOutputSession, details: string) { yield* writeBackendChildLogRecord(logFile, { - message: `backend child process session ${phase.toLowerCase()}`, - level: "INFO", + message: "backend child process failure output start", + level: "ERROR", annotations: { component: "desktop-backend-child", - runId, + runId: session.runId, instanceId: id, - phase, - details: sanitizeLogValue(details), + phase: "START", + details: session.startDetails, }, }); - }, - ), - writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( - function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); + for (const output of session.chunks) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: output.streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + stream: output.streamName, + text: textDecoder.decode(output.chunk.subarray(output.offset)), + }, + }); } - const runId = yield* currentDesktopRunId; yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: streamName === "stderr" ? "ERROR" : "INFO", + message: "backend child process failure output end", + level: "ERROR", annotations: { component: "desktop-backend-child", - runId, + runId: session.runId, instanceId: id, - stream: streamName, - text: textDecoder.decode(chunk), + phase: "END", + details: sanitizeLogValue(details), }, }); }, - ), - }) satisfies DesktopBackendOutputLogShape, + ); + return { + beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ + details, + }) { + const runId = yield* currentDesktopRunId; + yield* Ref.set( + sessionRef, + Option.some({ + runId, + startDetails: sanitizeLogValue(details), + chunks: [], + byteLength: 0, + }), + ); + }), + writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + yield* Ref.update( + sessionRef, + Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), + ); + }), + persistFailureSnapshot: Effect.fn( + "desktop.observability.backendOutput.persistFailureSnapshot", + )(function* ({ details }) { + const session = yield* Ref.get(sessionRef); + if (Option.isSome(session)) { + yield* writeFailure(session.value, details); + } + }), + persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( + function* ({ details }) { + const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); + if (Option.isNone(session)) return; + yield* writeFailure(session.value, details); + }, + ), + discardSession: Ref.set(sessionRef, Option.none()), + } satisfies DesktopBackendOutputLogShape; + }), }); const backendOutputLogFactoryLayer = Layer.effect( @@ -412,10 +530,9 @@ const backendOutputLogFactoryLayer = Layer.effect( const cacheKey = backendLogFilePathForInstance(environment, id); const cached = cache.get(cacheKey); if (cached !== undefined) { - return Effect.succeed([ - makeBackendOutputLogShape(environment, id, cached), - cache, - ] as const); + return makeBackendOutputLogShape(environment, id, cached).pipe( + Effect.map((outputLog) => [outputLog, cache] as const), + ); } return makeBackendOutputSinkForInstance(environment, id).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -424,11 +541,19 @@ const backendOutputLogFactoryLayer = Layer.effect( Effect.map((sink) => { const next = new Map(cache); next.set(cacheKey, sink); - return [ - makeBackendOutputLogShape(environment, id, sink), - next as ReadonlyMap>, - ] as const; + return { sink, next }; }), + Effect.flatMap(({ sink, next }) => + makeBackendOutputLogShape(environment, id, sink).pipe( + Effect.map( + (outputLog) => + [ + outputLog, + next as ReadonlyMap>, + ] as const, + ), + ), + ), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index f322953f6f9b..07f9eb85c7a7 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -52,6 +52,7 @@ function makeEnvironmentLayer( baseDir: string, options?: { readonly appPath?: string; + readonly dirname?: string; readonly isPackaged?: boolean; readonly devServerUrl?: string; readonly platform?: NodeJS.Platform; @@ -59,7 +60,7 @@ function makeEnvironmentLayer( }, ) { return DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", + dirname: options?.dirname ?? "/repo/apps/desktop/src", homeDirectory: baseDir, platform: options?.platform ?? "darwin", processArch: "x64", @@ -496,6 +497,8 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.bootstrap.host, "0.0.0.0"); assert.equal(config.bootstrap.tailscaleServeEnabled, false); assert.equal(config.bootstrap.tailscaleServePort, 443); + assert.notProperty(config.bootstrap, "desktopTelemetryFd"); + assert.notProperty(config.bootstrap, "resourceMonitorPath"); // httpBaseUrl uses the resolved distro IP from the test stub, // not localhost — the renderer reaches the backend directly to // avoid relying on wslhost forwarding. @@ -796,6 +799,97 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const resourcesPath = `${baseDir}/resources`; + const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`; + const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`; + const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`; + yield* fileSystem.makeDirectory( + `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`, + { recursive: true }, + ); + yield* fileSystem.makeDirectory(`${resourcesPath}/resource-monitor`, { + recursive: true, + }); + yield* fileSystem.writeFileString(embeddedMonitorPath, "embedded"); + yield* fileSystem.writeFileString(monitorPath, "binary"); + yield* fileSystem.chmod(monitorPath, 0o755); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolvePrimary; + assert.equal(config.bootstrap.resourceMonitorPath, monitorPath); + assert.equal(config.bootstrap.desktopTelemetryFd, 4); + assert.equal(config.bootstrap.desktopTelemetryControlFd, 5); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: `${resourcesPath}/app.asar`, + dirname, + isPackaged: true, + resourcesPath, + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("prefers the release resource monitor when both development builds exist", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const dirname = path.join(baseDir, "apps/desktop/src"); + const releaseMonitorPath = path.join( + baseDir, + "native/resource-monitor/target/release/t3-resource-monitor", + ); + const debugMonitorPath = path.join( + baseDir, + "native/resource-monitor/target/debug/t3-resource-monitor", + ); + yield* fileSystem.makeDirectory(path.dirname(releaseMonitorPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(debugMonitorPath), { recursive: true }); + yield* fileSystem.writeFileString(releaseMonitorPath, "release"); + yield* fileSystem.writeFileString(debugMonitorPath, "debug"); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolvePrimary; + assert.equal(config.bootstrap.resourceMonitorPath, releaseMonitorPath); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + dirname, + devServerUrl: "http://127.0.0.1:5733", + isPackaged: false, + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => withHarness( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 6fe54327da3f..9a07b26f3aaf 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -154,6 +154,44 @@ const logBackendObservabilitySettingsReadFailure = ( ); }; +function resourceMonitorBinaryName(platform: NodeJS.Platform): string { + return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; +} + +const resolveResourceMonitorPath = Effect.fn( + "desktop.backendConfiguration.resolveResourceMonitorPath", +)(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const binaryName = resourceMonitorBinaryName(environment.platform); + const candidates = environment.isDevelopment + ? [ + environment.path.join( + environment.rootDir, + "native/resource-monitor/target/release", + binaryName, + ), + environment.path.join( + environment.rootDir, + "native/resource-monitor/target/debug", + binaryName, + ), + ] + : environment.isPackaged + ? [environment.path.join(environment.resourcesPath, "resource-monitor", binaryName)] + : environment.resolveResourcePathCandidates( + environment.path.join("resource-monitor", binaryName), + ); + + for (const candidate of candidates) { + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return Option.some(candidate); + } + } + + return Option.none(); +}); + const readPersistedBackendObservabilitySettings = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -343,7 +381,9 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( function* ( - input: SharedBootstrapInput, + input: SharedBootstrapInput & { + readonly resourceMonitorPath: Option.Option; + }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, never, @@ -362,6 +402,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv desktopBootstrapToken: input.bootstrapToken, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, + desktopTelemetryFd: 4, + desktopTelemetryControlFd: 5, + ...Option.match(input.resourceMonitorPath, { + onNone: () => ({}), + onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), + }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -430,6 +476,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // inert. tailscaleServeEnabled: input.tailscaleServeEnabled ?? false, tailscaleServePort: input.tailscaleServePort ?? 443, + // The packaged sidecar is a Windows executable and cannot run inside the + // Linux WSL backend. Keep the field absent instead of passing an unusable + // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable. + // See docs/architecture/resource-telemetry.md. ...buildObservabilityFragment(input.observabilitySettings), }; @@ -649,7 +699,11 @@ export const make = Effect.gen(function* () { const buildWindowsPrimaryConfig = Effect.gen(function* () { const shared = yield* sharedInputs; - return yield* resolvePrimaryStartConfig(shared).pipe( + const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 858c9b0d560d..a32caa1fd370 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -1,14 +1,17 @@ import { DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, + DesktopTelemetryControlMessage, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -21,10 +24,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); +const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); +const encodeDesktopTelemetryControl = Schema.encodeSync( + Schema.fromJsonString(DesktopTelemetryControlMessage), +); const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", @@ -41,6 +49,8 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { desktopBootstrapToken: "token", tailscaleServeEnabled: false, tailscaleServePort: 443, + desktopTelemetryFd: 4, + desktopTelemetryControlFd: 5, }, bootstrapDelivery: "fd3", extendEnv: true, @@ -52,14 +62,16 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { const configWithObservability: DesktopBackendBootstrapValue = { ...baseConfig.bootstrap, tailscaleServeEnabled: true, + desktopTelemetryFd: 4, otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", }; function makeProcess(options?: { - readonly stdout?: Stream.Stream; - readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; + readonly getOutputFd?: ChildProcessSpawner.ChildProcessHandle["getOutputFd"]; }): ChildProcessSpawner.ChildProcessHandle { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(123), @@ -71,7 +83,7 @@ function makeProcess(options?: { kill: options?.kill ?? (() => Effect.void), stdin: Sink.drain, getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, + getOutputFd: options?.getOutputFd ?? (() => Stream.empty), unref: Effect.succeed(Effect.void), }); } @@ -112,7 +124,14 @@ interface MakeInstanceInput { failure: DesktopBackendManager.PreflightFailure, ) => Effect.Effect; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; - readonly configResolve?: Effect.Effect; + readonly configResolve?: Effect.Effect< + DesktopBackendManager.DesktopBackendStartConfig, + PlatformError.PlatformError + >; + readonly desktopTelemetryStream?: Stream.Stream; + readonly desktopTelemetryPublisher?: Partial< + DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] + >; } // Helper that constructs a primary backend instance using the factory @@ -122,8 +141,11 @@ interface MakeInstanceInput { // to drive the instance's lifecycle. function makeTestInstance(input: MakeInstanceInput) { const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { - writeSessionBoundary: () => Effect.void, + beginSession: () => Effect.void, writeOutputChunk: () => Effect.void, + persistFailureSnapshot: () => Effect.void, + persistFailure: () => Effect.void, + discardSession: Effect.void, ...input.backendOutputLog, }; const servicesLayer = Layer.mergeAll( @@ -135,6 +157,16 @@ function makeTestInstance(input: MakeInstanceInput) { Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { forInstance: () => Effect.succeed(stubLog), } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), + Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { + latest: Effect.succeed(Option.none()), + changes: Stream.empty, + encoded: input.desktopTelemetryStream ?? Stream.empty, + handleControl: () => Effect.void, + handleControlForSource: (_sourceId, message) => + (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message), + removeControlSource: () => Effect.void, + ...input.desktopTelemetryPublisher, + }), ); const instance = DesktopBackendManager.makeBackendInstance({ @@ -150,11 +182,12 @@ function makeTestInstance(input: MakeInstanceInput) { } describe("DesktopBackendManager", () => { - it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => + it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => Effect.scoped( Effect.gen(function* () { let spawnedCommand: ChildProcess.Command | undefined; let bootstrapJson = ""; + let telemetryJson = ""; let readyCount = 0; const ready = yield* Deferred.make(); const exited = yield* Queue.unbounded(); @@ -169,6 +202,10 @@ describe("DesktopBackendManager", () => { if (fd3?.type === "input" && fd3.stream) { bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); } + const fd4 = command.options.additionalFds?.fd4; + if (fd4?.type === "input" && fd4.stream) { + telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); + } } return makeProcess({ @@ -184,12 +221,14 @@ describe("DesktopBackendManager", () => { bootstrap: configWithObservability, }, spawnerLayer, + desktopTelemetryStream: Stream.encodeText( + Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), + ), onReady: Effect.sync(() => { readyCount += 1; }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), backendOutputLog: { - writeSessionBoundary: ({ phase }) => - phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), }, }); @@ -210,16 +249,400 @@ describe("DesktopBackendManager", () => { assert.equal(spawnedCommand.options.stderr, "pipe"); assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); + assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); assert.equal( Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), 2_000, ); assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + assert.equal( + telemetryJson, + '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', + ); + }), + ), + ); + + it.effect("preserves the readiness timeout cause and process context", () => + Effect.gen(function* () { + const requested = yield* Deferred.make(); + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer((request) => + Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), + ), + ); + + yield* Effect.gen(function* () { + const readiness = yield* DesktopBackendManager.waitForHttpReady({ + executablePath: baseConfig.executablePath, + entryPath: baseConfig.entryPath, + cwd: baseConfig.cwd, + httpBaseUrl: baseConfig.httpBaseUrl, + timeout: Duration.millis(50), + }).pipe(Effect.flip, Effect.forkChild); + + const request = yield* Deferred.await(requested); + assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); + + yield* TestClock.adjust(Duration.millis(50)); + const error = yield* Fiber.join(readiness); + + assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); + assert.equal(error.timeoutMs, 50); + assert.isDefined(error.cause); + assert.equal( + error.message, + "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", + ); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("reports bootstrap encoding failures with stable process context", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + const error = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + bootstrap: { + ...baseConfig.bootstrap, + port: 0, + }, + }).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessBootstrapEncodeError") { + return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.isDefined(error.cause); + assert.equal( + error.message, + "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", + ); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves spawn failures without deriving their message from the cause", () => + Effect.gen(function* () { + const spawnCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: baseConfig.executablePath, + description: "low-level detail that must not become the public message", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(spawnCause)), + ); + const error = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + }).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessSpawnError") { + return assert.fail(`Expected backend spawn error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, spawnCause); + assert.equal( + error.message, + "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", + ); + assert.notInclude(error.message, spawnCause.message); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves exit-status failures without copying their detail into the message", () => + Effect.gen(function* () { + const exitCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "exitCode", + description: "exit-status-secret-sentinel", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.fail(exitCause), + }), + ), + ), + ); + const error = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + }).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessExitStatusError") { + return assert.fail(`Expected backend exit-status error, received ${error._tag}`); + } + assert.equal(error.pid, 123); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, exitCause); + assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); + assert.notInclude(error.message, "exit-status-secret-sentinel"); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("reports output stream failures with process and stream context", () => + Effect.gen(function* () { + const outputCause = PlatformError.systemError({ + _tag: "BadResource", + module: "ChildProcess", + method: "stdout", + description: "output-stream-secret-sentinel", + }); + const reported = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fail(outputCause), + exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputReadError") { + return assert.fail(`Expected output read error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.strictEqual(error.cause, outputCause); + assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); + assert.notInclude(error.message, "output-stream-secret-sentinel"); + }), + ); + + it.effect("reports output handler failures separately from stream read failures", () => + Effect.gen(function* () { + const chunk = new TextEncoder().encode("backend output"); + const nextChunk = new TextEncoder().encode("still draining"); + const outputCause = new Error("output-handler-secret-sentinel"); + const reported = yield* Deferred.make(); + const drained = yield* Deferred.make(); + let outputCount = 0; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.make(chunk, nextChunk), + exitCode: Deferred.await(drained).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + onOutput: () => { + outputCount += 1; + return outputCount === 1 + ? Effect.fail(outputCause) + : Deferred.succeed(drained, void 0).pipe(Effect.asVoid); + }, + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputHandlingError") { + return assert.fail(`Expected output handling error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.equal(error.chunkByteLength, chunk.byteLength); + assert.strictEqual(error.cause, outputCause); + assert.equal( + error.message, + `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, + ); + assert.notInclude(error.message, "output-handler-secret-sentinel"); + assert.equal(outputCount, 2); + }), + ); + + it.effect("reports child exit before waiting for trailing output to drain", () => + Effect.scoped( + Effect.gen(function* () { + const exitObserved = yield* Deferred.make(); + const finishOutputDrain = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fromEffect( + Deferred.await(finishOutputDrain).pipe( + Effect.as(new TextEncoder().encode("trailing output\n")), + ), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), + ), + ), + ); + + const runFiber = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), + }).pipe( + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + Effect.forkChild, + ); + + yield* Deferred.await(exitObserved); + assert.isUndefined(runFiber.pollUnsafe()); + + yield* Deferred.succeed(finishOutputDrain, void 0); + assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); + }), + ), + ); + + it.effect("continues routing desktop telemetry control messages after an invalid line", () => + Effect.scoped( + Effect.gen(function* () { + const handled = yield* Deferred.make(); + const controlMessage = encodeDesktopTelemetryControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + getOutputFd: (fd) => + fd === 5 + ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) + : Stream.empty, + exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer, + desktopTelemetryPublisher: { + handleControl: (message) => + message.type === "setDiagnosticsDemand" + ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) + : Effect.void, + }, + }); + + yield* instance.start; + assert.isTrue(yield* Deferred.await(handled)); }), ), ); + it.effect("drains trailing child output before persisting an unexpected exit", () => + Effect.scoped( + Effect.gen(function* () { + const persistedOutput = yield* Deferred.make>(); + const outputDrainStarted = yield* Deferred.make(); + const outputChunks = yield* Ref.make>([]); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fromEffect( + Deferred.succeed(outputDrainStarted, void 0).pipe( + Effect.andThen(Effect.sleep(Duration.seconds(1))), + Effect.as(new TextEncoder().encode("trailing output\n")), + ), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), + ), + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + backendOutputLog: { + writeOutputChunk: (_streamName, chunk) => + Ref.update(outputChunks, (current) => [...current, new TextDecoder().decode(chunk)]), + persistFailure: () => + Ref.get(outputChunks).pipe( + Effect.flatMap((chunks) => Deferred.succeed(persistedOutput, chunks)), + Effect.asVoid, + ), + }, + }); + + yield* instance.start; + yield* Deferred.await(outputDrainStarted); + yield* TestClock.adjust(Duration.seconds(1)); + + assert.deepEqual(yield* Deferred.await(persistedOutput), ["trailing output\n"]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + it.effect("retries HTTP readiness before reporting the backend ready", () => Effect.scoped( Effect.gen(function* () { @@ -256,8 +679,7 @@ describe("DesktopBackendManager", () => { readyCount += 1; }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), backendOutputLog: { - writeSessionBoundary: ({ phase }) => - phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), }, }); @@ -285,9 +707,15 @@ describe("DesktopBackendManager", () => { let startCount = 0; let closedCount = 0; const closed = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); const startedPids = yield* Queue.unbounded(); const ready = yield* Deferred.make(); const backendReadyFlag = yield* Ref.make(false); + let shutdownCount = 0; + let persistedFailureCount = 0; + let discardedSessionCount = 0; + let removedTelemetrySources = 0; const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -296,9 +724,16 @@ describe("DesktopBackendManager", () => { const scope = yield* Scope.Scope; startCount += 1; yield* Queue.offer(startedPids, 123); - const close = Effect.sync(() => { - closedCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); + const close = Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen( + Effect.sync(() => { + closedCount += 1; + }), + ), + Effect.andThen(Deferred.succeed(closed, void 0)), + Effect.asVoid, + ); yield* Scope.addFinalizer(scope, close); @@ -316,7 +751,28 @@ describe("DesktopBackendManager", () => { Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid, ), - onShutdown: Ref.set(backendReadyFlag, false), + onShutdown: Ref.set(backendReadyFlag, false).pipe( + Effect.andThen( + Effect.sync(() => { + shutdownCount += 1; + }), + ), + ), + backendOutputLog: { + persistFailure: () => + Effect.sync(() => { + persistedFailureCount += 1; + }), + discardSession: Effect.sync(() => { + discardedSessionCount += 1; + }), + }, + desktopTelemetryPublisher: { + removeControlSource: () => + Effect.sync(() => { + removedTelemetrySources += 1; + }), + }, }); assert.isTrue(Option.isNone(yield* instance.currentConfig)); @@ -330,12 +786,21 @@ describe("DesktopBackendManager", () => { assert.equal(runningSnapshot.ready, true); assert.deepEqual(runningSnapshot.activePid, Option.some(123)); - yield* instance.stop(); + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); assert.equal(startCount, 1); assert.equal(closedCount, 1); + assert.equal(persistedFailureCount, 0); + assert.equal(discardedSessionCount, 1); + assert.equal(removedTelemetrySources, 1); const stoppedSnapshot = yield* instance.snapshot; assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); assert.equal(stoppedSnapshot.desiredRunning, false); assert.equal(stoppedSnapshot.ready, false); assert.equal(Option.isNone(stoppedSnapshot.activePid), true); @@ -343,6 +808,213 @@ describe("DesktopBackendManager", () => { ), ); + it.effect("restarts when start is requested during stop teardown", () => + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.asVoid, + ), + ); + } else { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + ); + } + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); + + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + yield* instance.start; + assert.equal((yield* instance.snapshot).desiredRunning, true); + + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); + const restarted = yield* instance.snapshot; + assert.equal(restarted.desiredRunning, true); + assert.deepEqual(restarted.activePid, Option.some(123)); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("retries config resolution after a start request during stop teardown", () => + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + const configAttempts = yield* Ref.make(0); + let startCount = 0; + + const configFailure = PlatformError.systemError({ + _tag: "Unknown", + module: "DesktopBackendManager", + method: "configResolve", + description: "transient configuration failure", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.asVoid, + ), + ); + } else { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + ); + } + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + const configResolve = Ref.updateAndGet(configAttempts, (attempt) => attempt + 1).pipe( + Effect.flatMap((attempt) => + attempt === 2 ? Effect.fail(configFailure) : Effect.succeed(baseConfig), + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer, + configResolve, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); + + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + yield* instance.start; + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + + const pendingRestart = yield* instance.snapshot; + assert.equal(pendingRestart.desiredRunning, true); + assert.equal(pendingRestart.restartScheduled, true); + + yield* TestClock.adjust(Duration.seconds(2)); + + assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); + assert.equal(yield* Ref.get(configAttempts), 3); + assert.equal((yield* instance.snapshot).desiredRunning, true); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("keeps a timed-out run active until its process exits", () => + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.asVoid, + ), + ); + } else { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + ); + } + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); + + const stopFiber = yield* instance + .stop({ timeout: Duration.millis(100) }) + .pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + yield* instance.start; + yield* TestClock.adjust(Duration.millis(100)); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + + assert.equal(startCount, 1); + const timedOut = yield* instance.snapshot; + assert.equal(timedOut.desiredRunning, true); + assert.deepEqual(timedOut.activePid, Option.some(123)); + + yield* Deferred.succeed(finishTeardown, undefined); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + it.effect("does not notify shutdown before the first start has prior state", () => Effect.scoped( Effect.gen(function* () { @@ -383,6 +1055,7 @@ describe("DesktopBackendManager", () => { Effect.scoped( Effect.gen(function* () { const starts = yield* Queue.unbounded(); + const failures = yield* Queue.unbounded(); let startCount = 0; const spawnerLayer = Layer.succeed( @@ -402,11 +1075,15 @@ describe("DesktopBackendManager", () => { const instance = yield* makeTestInstance({ spawnerLayer, httpClientLayer: httpClientLayer(() => Effect.never), + backendOutputLog: { + persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid), + }, }); yield* instance.start; assert.equal(yield* Queue.take(starts), 1); + assert.equal(yield* Queue.take(failures), "pid=123 code=1"); yield* TestClock.adjust(Duration.millis(499)); assert.equal(yield* Queue.size(starts), 0); @@ -657,6 +1334,13 @@ describe("DesktopBackendManager", () => { yield* instance.start; assert.equal(yield* Queue.take(starts), 1); + let restartScheduled = false; + while (!restartScheduled) { + restartScheduled = (yield* instance.snapshot).restartScheduled; + if (!restartScheduled) { + yield* Effect.yieldNow; + } + } yield* instance.start; assert.equal(yield* Queue.take(starts), 2); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index e3a4de661ac0..b50c7a55ed79 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -33,7 +33,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -46,10 +45,13 @@ import { DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, PRIMARY_LOCAL_ENVIRONMENT_ID, + DesktopTelemetryControlMessage, + type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue, } from "@t3tools/contracts"; import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -62,7 +64,10 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); +const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; +const { logWarning: logBackendProcessWarning } = + DesktopObservability.makeComponentLogger("desktop-backend-process"); type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; @@ -70,13 +75,17 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; -export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; - -export interface DesktopBackendStartConfig { +export interface BackendProcessContext { readonly executablePath: string; - readonly args: ReadonlyArray; readonly entryPath: string; readonly cwd: string; + readonly httpBaseUrl: URL; +} + +export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; + +export interface DesktopBackendStartConfig extends BackendProcessContext { + readonly args: ReadonlyArray; readonly env: Record; // When true the spawner merges the desktop process.env on top of `env`; // when false `env` is passed verbatim. WSL mode opts out so a leaking @@ -106,55 +115,122 @@ export interface PreflightFailure { interface BackendProcessExit { readonly code: Option.Option; readonly reason: string; - readonly result: Result.Result; } -export class BackendTimeoutError extends Schema.TaggedErrorClass()( - "BackendTimeoutError", +const backendProcessContextSchema = { + executablePath: Schema.String, + entryPath: Schema.String, + cwd: Schema.String, + httpBaseUrl: Schema.URL, +}; + +export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass()( + "BackendReadinessTimeoutError", { - url: Schema.instanceOf(URL), + ...backendProcessContextSchema, + readinessUrl: Schema.URL, + timeoutMs: Schema.Number, + cause: Schema.Defect(), }, ) { - override get message() { - return `Timed out waiting for backend readiness at ${this.url.href}.`; + override get message(): string { + return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; } } -class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( +export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( "BackendProcessBootstrapEncodeError", { - entryPath: Schema.String, + ...backendProcessContextSchema, cause: Schema.Defect(), }, ) { - override get message() { + override get message(): string { return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; } } -class BackendProcessSpawnError extends Schema.TaggedErrorClass()( +export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( "BackendProcessSpawnError", { - executablePath: Schema.String, + ...backendProcessContextSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; + } +} + +export class BackendProcessOutputReadError extends Schema.TaggedErrorClass()( + "BackendProcessOutputReadError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`; + } +} + +export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass()( + "BackendProcessOutputHandlingError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + chunkByteLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`; + } +} + +export type BackendProcessOutputError = + | BackendProcessOutputReadError + | BackendProcessOutputHandlingError; + +export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( + "BackendProcessExitStatusError", + { + ...backendProcessContextSchema, + pid: Schema.Number, cause: Schema.Defect(), }, ) { - override get message() { - return `Failed to spawn the desktop backend process at ${this.executablePath}.`; + override get message(): string { + return `Failed to read the exit status of desktop backend process ${this.pid}.`; } } -type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; +export const BackendProcessError = Schema.Union([ + BackendProcessBootstrapEncodeError, + BackendProcessSpawnError, + BackendProcessExitStatusError, +]); +export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { + readonly desktopTelemetryStream: Stream.Stream; + readonly onDesktopTelemetryControl?: ( + message: DesktopTelemetryControlMessageValue, + ) => Effect.Effect; readonly readinessTimeout?: Duration.Duration; + readonly outputDrainTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; + readonly onExitObserved?: () => Effect.Effect; readonly onReady?: () => Effect.Effect; - readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, - ) => Effect.Effect; + ) => Effect.Effect; + readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect; } export interface DesktopBackendSnapshot { @@ -225,6 +301,8 @@ interface ActiveBackendRun { readonly scope: Scope.Closeable; readonly fiber: Option.Option>; readonly pid: Option.Option; + readonly exitObserved: boolean; + readonly stopRequested: boolean; } interface BackendManagerState { @@ -266,76 +344,130 @@ const calculateRestartDelay = (attempt: number): Duration.Duration => const closeRun = ( run: ActiveBackendRun, + parentScope: Scope.Scope, options?: { readonly timeout?: Duration.Duration }, -): Effect.Effect => { +): Effect.Effect => { const waitForFiber = Option.match(run.fiber, { onNone: () => Effect.void, onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), }); const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); + const timeout = options?.timeout; - return ( - options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close - ).pipe(Effect.ignore); + if (!timeout) { + return close.pipe(Effect.as(true)); + } + + return Effect.forkIn(close, parentScope).pipe( + Effect.flatMap((closeFiber) => + Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)), + ), + ); }; -const waitForHttpReady = ( - baseUrl: URL, - timeout: Duration.Duration, -): Effect.Effect => { - const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); +export const waitForHttpReady = ( + options: BackendProcessContext & { readonly timeout: Duration.Duration }, +): Effect.Effect => { + const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); return waitForHttpReadyShared({ - baseUrl: baseUrl.href, + baseUrl: options.httpBaseUrl.href, path: BACKEND_READINESS_PATH, - timeoutMs: Duration.toMillis(timeout), + timeoutMs: Duration.toMillis(options.timeout), intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL), probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT), - makeError: () => new BackendTimeoutError({ url: readinessUrl }), + makeError: ({ cause }) => + new BackendReadinessTimeoutError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + readinessUrl, + timeoutMs: Duration.toMillis(options.timeout), + cause, + }), }); }; -function describeProcessExit( - result: Result.Result, -): BackendProcessExit { - if (Result.isSuccess(result)) { - return { - code: Option.some(result.success), - reason: `code=${result.success}`, - result, - }; - } - - return { - code: Option.none(), - reason: result.failure.message, - result, - }; -} - function drainBackendOutput( + context: BackendProcessContext & { readonly pid: number }, streamName: BackendProcessOutputStream, stream: Stream.Stream, - onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, + onOutput: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect, + onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect, ): Effect.Effect { return stream.pipe( - Stream.runForEach((chunk) => onOutput(streamName, chunk)), - Effect.ignore, + Stream.mapError( + (cause) => + new BackendProcessOutputReadError({ + ...context, + streamName, + cause, + }), + ), + Stream.runForEach((chunk) => + onOutput(streamName, chunk).pipe( + Effect.mapError( + (cause) => + new BackendProcessOutputHandlingError({ + ...context, + streamName, + chunkByteLength: chunk.byteLength, + cause, + }), + ), + Effect.catchTag("BackendProcessOutputHandlingError", onOutputFailure), + ), + ), + Effect.catchTags({ + BackendProcessOutputReadError: onOutputFailure, + }), ); } const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); +const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect( + Schema.fromJsonString(DesktopTelemetryControlMessage), +); -const runBackendProcess = Effect.fn("runBackendProcess")(function* ( +export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( Effect.mapError( - (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }), + (cause) => + new BackendProcessBootstrapEncodeError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + cause, + }), ), ); const onOutput = options.onOutput ?? (() => Effect.void); const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); + const additionalFds: Record<`fd${number}`, ChildProcess.AdditionalFdConfig> = {}; + if (options.bootstrapDelivery === "fd3") { + additionalFds.fd3 = { + type: "input", + stream: bootstrapStream, + }; + if (options.bootstrap.desktopTelemetryFd !== undefined) { + additionalFds[`fd${options.bootstrap.desktopTelemetryFd}`] = { + type: "input", + stream: options.desktopTelemetryStream, + }; + } + if (options.bootstrap.desktopTelemetryControlFd !== undefined) { + additionalFds[`fd${options.bootstrap.desktopTelemetryControlFd}`] = { + type: "output", + }; + } + } const command = ChildProcess.make(options.executablePath, options.args, { cwd: options.cwd, env: options.env, @@ -350,34 +482,131 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( // wsl.exe drops additional file descriptors when forwarding to the Linux // side, so the WSL spawn path delivers the bootstrap envelope via stdin // (`--bootstrap-fd 0`) instead. - ...(options.bootstrapDelivery === "fd3" - ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } - : {}), + ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}), }); - const handle = yield* spawner - .spawn(command) - .pipe( - Effect.mapError( - (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }), - ), - ); + const handle = yield* spawner.spawn(command).pipe( + Effect.mapError( + (cause) => + new BackendProcessSpawnError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + cause, + }), + ), + ); + const outputFibers: Array> = []; yield* options.onStarted?.(handle.pid) ?? Effect.void; + if ( + options.bootstrap.desktopTelemetryControlFd !== undefined && + options.onDesktopTelemetryControl !== undefined + ) { + const controlFd = options.bootstrap.desktopTelemetryControlFd; + const handleControl = options.onDesktopTelemetryControl; + yield* handle.getOutputFd(controlFd).pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filter((line) => line.trim().length > 0), + Stream.runForEach((line) => + decodeDesktopTelemetryControlLine(line).pipe( + Effect.flatMap(handleControl), + Effect.catchCause((cause) => + logBackendProcessWarning("ignored invalid desktop telemetry control message", { + fd: controlFd, + cause: Cause.pretty(cause), + }), + ), + ), + ), + Effect.catchCause((cause) => + logBackendProcessWarning("desktop telemetry control stream stopped", { + fd: controlFd, + cause: Cause.pretty(cause), + }), + ), + Effect.ensuring( + handleControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: false, + }), + ), + Effect.forkScoped, + ); + } if (options.captureOutput) { - yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); - yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + const outputContext = { + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + }; + const onOutputFailure = options.onOutputFailure ?? (() => Effect.void); + outputFibers.push( + yield* drainBackendOutput( + outputContext, + "stdout", + handle.stdout, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped), + yield* drainBackendOutput( + outputContext, + "stderr", + handle.stderr, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped), + ); } - yield* waitForHttpReady( - options.httpBaseUrl, - options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - ).pipe( + yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, + }), Effect.forkScoped, ); - return describeProcessExit(yield* Effect.result(handle.exitCode)); + const exit = yield* handle.exitCode.pipe( + Effect.mapError( + (cause) => + new BackendProcessExitStatusError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + cause, + }), + ), + Effect.exit, + ); + yield* options.onExitObserved?.() ?? Effect.void; + yield* Effect.forEach(outputFibers, Fiber.await, { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT), + Effect.ignore, + ); + if (Exit.isFailure(exit)) { + return yield* Effect.failCause(exit.cause); + } + const exitCode = exit.value; + return { + code: Option.some(exitCode), + reason: `code=${exitCode}`, + } satisfies BackendProcessExit; }); // Factory for one pooled backend instance. The returned instance owns @@ -394,12 +623,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient | DesktopObservability.DesktopBackendOutputLogFactory + | DesktopTelemetryPublisher.DesktopTelemetryPublisher | Scope.Scope > { const parentScope = yield* Scope.Scope; const fileSystem = yield* FileSystem.FileSystem; const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); + const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); @@ -444,6 +675,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.gen(function* () { const current = yield* Ref.get(state); if (Option.isSome(current.active)) { + if (!current.desiredRunning) { + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: true, + })); + } return; } @@ -462,6 +699,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.option, ); if (Option.isNone(config)) { + if (current.desiredRunning) { + yield* scheduleRestart("failed to generate desktop backend configuration"); + } return; } const entryExists = yield* fileSystem @@ -561,6 +801,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( scope: runScope, fiber: Option.none(), pid: Option.none(), + exitObserved: false, + stopRequested: false, } satisfies ActiveBackendRun), nextRunId: latest.nextRunId + 1, }, @@ -571,54 +813,70 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ) { yield* mutex.withPermits(1)( Effect.gen(function* () { - const { isCurrentRun, nextState, pid } = yield* Ref.modify( - state, - ( - latest, - ): readonly [ - { - readonly isCurrentRun: boolean; - readonly nextState: BackendManagerState; - readonly pid: Option.Option; - }, - BackendManagerState, - ] => { - const currentRun = Option.getOrUndefined(latest.active); - if (currentRun?.id !== runId) { + const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = + yield* Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly isCurrentRun: boolean; + readonly nextState: BackendManagerState; + readonly pid: Option.Option; + readonly exitObserved: boolean; + readonly stopRequested: boolean; + readonly wasReady: boolean; + }, + BackendManagerState, + ] => { + const currentRun = Option.getOrUndefined(latest.active); + if (currentRun?.id !== runId) { + return [ + { + isCurrentRun: false, + nextState: latest, + pid: Option.none(), + exitObserved: false, + stopRequested: false, + wasReady: false, + }, + latest, + ] as const; + } + + const next = { + ...latest, + active: Option.none(), + ready: false, + }; return [ { - isCurrentRun: false, - nextState: latest, - pid: Option.none(), + isCurrentRun: true, + nextState: next, + pid: currentRun.pid, + exitObserved: currentRun.exitObserved, + stopRequested: currentRun.stopRequested, + wasReady: latest.ready, }, - latest, + next, ] as const; - } - - const next = { - ...latest, - active: Option.none(), - ready: false, - }; - return [ - { - isCurrentRun: true, - nextState: next, - pid: currentRun.pid, - }, - next, - ] as const; - }, - ); + }, + ); if (isCurrentRun) { + yield* desktopTelemetryPublisher.removeControlSource(spec.id); if (Option.isSome(pid)) { - yield* backendOutputLog.writeSessionBoundary({ - phase: "END", - details: `pid=${pid.value} ${reason}`, - }); + if (exitObserved && !stopRequested) { + yield* backendOutputLog.persistFailure({ + details: `pid=${pid.value} ${reason}`, + }); + } else { + yield* backendOutputLog.discardSession; + } + } + if (wasReady) { + yield* spec.onShutdown?.() ?? Effect.void; } - yield* spec.onShutdown?.() ?? Effect.void; } if (isCurrentRun && nextState.desiredRunning) { @@ -630,16 +888,23 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const program = runBackendProcess({ ...config.value, + desktopTelemetryStream: desktopTelemetryPublisher.encoded, + onDesktopTelemetryControl: (message) => + desktopTelemetryPublisher.handleControlForSource(spec.id, message), onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { yield* updateActiveRun(runId, (run) => ({ ...run, pid: Option.some(pid), })); - yield* backendOutputLog.writeSessionBoundary({ - phase: "START", + yield* backendOutputLog.beginSession({ details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, }); }), + onExitObserved: () => + updateActiveRun(runId, (run) => ({ + ...run, + exitObserved: true, + })), onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { const isCurrentRun = yield* Ref.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); @@ -662,10 +927,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; }), - onReadinessFailure: (error) => - logInstanceWarning("backend readiness check failed during bootstrap", { - error: error.message, - }), + onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( + function* (error) { + yield* logInstanceWarning("backend readiness check failed during bootstrap", { + error: error.message, + }); + yield* backendOutputLog.persistFailureSnapshot({ + details: error.message, + }); + }, + ), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -750,41 +1021,92 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { readonly timeout?: Duration.Duration; }) { - const { active, restartFiber } = yield* mutex.withPermits(1)( + const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)( Effect.gen(function* () { - const result = yield* Ref.modify(state, (latest) => [ - { - active: latest.active, - restartFiber: latest.restartFiber, - }, - { - ...latest, - desiredRunning: false, - ready: false, - active: Option.none(), - restartFiber: Option.none>(), - }, - ]); - // Ignore failures from spec.onShutdown so a downstream throw - // can't abort the rest of stop(). Ref.modify above already - // flipped state to "no active run / no restart fiber", and the - // physical cleanup (Fiber.interrupt + closeRun) runs after the - // mutex releases. If onShutdown were allowed to propagate, both - // would be skipped and the child process + restart fiber would - // be orphaned while state claimed nothing was running — the - // next start() would then spawn a second backend on top. - yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); + const result = yield* Ref.modify(state, (latest) => { + const active = Option.map(latest.active, (run) => + run.exitObserved ? run : { ...run, stopRequested: true }, + ); + return [ + { + active, + restartFiber: latest.restartFiber, + notifyShutdown: latest.ready, + }, + { + ...latest, + desiredRunning: false, + ready: false, + active, + restartFiber: Option.none>(), + }, + ] as const; + }); return result; }), ); + if (notifyShutdown) { + yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); + } yield* Option.match(restartFiber, { onNone: () => Effect.void, onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), }); yield* Option.match(active, { onNone: () => Effect.void, - onSome: (run) => closeRun(run, options), + onSome: (run) => + Effect.gen(function* () { + const closed = yield* closeRun(run, parentScope, options); + if (!closed) { + return; + } + const cleanup = yield* mutex.withPermits(1)( + Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly needsCleanup: boolean; + readonly shouldStart: boolean; + }, + BackendManagerState, + ] => { + const current = Option.getOrUndefined(latest.active); + if (current?.id !== run.id) { + return [ + { + needsCleanup: false, + shouldStart: + latest.desiredRunning && + Option.isNone(latest.active) && + Option.isNone(latest.restartFiber), + }, + latest, + ]; + } + return [ + { + needsCleanup: true, + shouldStart: latest.desiredRunning, + }, + { + ...latest, + active: Option.none(), + }, + ]; + }, + ), + ); + if (cleanup.needsCleanup) { + yield* desktopTelemetryPublisher.removeControlSource(spec.id); + yield* backendOutputLog.discardSession; + } + if (cleanup.shouldStart) { + yield* start; + } + }), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7b..523e8764697b 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -5,11 +5,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; @@ -56,10 +58,21 @@ function makePoolLayer( Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { forInstance: () => Effect.succeed({ - writeSessionBoundary: () => Effect.void, + beginSession: () => Effect.void, writeOutputChunk: () => Effect.void, + persistFailureSnapshot: () => Effect.void, + persistFailure: () => Effect.void, + discardSession: Effect.void, } satisfies DesktopObservability.DesktopBackendOutputLogShape), } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), + Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { + latest: Effect.succeed(Option.none()), + changes: Stream.empty, + encoded: Stream.empty, + handleControl: () => Effect.void, + handleControlForSource: () => Effect.void, + removeControlSource: () => Effect.void, + }), Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { resolvePrimary: Effect.die("unexpected primary config resolve"), resolvePrimaryLabel: Ref.get(labelRef), diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 258f8731fa1e..9b85d1bb2430 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -97,6 +97,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -176,7 +177,8 @@ export type BackendInstanceFactoryRequirements = | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient - | DesktopObservability.DesktopBackendOutputLogFactory; + | DesktopObservability.DesktopBackendOutputLogFactory + | DesktopTelemetryPublisher.DesktopTelemetryPublisher; interface ActiveRegisteredInstance { readonly _tag: "Active"; diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 933f40e17058..5f8052f902dc 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -57,6 +57,7 @@ export class ElectronApp extends Context.Service< ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; + readonly getAppMetrics: Effect.Effect>; readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; readonly setAsDefaultProtocolClient: ( protocol: string, @@ -153,6 +154,7 @@ export const make = ElectronApp.of({ Electron.app.setAppUserModelId(id); }), requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), + getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), isDefaultProtocolClient: (protocol) => Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), setAsDefaultProtocolClient: (protocol, path, args) => diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts new file mode 100644 index 000000000000..ad322a573fba --- /dev/null +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -0,0 +1,89 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +export type ElectronThermalState = ReturnType; +export type ElectronIdleState = ReturnType; + +export class ElectronPowerMonitor extends Context.Service< + ElectronPowerMonitor, + { + readonly isOnBatteryPower: Effect.Effect; + readonly getSystemIdleTime: Effect.Effect; + readonly getSystemIdleState: (idleThresholdSeconds: number) => Effect.Effect; + readonly getCurrentThermalState: Effect.Effect; + readonly onSimpleEvent: ( + eventName: "lock-screen" | "unlock-screen" | "on-ac" | "on-battery" | "suspend" | "resume", + listener: () => void, + ) => Effect.Effect; + readonly onThermalStateChange: ( + listener: (state: ElectronThermalState) => void, + ) => Effect.Effect; + readonly onSpeedLimitChange: ( + listener: (limit: number) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronPowerMonitor") {} + +const onSimpleEvent: ElectronPowerMonitor["Service"]["onSimpleEvent"] = (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + Electron.powerMonitor.on(eventName as any, listener as any); + }), + () => + Effect.sync(() => { + Electron.powerMonitor.removeListener(eventName as any, listener as any); + }), + ).pipe(Effect.asVoid); + +const onThermalStateChange: ElectronPowerMonitor["Service"]["onThermalStateChange"] = ( + listener, +) => { + const wrapped = ( + event: Electron.Event, + ): void => { + listener(event.state); + }; + return Effect.acquireRelease( + Effect.sync(() => { + Electron.powerMonitor.on("thermal-state-change", wrapped); + }), + () => + Effect.sync(() => { + Electron.powerMonitor.removeListener("thermal-state-change", wrapped); + }), + ).pipe(Effect.asVoid); +}; + +const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] = (listener) => { + const wrapped = ( + event: Electron.Event, + ): void => { + listener(event.limit); + }; + return Effect.acquireRelease( + Effect.sync(() => { + Electron.powerMonitor.on("speed-limit-change", wrapped); + }), + () => + Effect.sync(() => { + Electron.powerMonitor.removeListener("speed-limit-change", wrapped); + }), + ).pipe(Effect.asVoid); +}; + +export const make = ElectronPowerMonitor.of({ + isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), + getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), + getSystemIdleState: (idleThresholdSeconds) => + Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), + getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), + onSimpleEvent, + onThermalStateChange, + onSpeedLimitChange, +}); + +export const layer = Layer.succeed(ElectronPowerMonitor, make); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae6..ad370e36fdb5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -24,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; @@ -52,6 +53,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; +import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -113,6 +115,7 @@ const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, ElectronMenu.layer, + ElectronPowerMonitor.layer, ElectronProtocol.layer, ElectronSafeStorage.layer, ElectronShell.layer, @@ -160,6 +163,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopWslEnvironment.layer), + Layer.provideMerge(DesktopTelemetryPublisher.layer), Layer.provideMerge(desktopWindowLayer), ); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index fa962f58ce4e..684d6655da5b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -982,6 +982,92 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("emits debugger screencast frames only while recording is active", () => + withManager((manager) => + Effect.gen(function* () { + let debuggerMessage: + | ((event: unknown, method: string, params: Record) => void) + | undefined; + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("scheduled-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const sendCommand = vi.fn(async (method: string) => + method === "Runtime.evaluate" ? { result: { value: null } } : undefined, + ); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn( + ( + event: string, + listener: (event: unknown, method: string, params: Record) => void, + ) => { + if (event === "message") debuggerMessage = listener; + }, + ), + off: vi.fn(), + }, + capturePage, + } as never); + const recordingFrames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + recordingFrames.push(frame); + }), + ); + yield* manager.createTab("tab_screencast_guard"); + yield* manager.registerWebview("tab_screencast_guard", 42); + yield* manager.automationEvaluate("tab_screencast_guard", { expression: "null" }); + + debuggerMessage?.({}, "Page.screencastFrame", { + sessionId: 1, + data: "inactive-frame", + metadata: { deviceWidth: 1280, deviceHeight: 720 }, + }); + yield* Effect.yieldNow; + expect(recordingFrames).toHaveLength(0); + + yield* manager.startRecording("tab_screencast_guard"); + recordingFrames.length = 0; + debuggerMessage?.({}, "Page.screencastFrame", { + sessionId: 2, + data: "active-frame", + metadata: { deviceWidth: 1280, deviceHeight: 720 }, + }); + yield* Effect.yieldNow; + + expect(recordingFrames).toEqual([ + expect.objectContaining({ + tabId: "tab_screencast_guard", + data: "active-frame", + width: 1280, + height: 720, + }), + ]); + yield* manager.stopRecording("tab_screencast_guard"); + }), + ), + ); + effectIt.effect("shares background frame capture between recording and picture-in-picture", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4321cf9dcfb3..d7e8376cec74 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -671,10 +671,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); + const tabIdForWebContents = Effect.fnUntraced(function* (webContentsId: number) { + const tabs = yield* SynchronizedRef.get(tabsRef); + return ( + Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null + ); + }); + const pushBounded = (buffer: ReadonlyArray, entry: A): ReadonlyArray => [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); - const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* ( + const captureDiagnosticMessage = Effect.fnUntraced(function* ( webContentsId: number, method: string, params: Record, @@ -852,11 +859,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { const semaphore = yield* Semaphore.make(1); const scope = yield* Scope.fork(parentScope, "sequential"); - const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( - function* (method: string, params: Record) { - yield* captureDiagnosticMessage(wc.id, method, params); - }, - ); + const handleDebuggerMessage = Effect.fnUntraced(function* ( + method: string, + params: Record, + ) { + if (method === "Page.screencastFrame") { + const sessionId = params["sessionId"]; + if (typeof sessionId === "number") { + yield* attemptPromise( + { + operation: "ackScreencastFrame", + webContentsId: wc.id, + }, + () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + ).pipe(Effect.ignore); + } + const tabId = yield* tabIdForWebContents(wc.id); + const metadata = + typeof params["metadata"] === "object" && params["metadata"] !== null + ? (params["metadata"] as Record) + : {}; + if (tabId && typeof params["data"] === "string") { + const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get( + tabId, + ); + if (captureSession?.consumers.has("recording")) { + const receivedAt = yield* currentIso; + const listeners = yield* Ref.get(recordingFrameListenersRef); + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: params["data"], + width: + typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: + typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt, + }; + yield* Effect.forEach( + listeners, + (listener) => + deliverEvent("recording-frame", frame.tabId, () => listener(frame)), + { discard: true }, + ); + } + } + } + yield* captureDiagnosticMessage(wc.id, method, params); + }); const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { runFork(handleDebuggerMessage(method, params)); }; diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts new file mode 100644 index 000000000000..475be3da1519 --- /dev/null +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -0,0 +1,388 @@ +import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; +import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; + +function makeElectronAppLayer( + metrics: ReadonlyArray, + onMetricsRead: () => void = () => undefined, +) { + return Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + getAppMetrics: Effect.sync(() => { + onMetricsRead(); + return metrics; + }), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, + on: () => Effect.void, + } satisfies ElectronApp.ElectronApp["Service"]); +} + +describe("DesktopTelemetryPublisher", () => { + it.effect("stops when its scope closes during an Electron telemetry sample", () => + Effect.gen(function* () { + const pollStarted = yield* Deferred.make(); + const blockPoll = yield* Deferred.make(); + const powerLayer = Layer.succeed( + ElectronPowerMonitor.ElectronPowerMonitor, + ElectronPowerMonitor.ElectronPowerMonitor.of({ + isOnBatteryPower: Effect.succeed(false), + getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe( + Effect.andThen(Deferred.await(blockPoll)), + Effect.as(0), + ), + getSystemIdleState: () => Effect.succeed("active"), + getCurrentThermalState: Effect.succeed("nominal"), + onSimpleEvent: () => Effect.void, + onThermalStateChange: () => Effect.void, + onSpeedLimitChange: () => Effect.void, + }), + ); + const layer = DesktopTelemetryPublisher.layer.pipe( + Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), + ); + const scope = yield* Scope.make(); + + yield* Layer.buildWithScope(layer, scope); + yield* Deferred.await(pollStarted); + + const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + assert.isDefined(closeFiber.pollUnsafe()); + }), + ); + + it.effect("publishes Electron metrics and event-driven power state over NDJSON", () => + Effect.gen(function* () { + const onBattery = yield* Ref.make(false); + const systemIdleState = yield* Ref.make("active"); + let beforeSystemIdleState: Effect.Effect = Effect.void; + let metricsReadCount = 0; + const simpleListeners = new Map void>(); + let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null = + null; + let speedLimitListener: ((limit: number) => void) | null = null; + const metrics = [ + { + pid: 4_242, + type: "Browser", + creationTime: 1_000.75, + name: "electron", + cpu: { + percentCPUUsage: 12.5, + cumulativeCPUUsage: 3.25, + idleWakeupsPerSecond: 7, + }, + memory: { + workingSetSize: 2_048, + peakWorkingSetSize: 4_096, + }, + } as Electron.ProcessMetric, + ]; + const powerLayer = Layer.succeed( + ElectronPowerMonitor.ElectronPowerMonitor, + ElectronPowerMonitor.ElectronPowerMonitor.of({ + isOnBatteryPower: Ref.get(onBattery), + getSystemIdleTime: Effect.succeed(5), + getSystemIdleState: () => + beforeSystemIdleState.pipe(Effect.andThen(Ref.get(systemIdleState))), + getCurrentThermalState: Effect.succeed("nominal"), + onSimpleEvent: (eventName, listener) => + Effect.sync(() => { + simpleListeners.set(eventName, listener); + }), + onThermalStateChange: (listener) => + Effect.sync(() => { + thermalListener = listener; + }), + onSpeedLimitChange: (listener) => + Effect.sync(() => { + speedLimitListener = listener; + }), + }), + ); + const layer = DesktopTelemetryPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + makeElectronAppLayer(metrics, () => { + metricsReadCount += 1; + }), + powerLayer, + ), + ), + ); + + yield* Effect.gen(function* () { + const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; + const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); + const decoder = new TextDecoder(); + const decodeMessage = Schema.decodeUnknownEffect( + Schema.fromJsonString(DesktopHostTelemetryMessage), + ); + const messages = yield* Effect.forEach(encoded, (bytes) => + decodeMessage(decoder.decode(bytes).trim()), + ); + + assert.equal(messages[0]?.type, "desktopTelemetryHello"); + assert.equal(messages[0]?.electronPid, process.pid); + const initialSnapshot = messages[1]; + if (initialSnapshot?.type !== "desktopTelemetry") { + return assert.fail("Expected the second telemetry message to be a snapshot."); + } + assert.deepEqual(initialSnapshot.electronProcesses, []); + assert.equal(initialSnapshot.electronPid, process.pid); + assert.equal(metricsReadCount, 0); + + const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* publisher.handleControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + const demandedSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber)); + assert.equal(demandedSnapshot.electronProcesses[0]?.pid, 4_242); + assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001); + assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5); + assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024); + assert.equal(metricsReadCount, 1); + yield* publisher.handleControlForSource("secondary-backend", { + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + yield* publisher.handleControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: false, + }); + yield* publisher.handleControlForSource("old-backend", { + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + yield* publisher.handleControlForSource("secondary-backend", { + version: 1, + type: "setDiagnosticsDemand", + enabled: false, + }); + yield* Effect.all( + [ + publisher.removeControlSource("old-backend"), + publisher.handleControlForSource("replacement-backend", { + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }), + ], + { concurrency: "unbounded", discard: true }, + ); + + const batterySnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + simpleListeners.get("on-battery")?.(); + const batterySnapshot = Option.getOrThrow(yield* Fiber.join(batterySnapshotFiber)); + assert.equal(batterySnapshot.power.onBattery, "true"); + yield* Ref.set(onBattery, true); + + const metricsAfterBatteryEvent = metricsReadCount; + yield* TestClock.adjust(Duration.millis(4_999)); + assert.equal(metricsReadCount, metricsAfterBatteryEvent); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(metricsReadCount, metricsAfterBatteryEvent + 1); + assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "true"); + + yield* Ref.set(onBattery, false); + const metricsBeforePolledAc = metricsReadCount; + yield* TestClock.adjust(Duration.seconds(5)); + assert.equal(metricsReadCount, metricsBeforePolledAc + 1); + assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "false"); + yield* TestClock.adjust(Duration.seconds(1)); + assert.equal(metricsReadCount, metricsBeforePolledAc + 2); + + const suspendedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + simpleListeners.get("suspend")?.(); + const suspendedSnapshot = Option.getOrThrow(yield* Fiber.join(suspendedSnapshotFiber)); + assert.isTrue(suspendedSnapshot.power.suspended); + + const constrainedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + thermalListener?.("serious"); + const constrainedSnapshot = Option.getOrThrow(yield* Fiber.join(constrainedSnapshotFiber)); + assert.equal(constrainedSnapshot.power.thermalState, "serious"); + assert.isTrue(constrainedSnapshot.power.suspended); + + const metricsAfterThermalEvent = metricsReadCount; + const recoveredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.millis(14_999)); + assert.equal(metricsReadCount, metricsAfterThermalEvent); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(metricsReadCount, metricsAfterThermalEvent + 1); + const recoveredSnapshot = Option.getOrThrow(yield* Fiber.join(recoveredSnapshotFiber)); + assert.isFalse(recoveredSnapshot.power.suspended); + + const speedLimitSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + speedLimitListener?.(65); + const speedLimitSnapshot = Option.getOrThrow(yield* Fiber.join(speedLimitSnapshotFiber)); + assert.equal(Option.getOrNull(speedLimitSnapshot.speedLimitPercent), 65); + + const encodedSpeedLimit = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); + const decodedSpeedLimit = yield* decodeMessage(decoder.decode(encodedSpeedLimit[1]).trim()); + if (decodedSpeedLimit.type !== "desktopTelemetry") { + return assert.fail("Expected the encoded telemetry message to be a snapshot."); + } + assert.equal(Option.getOrNull(decodedSpeedLimit.speedLimitPercent), 65); + assert.equal(decodedSpeedLimit.electronProcesses[0]?.pid, 4_242); + assert.equal(decodedSpeedLimit.electronProcesses[0]?.creationTimeMs, 1_001); + + const metricsBeforeSecondaryOnlySample = metricsReadCount; + yield* TestClock.adjust(Duration.seconds(15)); + assert.equal(metricsReadCount, metricsBeforeSecondaryOnlySample + 1); + assert.equal( + (yield* publisher.latest).pipe(Option.getOrThrow).electronProcesses[0]?.pid, + 4_242, + ); + + const stoppedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* publisher.handleControlForSource("replacement-backend", { + version: 1, + type: "setDiagnosticsDemand", + enabled: false, + }); + const stoppedSnapshot = Option.getOrThrow(yield* Fiber.join(stoppedSnapshotFiber)); + assert.deepEqual(stoppedSnapshot.electronProcesses, []); + const metricsAfterStopping = metricsReadCount; + const configuredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* publisher.handleControl({ + version: 1, + type: "setHostPowerIntervals", + activeIntervalMs: 7_000, + idleIntervalMs: 11_000, + }); + const configuredSequence = Option.getOrThrow( + yield* Fiber.join(configuredSnapshotFiber), + ).sequence; + + yield* TestClock.adjust(Duration.millis(6_999)); + assert.equal( + (yield* publisher.latest).pipe(Option.getOrThrow).sequence, + configuredSequence, + ); + assert.equal(metricsReadCount, metricsAfterStopping); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal( + (yield* publisher.latest).pipe(Option.getOrThrow).sequence, + configuredSequence + 1, + ); + assert.equal(metricsReadCount, metricsAfterStopping); + + yield* Ref.set(systemIdleState, "locked"); + yield* TestClock.adjust(Duration.seconds(7)); + const lockedSequence = (yield* publisher.latest).pipe(Option.getOrThrow).sequence; + assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.locked, "true"); + yield* TestClock.adjust(Duration.millis(10_999)); + assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).sequence, lockedSequence); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal( + (yield* publisher.latest).pipe(Option.getOrThrow).sequence, + lockedSequence + 1, + ); + + yield* Ref.set(systemIdleState, "active"); + yield* TestClock.adjust(Duration.seconds(11)); + const unlockedSnapshot = (yield* publisher.latest).pipe(Option.getOrThrow); + assert.equal(unlockedSnapshot.power.locked, "false"); + assert.equal(unlockedSnapshot.power.idle, "false"); + + yield* TestClock.adjust(Duration.millis(6_999)); + assert.equal( + (yield* publisher.latest).pipe(Option.getOrThrow).sequence, + unlockedSnapshot.sequence, + ); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal( + (yield* publisher.latest).pipe(Option.getOrThrow).sequence, + unlockedSnapshot.sequence + 1, + ); + + const pollStarted = yield* Deferred.make(); + const releasePoll = yield* Deferred.make(); + beforeSystemIdleState = Deferred.succeed(pollStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePoll)), + ); + const concurrentEventSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* publisher.handleControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + yield* Deferred.await(pollStarted); + simpleListeners.get("lock-screen")?.(); + thermalListener?.("critical"); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Deferred.succeed(releasePoll, undefined); + + const concurrentEventSnapshot = Option.getOrThrow( + yield* Fiber.join(concurrentEventSnapshotFiber), + ); + assert.equal(concurrentEventSnapshot.power.locked, "true"); + assert.equal(concurrentEventSnapshot.power.thermalState, "critical"); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts new file mode 100644 index 000000000000..9c17ae514d22 --- /dev/null +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -0,0 +1,379 @@ +import { + DesktopHostTelemetryMessage, + type DesktopHostTelemetrySnapshot, + type DesktopTelemetryControlMessage, + type HostPowerSnapshot, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; + +const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); +const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); +const CONSTRAINED_SAMPLE_INTERVAL = Duration.seconds(15); +const DEFAULT_HOST_POWER_ACTIVE_INTERVAL = Duration.seconds(30); +const DEFAULT_HOST_POWER_IDLE_INTERVAL = Duration.minutes(2); +const IDLE_THRESHOLD_SECONDS = 60; +const encodeMessage = Schema.encodeSync(Schema.fromJsonString(DesktopHostTelemetryMessage)); +const textEncoder = new TextEncoder(); + +type PowerEvent = + | { readonly type: "locked"; readonly value: boolean } + | { readonly type: "suspended"; readonly value: boolean } + | { readonly type: "onBattery"; readonly value: boolean } + | { readonly type: "thermal"; readonly value: HostPowerSnapshot["thermalState"] } + | { readonly type: "speedLimit"; readonly value: number }; + +interface PowerState { + readonly idle: HostPowerSnapshot["idle"]; + readonly locked: HostPowerSnapshot["locked"]; + readonly lockedEventPending: boolean; + readonly suspended: boolean; + readonly suspendedEventPending: boolean; + readonly onBattery: HostPowerSnapshot["onBattery"]; + readonly onBatteryEventPending: boolean; + readonly thermalState: HostPowerSnapshot["thermalState"]; + readonly speedLimitPercent: Option.Option; +} + +interface HostPowerIntervals { + readonly active: Duration.Duration; + readonly idle: Duration.Duration; +} + +export class DesktopTelemetryPublisher extends Context.Service< + DesktopTelemetryPublisher, + { + readonly latest: Effect.Effect>; + readonly changes: Stream.Stream; + readonly encoded: Stream.Stream; + readonly handleControl: (message: DesktopTelemetryControlMessage) => Effect.Effect; + readonly handleControlForSource: ( + sourceId: string, + message: DesktopTelemetryControlMessage, + ) => Effect.Effect; + readonly removeControlSource: (sourceId: string) => Effect.Effect; + } +>()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {} + +function booleanState(value: boolean): HostPowerSnapshot["onBattery"] { + return value ? "true" : "false"; +} + +function idleState(value: ElectronPowerMonitor.ElectronIdleState): HostPowerSnapshot["idle"] { + switch (value) { + case "active": + return "false"; + case "idle": + case "locked": + return "true"; + case "unknown": + return "unknown"; + } +} + +function updatePowerState(state: PowerState, event: PowerEvent): PowerState { + switch (event.type) { + case "locked": + return { + ...state, + locked: booleanState(event.value), + lockedEventPending: true, + }; + case "suspended": + return { + ...state, + suspended: event.value, + suspendedEventPending: event.value, + }; + case "onBattery": + return { + ...state, + onBattery: booleanState(event.value), + onBatteryEventPending: true, + }; + case "thermal": + return { ...state, thermalState: event.value }; + case "speedLimit": + return { ...state, speedLimitPercent: Option.some(event.value) }; + } +} + +function sampleInterval( + power: PowerState, + diagnosticsDemand: boolean, + hostPowerIntervals: HostPowerIntervals, +): Duration.Duration { + if (!diagnosticsDemand) { + return power.suspended || power.locked === "true" || power.idle === "true" + ? hostPowerIntervals.idle + : hostPowerIntervals.active; + } + if ( + power.suspended || + power.locked === "true" || + power.thermalState === "serious" || + power.thermalState === "critical" + ) { + return CONSTRAINED_SAMPLE_INTERVAL; + } + if (power.onBattery === "true") return BATTERY_SAMPLE_INTERVAL; + return LIVE_SAMPLE_INTERVAL; +} + +export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; + yield* electronApp.whenReady; + + const initialPowerState: PowerState = { + idle: "unknown", + locked: "unknown", + lockedEventPending: false, + suspended: false, + suspendedEventPending: false, + onBattery: booleanState(yield* powerMonitor.isOnBatteryPower), + onBatteryEventPending: false, + thermalState: yield* powerMonitor.getCurrentThermalState, + speedLimitPercent: Option.none(), + }; + const powerState = yield* Ref.make(initialPowerState); + const hostPowerIntervals = yield* Ref.make({ + active: DEFAULT_HOST_POWER_ACTIVE_INTERVAL, + idle: DEFAULT_HOST_POWER_IDLE_INTERVAL, + }); + const powerEvents = yield* Queue.unbounded(); + const sampleTriggers = yield* Queue.sliding(1); + const diagnosticsDemandSources = yield* Ref.make>(new Set()); + const latest = yield* Ref.make(Option.none()); + const changes = yield* PubSub.sliding(8); + const sequence = yield* Ref.make(0); + + const offer = (event: PowerEvent): void => { + Queue.offerUnsafe(powerEvents, event); + }; + yield* Effect.all( + [ + powerMonitor.onSimpleEvent("lock-screen", () => offer({ type: "locked", value: true })), + powerMonitor.onSimpleEvent("unlock-screen", () => offer({ type: "locked", value: false })), + powerMonitor.onSimpleEvent("suspend", () => offer({ type: "suspended", value: true })), + powerMonitor.onSimpleEvent("resume", () => offer({ type: "suspended", value: false })), + powerMonitor.onSimpleEvent("on-battery", () => offer({ type: "onBattery", value: true })), + powerMonitor.onSimpleEvent("on-ac", () => offer({ type: "onBattery", value: false })), + powerMonitor.onThermalStateChange((value) => offer({ type: "thermal", value })), + powerMonitor.onSpeedLimitChange((value) => offer({ type: "speedLimit", value })), + ], + { concurrency: "unbounded" }, + ); + yield* Effect.forever( + Queue.take(powerEvents).pipe( + Effect.flatMap((event) => Ref.update(powerState, (state) => updatePowerState(state, event))), + Effect.andThen(Queue.offer(sampleTriggers, undefined)), + ), + ).pipe(Effect.forkScoped); + + const sampleOnce = (allowSuspendRecovery: boolean) => + Effect.gen(function* () { + const sampledAt = yield* DateTime.now; + const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt); + const demand = (yield* Ref.get(diagnosticsDemandSources)).size > 0; + const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all( + [ + Ref.get(powerState), + powerMonitor.getSystemIdleTime, + powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), + powerMonitor.isOnBatteryPower, + demand ? electronApp.getAppMetrics : Effect.succeed([]), + ], + { concurrency: "unbounded" }, + ); + const polledLocked = + systemIdleState === "unknown" + ? currentPower.locked + : booleanState(systemIdleState === "locked"); + const polledOnBattery = booleanState(onBattery); + const observedPower = yield* Ref.modify(powerState, (latestPower) => { + const preserveEventLocked = + latestPower.lockedEventPending || + latestPower.locked !== currentPower.locked || + latestPower.lockedEventPending !== currentPower.lockedEventPending; + const preserveEventOnBattery = + latestPower.onBatteryEventPending || + latestPower.onBattery !== currentPower.onBattery || + latestPower.onBatteryEventPending !== currentPower.onBatteryEventPending; + const preserveEventSuspended = + latestPower.suspendedEventPending || + latestPower.suspended !== currentPower.suspended || + latestPower.suspendedEventPending !== currentPower.suspendedEventPending; + const next: PowerState = { + ...latestPower, + idle: idleState(systemIdleState), + locked: preserveEventLocked ? latestPower.locked : polledLocked, + lockedEventPending: + latestPower.lockedEventPending && + (systemIdleState === "unknown" || polledLocked !== latestPower.locked), + suspended: + allowSuspendRecovery && !preserveEventSuspended ? false : latestPower.suspended, + suspendedEventPending: false, + onBattery: preserveEventOnBattery ? latestPower.onBattery : polledOnBattery, + onBatteryEventPending: + latestPower.onBatteryEventPending && polledOnBattery !== latestPower.onBattery, + }; + return [next, next] as const; + }); + const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]); + const snapshot: DesktopHostTelemetrySnapshot = { + version: 1, + type: "desktopTelemetry", + sequence: nextSequence, + sampledAtUnixMs, + electronPid: process.pid, + power: { + source: "electron-main", + idle: observedPower.idle, + idleSeconds, + locked: observedPower.locked, + suspended: observedPower.suspended, + onBattery: observedPower.onBattery, + lowPowerMode: "unknown", + thermalState: observedPower.thermalState, + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: observedPower.speedLimitPercent, + electronProcesses: metrics.map((metric) => ({ + pid: metric.pid, + creationTimeMs: Math.max(0, Math.round(metric.creationTime)), + type: metric.type, + ...(metric.name === undefined ? {} : { name: metric.name }), + ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }), + cpuPercent: metric.cpu.percentCPUUsage, + ...(metric.cpu.cumulativeCPUUsage === undefined + ? {} + : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }), + idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond, + workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)), + peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)), + })), + }; + + yield* Ref.set(latest, Option.some(snapshot)); + yield* PubSub.publish(changes, snapshot); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("Failed to sample Electron telemetry", { + cause: String(cause), + }), + ), + ); + + yield* Effect.gen(function* () { + yield* sampleOnce(false); + while (true) { + const [currentPower, demand, intervals] = yield* Effect.all([ + Ref.get(powerState), + Ref.get(diagnosticsDemandSources).pipe(Effect.map((sources) => sources.size > 0)), + Ref.get(hostPowerIntervals), + ]); + const allowSuspendRecovery = yield* Effect.raceFirst( + Queue.take(sampleTriggers).pipe(Effect.as(false)), + Effect.sleep(sampleInterval(currentPower, demand, intervals)).pipe(Effect.as(true)), + ); + yield* sampleOnce(allowSuspendRecovery); + } + }).pipe(Effect.forkScoped); + + const handleControlForSource: DesktopTelemetryPublisher["Service"]["handleControlForSource"] = ( + sourceId, + message, + ) => { + switch (message.type) { + case "setDiagnosticsDemand": + return Ref.modify(diagnosticsDemandSources, (sources) => { + const previous = sources.size > 0; + const next = new Set(sources); + if (message.enabled) { + next.add(sourceId); + } else { + next.delete(sourceId); + } + return [[previous, next.size > 0] as const, next] as const; + }).pipe( + Effect.flatMap(([previous, enabled]) => + previous === enabled + ? Effect.void + : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid), + ), + ); + case "setHostPowerIntervals": + return Ref.set(hostPowerIntervals, { + active: Duration.millis(message.activeIntervalMs), + idle: Duration.millis(message.idleIntervalMs), + }).pipe(Effect.andThen(Queue.offer(sampleTriggers, undefined)), Effect.asVoid); + } + }; + const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = ( + sourceId, + ) => + Ref.modify(diagnosticsDemandSources, (sources) => { + const previous = sources.size > 0; + const next = new Set(sources); + next.delete(sourceId); + return [[previous, next.size > 0] as const, next] as const; + }).pipe( + Effect.flatMap(([previous, enabled]) => + previous === enabled + ? Effect.void + : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid), + ), + ); + const handleControl: DesktopTelemetryPublisher["Service"]["handleControl"] = (message) => + handleControlForSource("legacy", message); + + const snapshots = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const initial = yield* Ref.get(latest); + return Stream.concat( + Option.match(initial, { + onNone: () => Stream.empty, + onSome: Stream.make, + }), + Stream.fromSubscription(subscription), + ); + }), + ); + const encoded = Stream.concat( + Stream.make({ + version: 1, + type: "desktopTelemetryHello", + electronPid: process.pid, + } as const), + snapshots, + ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`))); + + return DesktopTelemetryPublisher.of({ + latest: Ref.get(latest), + changes: Stream.fromPubSub(changes), + encoded, + handleControl, + handleControlForSource, + removeControlSource, + }); +}); + +export const layer = Layer.effect(DesktopTelemetryPublisher, make()); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 0d48ab04ceb4..34fc4447146f 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -40,6 +40,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, requestSingleInstanceLock: Effect.succeed(true), + getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, diff --git a/apps/mobile/src/connection/background-activity-scopes.ts b/apps/mobile/src/connection/background-activity-scopes.ts new file mode 100644 index 000000000000..da013a572191 --- /dev/null +++ b/apps/mobile/src/connection/background-activity-scopes.ts @@ -0,0 +1,92 @@ +import type { EnvironmentRpcSubscriptionObservation } from "@t3tools/client-runtime/rpc"; +import { type BackgroundScope, type EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +interface RetainedScope { + readonly environmentId: EnvironmentId; + readonly scope: BackgroundScope; + refCount: number; +} + +const retainedScopes = new Map(); +const listeners = new Set<() => void>(); + +function notify(): void { + for (const listener of listeners) { + try { + listener(); + } catch { + // A failing observer must not corrupt retained-scope lifetime. + } + } +} + +function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string { + switch (scope.type) { + case "server-config": + case "diagnostics": + return JSON.stringify([environmentId, scope.type]); + case "provider-status": + return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]); + case "vcs-status": + case "git-refs": + return JSON.stringify([environmentId, scope.type, scope.cwd]); + case "thread": + return JSON.stringify([environmentId, scope.type, scope.threadId]); + } +} + +function scopeForSubscription( + observation: EnvironmentRpcSubscriptionObservation, +): BackgroundScope | null { + if (observation.method === WS_METHODS.subscribeResourceTelemetry) { + return { type: "diagnostics" }; + } + if (observation.method !== WS_METHODS.subscribeVcsStatus) { + return null; + } + const input = observation.input as { readonly cwd?: unknown }; + return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; +} + +export function retainedMobileBackgroundScopes( + environmentId: EnvironmentId, +): ReadonlyArray { + return Array.from(retainedScopes.values(), (entry) => + entry.environmentId === environmentId ? entry.scope : null, + ).filter((scope): scope is BackgroundScope => scope !== null); +} + +export function observeMobileBackgroundActivitySubscription( + observation: EnvironmentRpcSubscriptionObservation, +): Effect.Effect> { + const scope = scopeForSubscription(observation); + if (scope === null) return Effect.succeed(Effect.void); + return Effect.sync(() => { + const environmentId = observation.environmentId as EnvironmentId; + const key = stableScopeKey(environmentId, scope); + const current = retainedScopes.get(key); + if (current) { + current.refCount += 1; + } else { + retainedScopes.set(key, { environmentId, scope, refCount: 1 }); + notify(); + } + return Effect.sync(() => { + const retained = retainedScopes.get(key); + if (!retained) return; + retained.refCount -= 1; + if (retained.refCount <= 0) { + retainedScopes.delete(key); + notify(); + } + }); + }); +} + +export function onRetainedMobileBackgroundScopesChange(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/apps/mobile/src/connection/background-activity.test.ts b/apps/mobile/src/connection/background-activity.test.ts new file mode 100644 index 000000000000..7a2d902557ed --- /dev/null +++ b/apps/mobile/src/connection/background-activity.test.ts @@ -0,0 +1,77 @@ +import { EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + onRetainedMobileBackgroundScopesChange, + observeMobileBackgroundActivitySubscription, + retainedMobileBackgroundScopes, +} from "./background-activity-scopes"; + +describe("mobile background activity", () => { + it.effect("retains VCS demand only while the mobile subscription is active", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("mobile-environment"); + const release = yield* observeMobileBackgroundActivitySubscription({ + environmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "/workspace" }, + }); + + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([ + { type: "vcs-status", cwd: "/workspace" }, + ]); + + yield* release; + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); + }), + ); + + it.effect("keeps delimiter-containing environment and scope values distinct", () => + Effect.gen(function* () { + const firstEnvironmentId = EnvironmentId.make("a"); + const secondEnvironmentId = EnvironmentId.make("a:vcs-status:b"); + const releaseFirst = yield* observeMobileBackgroundActivitySubscription({ + environmentId: firstEnvironmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "b:vcs-status:c" }, + }); + const releaseSecond = yield* observeMobileBackgroundActivitySubscription({ + environmentId: secondEnvironmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "c" }, + }); + + expect(retainedMobileBackgroundScopes(firstEnvironmentId)).toEqual([ + { type: "vcs-status", cwd: "b:vcs-status:c" }, + ]); + expect(retainedMobileBackgroundScopes(secondEnvironmentId)).toEqual([ + { type: "vcs-status", cwd: "c" }, + ]); + + yield* Effect.all([releaseFirst, releaseSecond]); + }), + ); + + it.effect("returns a release handle when a retained-scope listener throws", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("throwing-listener-environment"); + const removeListener = onRetainedMobileBackgroundScopesChange(() => { + throw new Error("listener failed"); + }); + + const release = yield* observeMobileBackgroundActivitySubscription({ + environmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "/workspace" }, + }); + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([ + { type: "vcs-status", cwd: "/workspace" }, + ]); + + yield* release; + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); + removeListener(); + }), + ); +}); diff --git a/apps/mobile/src/connection/background-activity.ts b/apps/mobile/src/connection/background-activity.ts new file mode 100644 index 000000000000..3364eed3c2d1 --- /dev/null +++ b/apps/mobile/src/connection/background-activity.ts @@ -0,0 +1,116 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { EnvironmentRpcSubscriptionObserver, request } from "@t3tools/client-runtime/rpc"; +import { + type BackgroundScope, + type ClientActivityReportInput, + type EnvironmentId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AppState, type AppStateStatus } from "react-native"; + +import * as MobileStorage from "../persistence/mobile-storage"; +import { + observeMobileBackgroundActivitySubscription, + onRetainedMobileBackgroundScopesChange, + retainedMobileBackgroundScopes, +} from "./background-activity-scopes"; + +const REPORT_INTERVAL_MS = 25_000; +const LEASE_TTL_MS = 45_000; +const BASELINE_SCOPES: ReadonlyArray = [{ type: "provider-status" }]; + +function normalizeAppState( + state: AppStateStatus, +): NonNullable { + if (state === "active" || state === "inactive" || state === "background") return state; + return "unknown"; +} + +export const mobileBackgroundActivityObserverLayer = Layer.succeed( + EnvironmentRpcSubscriptionObserver, + EnvironmentRpcSubscriptionObserver.of({ + observe: observeMobileBackgroundActivitySubscription, + }), +); + +export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const storage = yield* MobileStorage.MobileStorage; + const clientId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.map((deviceId) => `mobile-${deviceId}`), + Effect.orElseSucceed(() => "ephemeral-mobile-client"), + ); + const reportRequests = yield* Queue.sliding(1); + const requestReport = () => Queue.offerUnsafe(reportRequests, undefined); + let appState = AppState.currentState; + + const report = Effect.gen(function* () { + const observedAtMs = yield* Clock.currentTimeMillis; + const active = appState === "active"; + const entries = yield* SubscriptionRef.get(registry.entries); + yield* Effect.forEach( + entries.keys(), + (environmentId) => + registry + .run( + environmentId, + request(WS_METHODS.serverReportClientActivity, { + environmentId: environmentId as EnvironmentId, + clientId, + clientKind: "mobile", + visible: active, + focused: active, + recentlyInteracted: active, + appState: normalizeAppState(appState), + scopes: [ + ...BASELINE_SCOPES, + ...retainedMobileBackgroundScopes(environmentId as EnvironmentId), + ], + ttlMs: LEASE_TTL_MS, + observedAt: DateTime.makeUnsafe(observedAtMs), + }), + ) + .pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + }).pipe(Effect.withSpan("mobile.backgroundActivity.report")); + + yield* Effect.acquireRelease( + Effect.sync(() => { + const removeScopeListener = onRetainedMobileBackgroundScopesChange(requestReport); + const subscription = AppState.addEventListener("change", (nextState) => { + appState = nextState; + requestReport(); + }); + return { removeScopeListener, subscription }; + }), + ({ removeScopeListener, subscription }) => + Effect.sync(() => { + removeScopeListener(); + subscription.remove(); + }), + ); + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.runForEach(() => Effect.sync(requestReport)), + Effect.forkScoped, + ); + yield* Stream.fromQueue(reportRequests).pipe( + Stream.debounce("250 millis"), + Stream.runForEach(() => report), + Effect.forkScoped, + ); + yield* Effect.sync(requestReport).pipe( + Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)), + Effect.forkScoped, + ); + }), +); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index 3d4bf4944f14..b589c114b926 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -5,6 +5,10 @@ import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; import { runtimeContextLayer } from "../lib/runtime"; +import { + mobileBackgroundActivityObserverLayer, + mobileBackgroundActivityReporterLayer, +} from "./background-activity"; import { connectionPlatformLayer } from "./platform"; const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( @@ -17,10 +21,22 @@ type ConnectionLayerSource = | typeof Connection.layer | typeof snapshotLoaderLayer | typeof runtimeContextLayer - | typeof connectionPlatformLayer; + | typeof connectionPlatformLayer + | typeof mobileBackgroundActivityObserverLayer + | typeof mobileBackgroundActivityReporterLayer; -const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( - Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( + Layer.provideMerge( + Layer.mergeAll( + runtimeContextLayer, + providedConnectionPlatformLayer, + mobileBackgroundActivityObserverLayer, + ), + ), +); + +const connectionLayer = mobileBackgroundActivityReporterLayer.pipe( + Layer.provideMerge(providedClientConnectionLayer), ); export const connectionAtomRuntime: Atom.AtomRuntime< diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts new file mode 100644 index 000000000000..5aba97830754 --- /dev/null +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -0,0 +1,47 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthRelayWriteScope, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; + +describe("RPC authorization scopes", () => { + it("declares exactly one scope for every RPC in the server group", () => { + expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); + }); + + it("authorizes background policy reporting and observation deliberately", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.subscribeBackgroundPolicy)).toBe( + AuthOrchestrationReadScope, + ); + }); + + it("allows relay status reads without granting relay installation access", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe( + AuthRelayReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); + }); + + it("rejects unknown RPC method names", () => { + for (const method of ["server.notRegistered", "toString", "constructor"]) { + expect(() => requiredScopeForRpcMethod(method)).toThrow( + `RPC method ${method} has no declared authorization scope.`, + ); + } + }); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts new file mode 100644 index 000000000000..5655f260bc95 --- /dev/null +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -0,0 +1,111 @@ +import { + AuthAccessReadScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthRelayWriteScope, + AuthReviewWriteScope, + AuthTerminalOperateScope, + ORCHESTRATION_WS_METHODS, + type AuthEnvironmentScope, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +type WsRpcMethod = RpcGroup.Rpcs["_tag"]; + +/** + * Keep authorization coverage coupled to the RPC group itself. Adding an RPC to + * `WsRpcGroup` without choosing a scope is a type error instead of a production + * runtime failure. + */ +export const RPC_REQUIRED_SCOPES = { + [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, + [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, + [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, + [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, + [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, + [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, + [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, + [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, + [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, + [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, + [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, + [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, + [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, + [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, + [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, + [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, + [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, + [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, + [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, + [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, + [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, + [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, + [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, + [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, + [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, + [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, + [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, + [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, + [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, + [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, + [WS_METHODS.terminalResize]: AuthTerminalOperateScope, + [WS_METHODS.terminalClear]: AuthTerminalOperateScope, + [WS_METHODS.terminalRestart]: AuthTerminalOperateScope, + [WS_METHODS.terminalClose]: AuthTerminalOperateScope, + [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, + [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, + [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope, + [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope, + [WS_METHODS.previewResize]: AuthOrchestrationOperateScope, + [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope, + [WS_METHODS.previewClose]: AuthOrchestrationOperateScope, + [WS_METHODS.previewList]: AuthOrchestrationReadScope, + [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, + [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, + [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope, + [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope, + [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, + [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, +} as const satisfies Readonly>; + +export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope { + if (!Object.hasOwn(RPC_REQUIRED_SCOPES, method)) { + throw new Error(`RPC method ${method} has no declared authorization scope.`); + } + const requiredScope = RPC_REQUIRED_SCOPES[method as WsRpcMethod]; + if (requiredScope === undefined) { + throw new Error(`RPC method ${method} has no declared authorization scope.`); + } + return requiredScope; +} diff --git a/apps/server/src/background/BackgroundPolicy.test.ts b/apps/server/src/background/BackgroundPolicy.test.ts new file mode 100644 index 000000000000..441a518d2c89 --- /dev/null +++ b/apps/server/src/background/BackgroundPolicy.test.ts @@ -0,0 +1,386 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + AuthSessionId, + RpcClientId, + type HostPowerSnapshot, + type ClientActivityReportInput, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import * as BackgroundPolicy from "./BackgroundPolicy.ts"; +import * as HostPowerMonitor from "./HostPowerMonitor.ts"; + +const TEST_NOW = DateTime.makeUnsafe("2026-05-13T00:00:00.000Z"); + +const nominalHostPower: HostPowerSnapshot = { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_NOW, +}; + +const constrainedHostPower: HostPowerSnapshot = { + ...nominalHostPower, + lowPowerMode: "true", + stale: false, +}; + +function makeReport(overrides: Partial = {}): ClientActivityReportInput { + return { + clientId: "client-1", + clientKind: "web", + visible: true, + focused: true, + recentlyInteracted: true, + scopes: [{ type: "vcs-status", cwd: "/repo" }], + ttlMs: 45_000, + observedAt: TEST_NOW, + ...overrides, + }; +} + +function makeLayer( + hostPower: HostPowerSnapshot, + settingsOverrides: Parameters[0] = {}, +) { + const hostLayer = Layer.effect( + HostPowerMonitor.HostPowerMonitor, + Effect.gen(function* () { + const changes = yield* PubSub.sliding(1); + let snapshot = hostPower; + return HostPowerMonitor.HostPowerMonitor.of({ + snapshot: Effect.sync(() => snapshot), + report: (next) => + Effect.sync(() => { + snapshot = next; + }).pipe(Effect.andThen(PubSub.publish(changes, next)), Effect.asVoid), + streamChanges: Stream.fromPubSub(changes), + }); + }), + ); + return BackgroundPolicy.layer.pipe( + Layer.provide(Layer.merge(hostLayer, ServerSettingsService.layerTest(settingsOverrides))), + ); +} + +describe("BackgroundPolicy", () => { + it.effect("records foreground scoped client demand", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport(), + ); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 1); + assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); + assert.equal(snapshot.shouldRunOpportunisticWork, true); + assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); + assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/other" }), false); + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/other" }), false); + }).pipe(Effect.provide(makeLayer(nominalHostPower))), + ); + + it.effect("removes all leases for a disconnected websocket connection", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport(), + ); + yield* policy.removeRpcClient(AuthSessionId.make("session-1"), RpcClientId.make(1)); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 0); + assert.deepStrictEqual(snapshot.activeScopeKeys, []); + assert.equal(snapshot.shouldRunOpportunisticWork, false); + }).pipe(Effect.provide(makeLayer(nominalHostPower))), + ); + + it.effect("keeps leases from another session when rpc client ids are reused", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + const rpcClientId = RpcClientId.make(1); + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + rpcClientId, + makeReport({ clientId: "client-1" }), + ); + yield* policy.reportClientActivity( + AuthSessionId.make("session-2"), + rpcClientId, + makeReport({ clientId: "client-2" }), + ); + + yield* policy.removeRpcClient(AuthSessionId.make("session-1"), rpcClientId); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 1); + assert.equal(snapshot.leases[0]?.sessionId, AuthSessionId.make("session-2")); + assert.equal(snapshot.leases[0]?.clientId, "client-2"); + }).pipe(Effect.provide(makeLayer(nominalHostPower))), + ); + + it.effect("serializes lease mutation publications", () => + Effect.gen(function* () { + const firstSnapshotStarted = yield* Deferred.make(); + const releaseFirstSnapshot = yield* Deferred.make(); + const snapshotReads = yield* Ref.make(0); + const hostLayer = Layer.succeed( + HostPowerMonitor.HostPowerMonitor, + HostPowerMonitor.HostPowerMonitor.of({ + snapshot: Ref.updateAndGet(snapshotReads, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.succeed(firstSnapshotStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstSnapshot)), + Effect.as(nominalHostPower), + ) + : Effect.succeed(nominalHostPower), + ), + ), + report: () => Effect.void, + streamChanges: Stream.empty, + }), + ); + const layer = BackgroundPolicy.layer.pipe( + Layer.provide(Layer.merge(hostLayer, ServerSettingsService.layerTest())), + ); + + yield* Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + const updatesFiber = yield* policy.streamChanges.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const reportFiber = yield* policy + .reportClientActivity(AuthSessionId.make("session-1"), RpcClientId.make(1), makeReport()) + .pipe(Effect.forkChild); + yield* Deferred.await(firstSnapshotStarted); + const removeFiber = yield* policy + .removeRpcClient(AuthSessionId.make("session-1"), RpcClientId.make(1)) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseFirstSnapshot, undefined); + yield* Fiber.join(reportFiber); + yield* Fiber.join(removeFiber); + const updates = Array.from(yield* Fiber.join(updatesFiber)); + + assert.equal(updates.at(-1)?.activeForegroundLeaseCount, 0); + assert.deepStrictEqual(updates.at(-1)?.activeScopeKeys, []); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("bounds client-id churn for one websocket connection", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + const sessionId = AuthSessionId.make("session-1"); + const rpcClientId = RpcClientId.make(1); + for ( + let index = 0; + index <= BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT; + index += 1 + ) { + yield* policy.reportClientActivity( + sessionId, + rpcClientId, + makeReport({ clientId: `client-${index}` }), + ); + } + + const snapshot = yield* policy.snapshot; + assert.equal( + snapshot.leases.length, + BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT, + ); + assert.isTrue( + snapshot.leases.some( + (lease) => + lease.clientId === + `client-${BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT}`, + ), + ); + }).pipe(Effect.provide(makeLayer(nominalHostPower))), + ); + + it.effect("host low power mode disables opportunistic work without dropping scoped demand", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport(), + ); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 1); + assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); + assert.equal(snapshot.shouldRunOpportunisticWork, false); + assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); + }).pipe(Effect.provide(makeLayer(constrainedHostPower))), + ); + + it.effect("host suspension disables scoped and opportunistic work", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport(), + ); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 1); + assert.equal(snapshot.shouldRunOpportunisticWork, false); + assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); + }).pipe( + Effect.provide( + makeLayer({ + ...nominalHostPower, + suspended: true, + stale: false, + }), + ), + ), + ); + + it.effect("keeps background demand visible while preventing scoped work", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport({ focused: false, visible: false }), + ); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 0); + assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); + assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); + }).pipe(Effect.provide(makeLayer(nominalHostPower))), + ); + + it.effect("keeps scoped work active after a recently used visible window loses focus", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport({ focused: false, recentlyInteracted: true }), + ); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 1); + assert.equal(snapshot.shouldRunOpportunisticWork, true); + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); + }).pipe(Effect.provide(makeLayer(nominalHostPower))), + ); + + it.effect("pauses scoped work for a visible unfocused client without recent interaction", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport({ focused: false, recentlyInteracted: false }), + ); + + const snapshot = yield* policy.snapshot; + assert.equal(snapshot.activeForegroundLeaseCount, 0); + assert.equal(snapshot.shouldRunOpportunisticWork, false); + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); + }).pipe(Effect.provide(makeLayer(nominalHostPower))), + ); + + it.effect( + "performance profile allows background scoped work while a scoped lease is active", + () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport({ focused: false, visible: false }), + ); + + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); + }).pipe( + Effect.provide(makeLayer(nominalHostPower, { backgroundActivityProfile: "performance" })), + ), + ); + + it.effect("battery saver profile pauses scoped work on battery", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport(), + ); + + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); + }).pipe( + Effect.provide( + makeLayer( + { + ...nominalHostPower, + onBattery: "true", + stale: false, + }, + { backgroundActivityProfile: "battery-saver" }, + ), + ), + ), + ); + + it.effect("does not gate work on stale host power values", () => + Effect.gen(function* () { + const policy = yield* BackgroundPolicy.BackgroundPolicy; + yield* policy.reportClientActivity( + AuthSessionId.make("session-1"), + RpcClientId.make(1), + makeReport(), + ); + + assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); + }).pipe( + Effect.provide( + makeLayer( + { + ...nominalHostPower, + locked: "true", + onBattery: "true", + lowPowerMode: "true", + thermalState: "critical", + stale: true, + }, + { backgroundActivityProfile: "battery-saver" }, + ), + ), + ), + ); +}); diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts new file mode 100644 index 000000000000..a2a8e99d33ce --- /dev/null +++ b/apps/server/src/background/BackgroundPolicy.ts @@ -0,0 +1,349 @@ +import { + type AuthSessionId, + type BackgroundPolicySnapshot, + type BackgroundScope, + type ClientActivityLease, + type ClientActivityReportInput, + type HostPowerSnapshot, + type RpcClientId, +} from "@t3tools/contracts"; +import { + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, + type ResolvedBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; +import * as DateTime from "effect/DateTime"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts"; +import * as HostPowerMonitor from "./HostPowerMonitor.ts"; + +export class BackgroundPolicy extends Context.Service< + BackgroundPolicy, + { + readonly reportClientActivity: ( + sessionId: AuthSessionId, + rpcClientId: RpcClientId, + input: ClientActivityReportInput, + ) => Effect.Effect; + readonly removeRpcClient: ( + sessionId: AuthSessionId, + rpcClientId: RpcClientId, + ) => Effect.Effect; + readonly reportHostPowerState: (snapshot: HostPowerSnapshot) => Effect.Effect; + readonly snapshot: Effect.Effect; + readonly streamChanges: Stream.Stream; + readonly subscribe: Effect.Effect< + { + readonly latest: BackgroundPolicySnapshot; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; + readonly hasDemand: (scope: BackgroundScope) => Effect.Effect; + readonly shouldRunScopeWork: (scope: BackgroundScope) => Effect.Effect; + readonly shouldRunOpportunisticWork: Effect.Effect; + } +>()("t3/background/BackgroundPolicy") {} + +const DEFAULT_LEASE_TTL_MS = 45_000; +const MAX_LEASE_TTL_MS = 120_000; +export const MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT = 16; + +function scopeKey(scope: BackgroundScope): string { + switch (scope.type) { + case "server-config": + case "diagnostics": + return scope.type; + case "provider-status": + return scope.instanceId ? `${scope.type}:${scope.instanceId}` : scope.type; + case "vcs-status": + case "git-refs": + return `${scope.type}:${scope.cwd}`; + case "thread": + return `${scope.type}:${scope.threadId}`; + } +} + +function isLeaseActive(lease: ClientActivityLease, now: DateTime.Utc): boolean { + return DateTime.isGreaterThan(lease.expiresAt, now); +} + +function leaseKey(lease: Pick) { + return JSON.stringify([lease.sessionId, lease.rpcClientId, lease.clientId]); +} + +export function upsertClientActivityLease( + leases: ReadonlyMap, + lease: ClientActivityLease, + now: DateTime.Utc, +): Map { + const next = new Map(leases); + for (const [key, current] of next) { + if (!isLeaseActive(current, now)) { + next.delete(key); + } + } + + const key = leaseKey(lease); + if (!next.has(key)) { + let connectionLeaseCount = 0; + let oldestConnectionLease: + | { + readonly key: string; + readonly updatedAtMs: number; + } + | undefined; + for (const [currentKey, current] of next) { + if (current.sessionId !== lease.sessionId || current.rpcClientId !== lease.rpcClientId) { + continue; + } + connectionLeaseCount += 1; + const updatedAtMs = DateTime.toEpochMillis(current.updatedAt); + if (oldestConnectionLease === undefined || updatedAtMs < oldestConnectionLease.updatedAtMs) { + oldestConnectionLease = { key: currentKey, updatedAtMs }; + } + } + if ( + connectionLeaseCount >= MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT && + oldestConnectionLease !== undefined + ) { + next.delete(oldestConnectionLease.key); + } + } + + next.set(key, lease); + return next; +} + +function isForegroundLease(lease: ClientActivityLease, now: DateTime.Utc): boolean { + return isLeaseActive(lease, now) && lease.visible && (lease.focused || lease.recentlyInteracted); +} + +function leaseHasScope(lease: ClientActivityLease, scope: BackgroundScope): boolean { + const key = scopeKey(scope); + return lease.scopes.some((leaseScope) => scopeKey(leaseScope) === key); +} + +function hasThermalPressure(hostPower: HostPowerSnapshot): boolean { + return hostPower.thermalState === "serious" || hostPower.thermalState === "critical"; +} + +function isHostConstrained( + hostPower: HostPowerSnapshot, + settings: ResolvedBackgroundActivitySettings, +): boolean { + if (hostPower.stale) return false; + if ( + hostPower.suspended || + (settings.pauseWhenHostLocked && hostPower.locked === "true") || + hasThermalPressure(hostPower) + ) { + return true; + } + if (settings.pauseWhenHostLowPower && hostPower.lowPowerMode === "true") return true; + return settings.pauseWhenOnBattery && hostPower.onBattery === "true"; +} + +function isClientConstrained( + lease: ClientActivityLease, + settings: ResolvedBackgroundActivitySettings, +): boolean { + if (settings.pauseWhenClientLowPower && lease.lowPowerMode === "true") return true; + return settings.pauseWhenOnBattery && lease.batteryState === "unplugged"; +} + +function leaseMayRunScopedWork( + lease: ClientActivityLease, + scope: BackgroundScope, + now: DateTime.Utc, + settings: ResolvedBackgroundActivitySettings, +): boolean { + const activeWithScope = isLeaseActive(lease, now) && leaseHasScope(lease, scope); + if (!activeWithScope || isClientConstrained(lease, settings)) { + return false; + } + if (settings.profile === "performance") { + return true; + } + return isForegroundLease(lease, now); +} + +function computeSnapshot(input: { + readonly hostPower: HostPowerSnapshot; + readonly leases: ReadonlyMap; + readonly now: DateTime.Utc; + readonly settings: ResolvedBackgroundActivitySettings; + readonly updatedAt: DateTime.Utc; +}): BackgroundPolicySnapshot { + const activeLeases = [...input.leases.values()].filter((lease) => + isLeaseActive(lease, input.now), + ); + const foregroundLeases = activeLeases.filter((lease) => isForegroundLease(lease, input.now)); + const activeScopeKeys = new Set(); + for (const lease of activeLeases) { + for (const scope of lease.scopes) { + activeScopeKeys.add(scopeKey(scope)); + } + } + + return { + hostPower: input.hostPower, + leases: activeLeases, + activeForegroundLeaseCount: foregroundLeases.length, + activeScopeKeys: [...activeScopeKeys].toSorted(), + shouldRunOpportunisticWork: + foregroundLeases.some((lease) => !isClientConstrained(lease, input.settings)) && + !isHostConstrained(input.hostPower, input.settings), + updatedAt: input.updatedAt, + }; +} + +export const make = Effect.fn("background.policy.make")(function* () { + const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor; + const serverSettings = yield* ServerSettingsService; + const leasesRef = yield* Ref.make(new Map()); + const changes = yield* PubSub.sliding(1); + const publishMutex = yield* Semaphore.make(1); + + const backgroundActivitySettings = serverSettings.getSettings.pipe( + Effect.map(resolveServerBackgroundActivitySettings), + Effect.orElseSucceed(() => getBackgroundActivityPresetSettings("balanced")), + ); + + const snapshot = Effect.gen(function* () { + const [hostPower, leases, now, settings] = yield* Effect.all([ + hostPowerMonitor.snapshot, + Ref.get(leasesRef), + DateTime.now, + backgroundActivitySettings, + ]); + return computeSnapshot({ hostPower, leases, now, settings, updatedAt: now }); + }); + + const publishSnapshotUnlocked = snapshot.pipe( + Effect.flatMap((next) => PubSub.publish(changes, next)), + ); + const publishSnapshot = publishMutex.withPermits(1)(publishSnapshotUnlocked); + + const reportClientActivity: BackgroundPolicy["Service"]["reportClientActivity"] = ( + sessionId, + rpcClientId, + input, + ) => + publishMutex.withPermits(1)( + Effect.gen(function* () { + const ttlMs = Math.min( + Math.max(input.ttlMs ?? DEFAULT_LEASE_TTL_MS, 1_000), + MAX_LEASE_TTL_MS, + ); + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { milliseconds: ttlMs }); + const lease: ClientActivityLease = { + sessionId, + rpcClientId, + clientId: input.clientId, + clientKind: input.clientKind, + visible: input.visible, + focused: input.focused, + recentlyInteracted: input.recentlyInteracted, + ...(input.appState !== undefined ? { appState: input.appState } : {}), + ...(input.lowPowerMode !== undefined ? { lowPowerMode: input.lowPowerMode } : {}), + ...(input.batteryState !== undefined ? { batteryState: input.batteryState } : {}), + ...(input.networkType !== undefined ? { networkType: input.networkType } : {}), + scopes: input.scopes, + updatedAt: now, + expiresAt, + }; + yield* Ref.update(leasesRef, (leases) => upsertClientActivityLease(leases, lease, now)); + yield* publishSnapshotUnlocked; + }), + ); + + const removeRpcClient: BackgroundPolicy["Service"]["removeRpcClient"] = ( + sessionId, + rpcClientId, + ) => + publishMutex.withPermits(1)( + Ref.update(leasesRef, (leases) => { + const next = new Map(leases); + for (const [key, lease] of next) { + if (lease.sessionId === sessionId && lease.rpcClientId === rpcClientId) { + next.delete(key); + } + } + return next; + }).pipe(Effect.andThen(publishSnapshotUnlocked), Effect.asVoid), + ); + + const hasDemand: BackgroundPolicy["Service"]["hasDemand"] = (scope) => + Effect.map(snapshot, (current) => current.activeScopeKeys.includes(scopeKey(scope))); + + const shouldRunScopeWork: BackgroundPolicy["Service"]["shouldRunScopeWork"] = (scope) => + Effect.gen(function* () { + const [current, settings] = yield* Effect.all([snapshot, backgroundActivitySettings]); + if (isHostConstrained(current.hostPower, settings)) { + return false; + } + return current.leases.some((lease) => + leaseMayRunScopedWork(lease, scope, current.updatedAt, settings), + ); + }); + + const shouldRunOpportunisticWork = Effect.map( + snapshot, + (current) => current.shouldRunOpportunisticWork, + ); + + yield* Stream.runForEach(hostPowerMonitor.streamChanges, () => publishSnapshot).pipe( + Effect.forkScoped, + ); + yield* Stream.runForEach(serverSettings.streamChanges, () => publishSnapshot).pipe( + Effect.forkScoped, + ); + + yield* Effect.forever( + Effect.sleep("15 seconds").pipe( + Effect.andThen( + publishMutex.withPermits(1)( + Effect.gen(function* () { + const now = yield* DateTime.now; + yield* Ref.update(leasesRef, (leases) => { + const next = new Map(leases); + for (const [key, lease] of next) { + if (!isLeaseActive(lease, now)) { + next.delete(key); + } + } + return next; + }); + yield* publishSnapshotUnlocked; + }), + ), + ), + ), + ).pipe(Effect.forkScoped); + + return BackgroundPolicy.of({ + reportClientActivity, + removeRpcClient, + reportHostPowerState: hostPowerMonitor.report, + snapshot, + streamChanges: Stream.fromPubSub(changes), + subscribe: subscribeBeforeSnapshot(changes, snapshot, publishMutex), + hasDemand, + shouldRunScopeWork, + shouldRunOpportunisticWork, + }); +}); + +export const layer = Layer.effect(BackgroundPolicy, make()); diff --git a/apps/server/src/background/HostPowerMonitor.test.ts b/apps/server/src/background/HostPowerMonitor.test.ts new file mode 100644 index 000000000000..2bd4dc0feb1e --- /dev/null +++ b/apps/server/src/background/HostPowerMonitor.test.ts @@ -0,0 +1,188 @@ +import type { DesktopHostTelemetrySnapshot } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; +import * as HostPowerMonitor from "./HostPowerMonitor.ts"; + +describe("HostPowerMonitor", () => { + it.effect("publishes semantic power changes without idle-time heartbeat churn", () => + Effect.gen(function* () { + const monitor = yield* HostPowerMonitor.make(); + const initial = { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "false", + lowPowerMode: "unknown", + thermalState: "nominal", + stale: false, + updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), + } as const; + yield* monitor.report(initial); + + const nextChange = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* monitor.report({ + ...initial, + idleSeconds: 1, + updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"), + }); + yield* monitor.report({ + ...initial, + locked: "true", + updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:02.000Z"), + }); + + expect(Option.getOrThrow(yield* Fiber.join(nextChange)).locked).toBe("true"); + }), + ); + + it.effect("ignores host power reports older than the latest accepted snapshot", () => + Effect.gen(function* () { + const initial = { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "false", + lowPowerMode: "false", + thermalState: "nominal", + stale: false, + updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), + } as const; + const monitor = yield* HostPowerMonitor.make(initial); + const latestAt = DateTime.makeUnsafe("2026-06-17T12:00:02.000Z"); + yield* monitor.report({ + ...initial, + locked: "true", + updatedAt: latestAt, + }); + yield* monitor.report({ + ...initial, + updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"), + }); + + const snapshot = yield* monitor.snapshot; + expect(snapshot.locked).toBe("true"); + expect(DateTime.toEpochMillis(snapshot.updatedAt)).toBe(DateTime.toEpochMillis(latestAt)); + }), + ); + + it.effect("consumes desktop power directly without retaining diagnostics telemetry", () => + Effect.gen(function* () { + const sampledAt = DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"); + const desktopChanges = yield* PubSub.sliding(1); + const diagnosticsDemandWrites = yield* Ref.make(0); + const receiverLayer = DesktopTelemetryReceiver.layerTest({ + changes: Stream.fromPubSub(desktopChanges), + setDiagnosticsDemand: () => Ref.update(diagnosticsDemandWrites, (count) => count + 1), + }); + const layer = HostPowerMonitor.layer.pipe(Layer.provide(receiverLayer)); + + yield* Effect.gen(function* () { + const monitor = yield* HostPowerMonitor.HostPowerMonitor; + const nextPower = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* PubSub.publish(desktopChanges, { + version: 1, + type: "desktopTelemetry", + sequence: 1, + sampledAtUnixMs: DateTime.toEpochMillis(sampledAt), + electronPid: 100, + power: { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "true", + lowPowerMode: "unknown", + thermalState: "nominal", + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: Option.none(), + electronProcesses: [], + }); + + expect(Option.getOrThrow(yield* Fiber.join(nextPower)).onBattery).toBe("true"); + expect(yield* Ref.get(diagnosticsDemandWrites)).toBe(0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect( + "subscribes before reading the desktop snapshot so concurrent power updates survive", + () => + Effect.gen(function* () { + const sampledAt = DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"); + const initial: DesktopHostTelemetrySnapshot = { + version: 1, + type: "desktopTelemetry", + sequence: 1, + sampledAtUnixMs: DateTime.toEpochMillis(sampledAt), + electronPid: 100, + power: { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "false", + lowPowerMode: "unknown", + thermalState: "nominal", + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: Option.none(), + electronProcesses: [], + }; + const updated: DesktopHostTelemetrySnapshot = { + ...initial, + sequence: 2, + power: { + ...initial.power, + onBattery: "true", + updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"), + }, + }; + const desktopChanges = yield* PubSub.sliding(1); + const receiverLayer = DesktopTelemetryReceiver.layerTest({ + latest: Effect.succeedSome(initial), + changes: Stream.empty, + subscribe: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(desktopChanges); + yield* PubSub.publish(desktopChanges, updated); + return { + latest: Option.some(initial), + changes: Stream.fromSubscription(subscription), + }; + }), + }); + const layer = HostPowerMonitor.layer.pipe(Layer.provide(receiverLayer)); + + yield* Effect.gen(function* () { + const monitor = yield* HostPowerMonitor.HostPowerMonitor; + for ( + let attempt = 0; + attempt < 100 && (yield* monitor.snapshot).onBattery !== "true"; + attempt += 1 + ) { + yield* Effect.yieldNow; + } + expect((yield* monitor.snapshot).onBattery).toBe("true"); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/server/src/background/HostPowerMonitor.ts b/apps/server/src/background/HostPowerMonitor.ts new file mode 100644 index 000000000000..273548faa47f --- /dev/null +++ b/apps/server/src/background/HostPowerMonitor.ts @@ -0,0 +1,101 @@ +import type { HostPowerSnapshot } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; + +export class HostPowerMonitor extends Context.Service< + HostPowerMonitor, + { + readonly snapshot: Effect.Effect; + readonly report: (snapshot: HostPowerSnapshot) => Effect.Effect; + readonly streamChanges: Stream.Stream; + } +>()("t3/background/HostPowerMonitor") {} + +export const makeUnknownSnapshot = ( + source: HostPowerSnapshot["source"], + updatedAt: HostPowerSnapshot["updatedAt"], +): HostPowerSnapshot => ({ + source, + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt, +}); + +function samePowerState(left: HostPowerSnapshot, right: HostPowerSnapshot): boolean { + return ( + left.source === right.source && + left.idle === right.idle && + left.locked === right.locked && + left.suspended === right.suspended && + left.onBattery === right.onBattery && + left.lowPowerMode === right.lowPowerMode && + left.thermalState === right.thermalState && + left.stale === right.stale + ); +} + +export const make = Effect.fn("background.hostPower.make")(function* ( + initialSnapshot?: HostPowerSnapshot, +) { + const initial = initialSnapshot ?? makeUnknownSnapshot("unknown", yield* DateTime.now); + const latestRef = yield* Ref.make(initial); + const changes = yield* PubSub.sliding(1); + + const report: HostPowerMonitor["Service"]["report"] = (snapshot) => + Ref.modify(latestRef, (current) => { + if (DateTime.isLessThan(snapshot.updatedAt, current.updatedAt)) { + return [Option.none(), current] as const; + } + return [ + samePowerState(current, snapshot) ? Option.none() : Option.some(snapshot), + snapshot, + ] as const; + }).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (next) => PubSub.publish(changes, next), + }), + ), + Effect.asVoid, + ); + + return HostPowerMonitor.of({ + snapshot: Ref.get(latestRef), + report, + streamChanges: Stream.fromPubSub(changes), + }); +}); + +export const layer = Layer.effect( + HostPowerMonitor, + Effect.gen(function* () { + const desktopTelemetry = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; + const desktopSubscription = yield* desktopTelemetry.subscribe; + const initial = desktopSubscription.latest; + const monitor = yield* Option.match(initial, { + onNone: () => make(), + onSome: (snapshot) => make(snapshot.power), + }); + yield* desktopSubscription.changes.pipe( + Stream.map((snapshot) => snapshot.power), + Stream.runForEach(monitor.report), + Effect.forkScoped, + ); + return monitor; + }), +); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index be12b01c30a9..495ec422331a 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -41,7 +41,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { traceMinLevel: "Info", traceTimingEnabled: true, - traceBatchWindowMs: 200, + traceBatchWindowMs: 1_000, traceMaxBytes: 10 * 1024 * 1024, traceMaxFiles: 10, otlpTracesUrl: undefined, @@ -286,6 +286,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: baseDir, noBrowser: true, desktopBootstrapToken: "desktop-token", + desktopTelemetryFd: 4, + desktopTelemetryControlFd: 5, tailscaleServeEnabled: false, tailscaleServePort: 443, otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -341,12 +343,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: "desktop-token", + desktopTelemetryFd: 4, + desktopTelemetryControlFd: 5, + resourceMonitorPath: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, }); assert.equal(join(baseDir, "userdata"), resolved.stateDir); + assert.equal(resolved.desktopTelemetryFd, 4); + assert.equal(resolved.desktopTelemetryControlFd, 5); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 48ada8ce63b7..f605e7dc4f62 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -85,7 +85,7 @@ const EnvServerConfig = Config.all({ ), traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), - traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)), + traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)), otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -306,6 +306,9 @@ export const resolveServerConfig = ( () => mode === "desktop", ); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; + const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; + const resourceMonitorPath = bootstrap?.resourceMonitorPath; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), @@ -379,6 +382,9 @@ export const resolveServerConfig = ( noBrowser, startupPresentation, desktopBootstrapToken, + desktopTelemetryFd, + desktopTelemetryControlFd, + resourceMonitorPath, autoBootstrapProjectFromCwd, logWebSocketEvents, tailscaleServeEnabled, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 5a16e144ee46..e678264dde5f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -76,6 +76,9 @@ export class ServerConfig extends Context.Service< readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; + readonly desktopTelemetryFd?: number | undefined; + readonly desktopTelemetryControlFd?: number | undefined; + readonly resourceMonitorPath?: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; @@ -186,6 +189,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( port: 0, host: undefined, desktopBootstrapToken: undefined, + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, staticDir: undefined, devUrl, devAllowedOrigins: [], diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 7d16a11c829c..2efa3375d275 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -1,291 +1,293 @@ import { describe, expect, it } from "@effect/vitest"; +import type { + DesktopHostTelemetrySnapshot, + ResourceMonitorSnapshotEvent, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; +import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; +import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; +import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; -const encoder = new TextEncoder(); +function makeNativeSnapshot( + processes: ResourceMonitorSnapshotEvent["processes"], +): ResourceMonitorSnapshotEvent { + return { + version: 2, + type: "snapshot", + sequence: 1, + sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")), + collectionDurationMicros: 250, + scannedProcessCount: processes.length, + retainedProcessCount: processes.length, + inaccessibleProcessCount: 0, + processes, + }; +} -function mockHandle(result: { - readonly stdout?: string; - readonly stderr?: string; - readonly code?: number; -}) { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout ?? "")), - stderr: Stream.make(encoder.encode(result.stderr ?? "")), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, +function makeTelemetryLayer( + snapshot: ResourceMonitorSnapshotEvent, + desktopSnapshot?: DesktopHostTelemetrySnapshot, +) { + const nativeLayer = NativeTelemetryClient.layerTest({ + sampleNow: Effect.succeed({ generation: 0, snapshot }), + health: Effect.succeed({ + status: "healthy", + hello: Option.none(), + lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)), + lastError: Option.none(), + restartCount: 0, + sampleIntervalMs: 1_000, + }), }); + const desktopLayer = desktopSnapshot + ? DesktopTelemetryReceiver.layerTest({ + latest: Effect.succeedSome(desktopSnapshot), + health: Effect.succeed({ + status: "healthy", + lastSampleAt: Option.some(DateTime.makeUnsafe(desktopSnapshot.sampledAtUnixMs)), + lastError: Option.none(), + }), + }) + : DesktopTelemetryReceiver.layerTest(); + return ResourceTelemetry.layer.pipe( + Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), + ); } describe("ProcessDiagnostics", () => { - it.effect("parses POSIX ps rows with full commands", () => - Effect.sync(() => { - const rows = ProcessDiagnostics.parsePosixProcessRows( - [ - " 10 1 10 Ss 0.0 1024 01:02.03 /usr/bin/node server.js", - " 11 10 10 S+ 12.5 20480 00:04 codex app-server --config /tmp/one two", - ].join("\n"), - ); - - expect(rows).toEqual([ + it.effect("projects live process data from resource telemetry", () => + Effect.gen(function* () { + const snapshot = makeNativeSnapshot([ { - pid: 10, + pid: process.pid, ppid: 1, - pgid: 10, - status: "Ss", + startTimeMs: 1_000, + runTimeMs: 60_000, + name: "node", + command: "t3 server", + status: "Running", cpuPercent: 0, - rssBytes: 1024 * 1024, - elapsed: "01:02.03", - command: "/usr/bin/node server.js", + cpuTimeMs: 100, + residentBytes: 1_024, + virtualBytes: 2_048, + ioReadBytes: 100, + ioWriteBytes: 200, + ioSemantics: "storage", }, { - pid: 11, - ppid: 10, - pgid: 10, - status: "S+", - cpuPercent: 12.5, - rssBytes: 20480 * 1024, - elapsed: "00:04", - command: "codex app-server --config /tmp/one two", + pid: 4_242, + ppid: process.pid, + startTimeMs: 2_000, + runTimeMs: 4_000, + name: "agent", + command: "codex app-server", + status: "Running", + cpuPercent: 1.5, + cpuTimeMs: 60, + residentBytes: 2_048, + virtualBytes: 4_096, + ioReadBytes: 300, + ioWriteBytes: 400, + ioSemantics: "storage", }, ]); - }), - ); - - it.effect("aggregates only descendants of the server process", () => - Effect.sync(() => { - const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ - serverPid: 100, - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - rows: [ - { - pid: 100, - ppid: 1, - pgid: 100, - status: "S", - cpuPercent: 0, - rssBytes: 1_000, - elapsed: "01:00", - command: "t3 server", - }, - { - pid: 101, - ppid: 100, - pgid: 100, - status: "S", - cpuPercent: 1.5, - rssBytes: 2_000, - elapsed: "00:20", - command: "codex app-server", - }, - { - pid: 102, - ppid: 101, - pgid: 100, - status: "R", - cpuPercent: 3.25, - rssBytes: 4_000, - elapsed: "00:05", - command: "git status", - }, - { - pid: 200, - ppid: 1, - pgid: 200, - status: "S", - cpuPercent: 99, - rssBytes: 8_000, - elapsed: "00:01", - command: "unrelated", - }, - { - pid: 201, - ppid: 100, - pgid: 100, - status: "R", - cpuPercent: 9, - rssBytes: 9_000, - elapsed: "00:00", - command: "ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=", - }, - ], - }); + const telemetryLayer = makeTelemetryLayer(snapshot); + const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer)); - expect(diagnostics.serverPid).toBe(100); - expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z"); - expect(diagnostics.processCount).toBe(2); - expect(diagnostics.totalRssBytes).toBe(6_000); - expect(diagnostics.totalCpuPercent).toBe(4.75); - expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]); - expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]); - expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100); - expect(diagnostics.processes[0]?.childPids).toEqual([102]); - }), - ); + const diagnostics = yield* Effect.gen(function* () { + const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + return yield* processDiagnostics.read; + }).pipe(Effect.provide(layer)); - it.effect("preserves ascending sibling order for nested descendants", () => - Effect.sync(() => { - const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ - serverPid: 100, - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - rows: [ - { - pid: 101, - ppid: 100, - pgid: 100, - status: "S", - cpuPercent: 0, - rssBytes: 100, - elapsed: "00:10", - command: "agent", - }, - { - pid: 103, - ppid: 101, - pgid: 100, - status: "S", - cpuPercent: 0, - rssBytes: 100, - elapsed: "00:10", - command: "child-b", - }, - { - pid: 102, - ppid: 101, - pgid: 100, - status: "S", - cpuPercent: 0, - rssBytes: 100, - elapsed: "00:10", - command: "child-a", - }, - ], - }); - - expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]); + expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); + expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000); + expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5); + expect(diagnostics.processes[0]?.rssBytes).toBe(2_048); }), ); - it.effect("queries processes through the ChildProcessSpawner service", () => + it.effect("rejects stale process identities before signaling", () => Effect.gen(function* () { - const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = - []; - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const childProcess = command as unknown as { - readonly command: string; - readonly args: ReadonlyArray; - }; - commands.push({ command: childProcess.command, args: childProcess.args }); - return Effect.succeed( - mockHandle({ - stdout: [ - ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`, - ` 4242 ${process.pid} ${process.pid} S 1.5 2048 00:04 agent`, - ].join("\n"), - }), - ); - }), - ); - const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer)); + const snapshot = makeNativeSnapshot([]); + const telemetryLayer = makeTelemetryLayer(snapshot); + const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); - const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( - Effect.flatMap((pd) => pd.read), + const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( + Effect.flatMap((processDiagnostics) => + processDiagnostics.signal({ + pid: 4_242, + startTimeMs: 2_000, + signal: "SIGINT", + }), + ), Effect.provide(layer), ); - expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); - expect(commands).toEqual([ - { - command: "ps", - args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="], - }, - ]); + expect(result).toEqual({ + pid: 4242, + signal: "SIGINT", + signaled: false, + message: Option.some("Process 4242 no longer matches the selected process identity."), + }); }), ); - it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () => + it.effect("refuses to signal when a fresh identity check cannot be collected", () => Effect.gen(function* () { - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - mockHandle({ - code: 17, - stdout: "partial process output", - stderr: "process access denied", + const snapshot = makeNativeSnapshot([ + { + pid: 4_242, + ppid: process.pid, + startTimeMs: 2_000, + runTimeMs: 4_000, + name: "agent", + command: "codex app-server", + status: "Running", + cpuPercent: 1.5, + cpuTimeMs: 60, + residentBytes: 2_048, + virtualBytes: 4_096, + ioReadBytes: 300, + ioWriteBytes: 400, + ioSemantics: "storage", + }, + ]); + const staleTelemetry = yield* Effect.service(ResourceTelemetry.ResourceTelemetry).pipe( + Effect.flatMap((telemetry) => telemetry.latest), + Effect.provide(makeTelemetryLayer(snapshot)), + ); + const telemetryLayer = Layer.succeed( + ResourceTelemetry.ResourceTelemetry, + ResourceTelemetry.ResourceTelemetry.of({ + latest: Effect.succeed(staleTelemetry), + changes: Stream.empty, + subscribe: Effect.die("unused"), + readHistory: () => Effect.die("unused"), + refresh: Effect.fail( + new ResourceTelemetry.ResourceTelemetryRefreshFailed({ + operation: "refresh", + cause: new Error("collector unavailable"), }), ), - ), + validateProcessIdentity: () => Effect.die("unused"), + retry: Effect.die("unused"), + }), ); + const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); - const error = yield* ProcessDiagnostics.readProcessRows.pipe( - Effect.provide(spawnerLayer), - Effect.provideService(HostProcessPlatform, "linux"), - Effect.flip, + const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( + Effect.flatMap((processDiagnostics) => + processDiagnostics.signal({ + pid: 4_242, + startTimeMs: 2_000, + signal: "SIGINT", + }), + ), + Effect.provide(layer), ); - expect(error).toMatchObject({ - _tag: "ProcessDiagnosticsQueryFailedError", - command: "ps", - argCount: 2, - cwd: process.cwd(), - exitCode: 17, - stdoutBytes: 22, - stderrBytes: 21, - stdoutTruncated: false, - stderrTruncated: false, + expect(result).toEqual({ + pid: 4_242, + signal: "SIGINT", + signaled: false, + message: Option.some( + "Could not refresh process 4242; refusing to signal a stale identity.", + ), }); - expect(error.message).toBe( - `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, - ); }), ); - it.effect("does not allow signaling the diagnostics query process", () => + it.effect("rejects Electron processes as signal targets", () => Effect.gen(function* () { - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - mockHandle({ - stdout: [ - ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`, - ` 4242 ${process.pid} ${process.pid} R 1.5 2048 00:00 ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=`, - ].join("\n"), - }), - ), - ), + const sampledAtUnixMs = DateTime.toEpochMillis( + DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), ); - const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer)); + const snapshot = makeNativeSnapshot([ + { + pid: 4_242, + ppid: 1, + startTimeMs: 2_000, + runTimeMs: 4_000, + name: "electron", + command: "electron", + status: "Running", + cpuPercent: 1.5, + cpuTimeMs: 60, + residentBytes: 2_048, + virtualBytes: 4_096, + ioReadBytes: 300, + ioWriteBytes: 400, + ioSemantics: "storage", + }, + ]); + const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); + const telemetryLayer = makeTelemetryLayer(snapshot, { + version: 1, + type: "desktopTelemetry", + sequence: 1, + sampledAtUnixMs, + electronPid: 4_242, + power: { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "false", + lowPowerMode: "unknown", + thermalState: "nominal", + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: Option.none(), + electronProcesses: [ + { + pid: 4_242, + creationTimeMs: 2_000, + type: "Browser", + name: "electron", + cpuPercent: 1.5, + idleWakeupsPerSecond: 0, + workingSetBytes: 2_048, + peakWorkingSetBytes: 2_048, + }, + ], + }); + const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( - Effect.flatMap((pd) => pd.signal({ pid: 4242, signal: "SIGINT" })), + Effect.flatMap((processDiagnostics) => + processDiagnostics.signal({ + pid: 4_242, + startTimeMs: 2_000, + signal: "SIGKILL", + }), + ), Effect.provide(layer), ); expect(result).toEqual({ - pid: 4242, - signal: "SIGINT", + pid: 4_242, + signal: "SIGKILL", signaled: false, - message: Option.some("Process 4242 is not a live descendant of the T3 server."), + message: Option.some("Process 4242 is not a signalable T3 backend descendant."), }); + + const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( + Effect.flatMap((processDiagnostics) => processDiagnostics.read), + Effect.provide(layer), + ); + expect(diagnostics.processes).toEqual([]); + expect(diagnostics.processCount).toBe(0); + expect(diagnostics.totalCpuPercent).toBe(0); + expect(diagnostics.totalRssBytes).toBe(0); }), ); }); diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index b39d560a2280..8aeb7ba24715 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -1,105 +1,20 @@ import type { + ResourceTelemetryProcessCategory, ServerProcessDiagnosticsEntry, ServerProcessDiagnosticsResult, ServerProcessSignal, ServerSignalProcessResult, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as ChildProcess from "effect/unstable/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; -export interface ProcessRow { - readonly pid: number; - readonly ppid: number; - readonly pgid: number | null; - readonly status: string; - readonly cpuPercent: number; - readonly rssBytes: number; - readonly elapsed: string; - readonly command: string; -} - -const PROCESS_QUERY_TIMEOUT_MS = 1_000; -const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; -const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; - -export class ProcessDiagnostics extends Context.Service< - ProcessDiagnostics, - { - readonly read: Effect.Effect; - readonly signal: (input: { - readonly pid: number; - readonly signal: ServerProcessSignal; - }) => Effect.Effect; - } ->()("t3/diagnostics/ProcessDiagnostics") {} - -class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass()( - "ProcessDiagnosticsQueryTimeoutError", - { - command: Schema.String, - argCount: Schema.Number, - cwd: Schema.String, - timeoutMillis: Schema.Number, - }, -) { - override get message(): string { - return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`; - } -} - -class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass()( - "ProcessDiagnosticsQueryFailedError", - { - command: Schema.String, - argCount: Schema.Number, - cwd: Schema.String, - exitCode: Schema.optional(Schema.Number), - stdoutBytes: Schema.optional(Schema.Number), - stderrBytes: Schema.optional(Schema.Number), - stdoutTruncated: Schema.optional(Schema.Boolean), - stderrTruncated: Schema.optional(Schema.Boolean), - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`; - return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`; - } -} - -class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass()( - "ProcessDiagnosticsServerProcessSignalError", - { pid: Schema.Number }, -) { - override get message(): string { - return "Refusing to signal the T3 server process."; - } -} - -class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass()( - "ProcessDiagnosticsNotDescendantError", - { - pid: Schema.Number, - serverPid: Schema.Number, - }, -) { - override get message(): string { - return `Process ${this.pid} is not a live descendant of the T3 server.`; - } -} - -class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass()( - "ProcessDiagnosticsSignalFailedError", +export class ProcessSignalFailed extends Schema.TaggedErrorClass()( + "ProcessSignalFailed", { pid: Schema.Number, signal: Schema.String, @@ -111,458 +26,136 @@ class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass 0 ? parsed : null; -} - -function parseNonNegativeInt(value: string): number | null { - const parsed = Number.parseInt(value, 10); - return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; -} - -function parseNumber(value: string): number | null { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : null; -} - -export function parsePosixProcessRows(output: string): ReadonlyArray { - const rows: ProcessRow[] = []; - const rowPattern = - /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+([+-]?(?:\d+\.?\d*|\.\d+))\s+(\d+)\s+(\S+)\s+(.+)$/; - - for (const line of output.split(/\r?\n/)) { - if (line.trim().length === 0) continue; - - const match = rowPattern.exec(line); - if (!match) continue; - - const pidText = match[1]; - const ppidText = match[2]; - const pgidText = match[3]; - const status = match[4]; - const cpuText = match[5]; - const rssText = match[6]; - const elapsed = match[7]; - const command = match[8]; - if ( - pidText === undefined || - ppidText === undefined || - pgidText === undefined || - status === undefined || - cpuText === undefined || - rssText === undefined || - elapsed === undefined || - command === undefined - ) { - continue; - } - - const pid = parsePositiveInt(pidText); - const ppid = parseNonNegativeInt(ppidText); - const pgid = Number.parseInt(pgidText, 10); - const cpuPercent = parseNumber(cpuText); - const rssKiB = parseNonNegativeInt(rssText); - if ( - pid === null || - ppid === null || - !Number.isInteger(pgid) || - cpuPercent === null || - rssKiB === null || - !status || - !elapsed || - !command - ) { - continue; - } - - rows.push({ - pid, - ppid, - pgid, - status, - cpuPercent, - rssBytes: rssKiB * 1024, - elapsed, - command, - }); - } - - return rows; -} - -function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record; - const pid = typeof record.ProcessId === "number" ? record.ProcessId : null; - const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null; - const commandLine = - typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0 - ? record.CommandLine - : typeof record.Name === "string" - ? record.Name - : null; - const workingSet = - typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) - ? Math.max(0, Math.round(record.WorkingSetSize)) - : 0; - const cpuPercent = - typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) - ? Math.max(0, record.PercentProcessorTime) - : 0; - - if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; - return { - pid, - ppid, - pgid: null, - status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", - cpuPercent, - rssBytes: workingSet, - elapsed: "", - command: commandLine, - }; -} - -function parseWindowsProcessRows(output: string): ReadonlyArray { - if (output.trim().length === 0) return []; - try { - const parsed = JSON.parse(output) as unknown; - const records = Array.isArray(parsed) ? parsed : [parsed]; - return records.flatMap((record) => { - const row = normalizeWindowsProcessRow(record); - return row ? [row] : []; - }); - } catch { - return []; - } -} - -export function buildDescendantEntries( - rows: ReadonlyArray, - serverPid: number, -): ReadonlyArray { - const childrenByParent = new Map(); - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? []; - children.push(row); - childrenByParent.set(row.ppid, children); - } - - const entries: ServerProcessDiagnosticsEntry[] = []; - const visited = new Set(); - const stack = [...(childrenByParent.get(serverPid) ?? [])] - .toSorted((left, right) => left.pid - right.pid) - .map((row) => ({ row, depth: 0 })); - - while (stack.length > 0) { - const item = stack.shift(); - if (!item || visited.has(item.row.pid)) continue; - visited.add(item.row.pid); - - const children = [...(childrenByParent.get(item.row.pid) ?? [])].toSorted( - (left, right) => left.pid - right.pid, - ); - entries.push({ - pid: item.row.pid, - ppid: item.row.ppid, - pgid: Option.fromNullishOr(item.row.pgid), - status: item.row.status, - cpuPercent: item.row.cpuPercent, - rssBytes: item.row.rssBytes, - elapsed: item.row.elapsed || "n/a", - command: item.row.command, - depth: item.depth, - childPids: children.map((child) => child.pid), - }); - - stack.unshift(...children.map((row) => ({ row, depth: item.depth + 1 }))); +export class ProcessDiagnostics extends Context.Service< + ProcessDiagnostics, + { + readonly read: Effect.Effect; + readonly signal: (input: { + readonly pid: number; + readonly startTimeMs: number; + readonly signal: ServerProcessSignal; + }) => Effect.Effect; } +>()("t3/diagnostics/ProcessDiagnostics") {} - return entries; +function formatElapsed(runTimeMs: number): string { + const totalSeconds = Math.max(0, Math.floor(runTimeMs / 1_000)); + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` + : `${minutes}:${String(seconds).padStart(2, "0")}`; } -export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean { - if (row.ppid !== serverPid) return false; - - const command = row.command.trim(); +function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean { return ( - /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) || - (/\bpowershell(?:\.exe)?\b/i.test(command) && - /\bGet-CimInstance\s+Win32_Process\b/i.test(command)) + category === "server-child" || category === "provider-root" || category === "terminal-root" ); } -function makeResult(input: { - readonly serverPid: number; - readonly rows: ReadonlyArray; - readonly readAt: DateTime.Utc; - readonly error?: string; -}): ServerProcessDiagnosticsResult { - const readAt = input.readAt; - const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); - const processes = buildDescendantEntries(rows, input.serverPid); - const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0); - const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0); - - return { - serverPid: input.serverPid, - readAt, - processCount: processes.length, - totalRssBytes, - totalCpuPercent, - processes, - error: input.error ? Option.some({ message: input.error }) : Option.none(), - }; -} - -interface ProcessOutput { - readonly cwd: string; - readonly exitCode: number; - readonly stdout: string; - readonly stdoutBytes: number; - readonly stdoutTruncated: boolean; - readonly stderr: string; - readonly stderrBytes: number; - readonly stderrTruncated: boolean; -} - -const runProcess = Effect.fn("runProcess")(function* (input: { - readonly command: string; - readonly args: ReadonlyArray; -}) { - const cwd = process.cwd(); - return yield* Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // `ps` and `powershell.exe` are real executables; spawning through cmd.exe - // shell mode would re-tokenize the PowerShell `-Command` payload (which - // contains pipes) before PowerShell ever sees it. - const child = yield* spawner.spawn( - ChildProcess.make(input.command, input.args, { - cwd, - }), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectUint8StreamText({ - stream: child.stdout, - maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, - truncatedMarker: "\n\n[truncated]", - }), - collectUint8StreamText({ - stream: child.stderr, - maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, - truncatedMarker: "\n\n[truncated]", - }), - child.exitCode, - ], - { concurrency: "unbounded" }, - ); - - return { - cwd, - exitCode, - stdout: stdout.text, - stdoutBytes: stdout.bytes, - stdoutTruncated: stdout.truncated, - stderr: stderr.text, - stderrBytes: stderr.bytes, - stderrTruncated: stderr.truncated, - } satisfies ProcessOutput; - }).pipe( - Effect.scoped, - Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new ProcessDiagnosticsQueryTimeoutError({ - command: input.command, - argCount: input.args.length, - cwd, - timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.mapError((cause) => - isProcessDiagnosticsError(cause) - ? cause - : new ProcessDiagnosticsQueryFailedError({ - command: input.command, - argCount: input.args.length, - cwd, - cause, +export const make = Effect.fn("makeProcessDiagnostics")(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + const refreshedTelemetry = telemetry.refresh.pipe(Effect.catch(() => telemetry.latest)); + const read: ProcessDiagnostics["Service"]["read"] = refreshedTelemetry.pipe( + Effect.map((snapshot) => { + const processes = snapshot.processes + .filter((entry) => canSignalCategory(entry.category)) + .map( + (entry): ServerProcessDiagnosticsEntry => ({ + pid: entry.identity.pid, + startTimeMs: entry.identity.startTimeMs, + ppid: entry.ppid, + pgid: Option.none(), + status: entry.status || "Unknown", + cpuPercent: entry.cpuPercent, + rssBytes: entry.residentBytes, + elapsed: formatElapsed(entry.runTimeMs), + command: entry.command || entry.name || "unknown", + depth: Math.max(0, entry.depth - 1), + childPids: entry.childPids, }), - ), - ); -}); - -function readPosixProcessRows(): Effect.Effect< - ReadonlyArray, - ProcessDiagnosticsError, - ChildProcessSpawner.ChildProcessSpawner -> { - return runProcess({ - command: "ps", - args: ["-axo", POSIX_PROCESS_QUERY_COMMAND], - }).pipe( - Effect.flatMap((result) => - result.exitCode !== 0 - ? Effect.fail( - new ProcessDiagnosticsQueryFailedError({ - command: "ps", - argCount: 2, - cwd: result.cwd, - exitCode: result.exitCode, - stdoutBytes: result.stdoutBytes, - stderrBytes: result.stderrBytes, - stdoutTruncated: result.stdoutTruncated, - stderrTruncated: result.stderrTruncated, - }), - ) - : Effect.succeed(parsePosixProcessRows(result.stdout)), - ), - ); -} - -function readWindowsProcessRows(): Effect.Effect< - ReadonlyArray, - ProcessDiagnosticsError, - ChildProcessSpawner.ChildProcessSpawner -> { - const command = [ - "$processes = Get-CimInstance Win32_Process | ForEach-Object {", - '$perf = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess = $($_.ProcessId)" -ErrorAction SilentlyContinue;', - "[pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; Name = $_.Name; CommandLine = $_.CommandLine; Status = $_.Status; WorkingSetSize = $_.WorkingSetSize; PercentProcessorTime = if ($perf) { $perf.PercentProcessorTime } else { 0 } }", - "};", - "$processes | ConvertTo-Json -Compress -Depth 3", - ].join(" "); - - return runProcess({ - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - }).pipe( - Effect.flatMap((result) => - result.exitCode !== 0 - ? Effect.fail( - new ProcessDiagnosticsQueryFailedError({ - command: "powershell.exe", - argCount: 4, - cwd: result.cwd, - exitCode: result.exitCode, - stdoutBytes: result.stdoutBytes, - stderrBytes: result.stderrBytes, - stdoutTruncated: result.stdoutTruncated, - stderrTruncated: result.stderrTruncated, - }), - ) - : Effect.succeed(parseWindowsProcessRows(result.stdout)), - ), - ); -} - -export const readProcessRows = Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); -}); - -export function aggregateProcessDiagnostics(input: { - readonly serverPid: number; - readonly rows: ReadonlyArray; - readonly readAt: DateTime.Utc; -}): ServerProcessDiagnosticsResult { - return makeResult(input); -} - -function assertDescendantPid( - pid: number, -): Effect.Effect { - if (pid === process.pid) { - return Effect.fail( - new ProcessDiagnosticsServerProcessSignalError({ - pid, - }), - ); - } - - return readProcessRows.pipe( - Effect.flatMap((rows) => { - const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); - const descendant = buildDescendantEntries(filteredRows, process.pid).some( - (entry) => entry.pid === pid, - ); - return descendant - ? Effect.void - : Effect.fail( - new ProcessDiagnosticsNotDescendantError({ - pid, - serverPid: process.pid, - }), - ); + ); + return { + serverPid: process.pid, + readAt: snapshot.readAt, + processCount: processes.length, + totalRssBytes: processes.reduce((total, entry) => total + entry.rssBytes, 0), + totalCpuPercent: processes.reduce((total, entry) => total + entry.cpuPercent, 0), + processes, + error: Option.map(snapshot.health.native.lastError, (message) => ({ message })), + }; }), ); -} - -export const make = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const read: ProcessDiagnostics["Service"]["read"] = Effect.gen(function* () { - const readAt = yield* DateTime.now; - const rows = yield* readProcessRows.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - return makeResult({ serverPid: process.pid, rows, readAt }); - }).pipe( - Effect.catch((error: ProcessDiagnosticsError) => - DateTime.now.pipe( - Effect.map((readAt) => - makeResult({ serverPid: process.pid, rows: [], readAt, error: error.message }), - ), - ), - ), - ); const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")( function* (input) { - return yield* assertDescendantPid(input.pid).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.flatMap(() => - Effect.try({ - try: () => { - process.kill(input.pid, input.signal); - return { - pid: input.pid, - signal: input.signal, - signaled: true, - message: Option.none(), - }; - }, - catch: (cause) => - new ProcessDiagnosticsSignalFailedError({ - pid: input.pid, - signal: input.signal, - cause, - }), + if (input.pid === process.pid) { + return { + pid: input.pid, + signal: input.signal, + signaled: false, + message: Option.some("Refusing to signal the T3 server process."), + }; + } + const current = yield* telemetry.refresh.pipe(Effect.option); + if (Option.isNone(current)) { + return { + pid: input.pid, + signal: input.signal, + signaled: false, + message: Option.some( + `Could not refresh process ${input.pid}; refusing to signal a stale identity.`, + ), + }; + } + const selected = current.value.processes.find( + (entry) => + entry.identity.pid === input.pid && entry.identity.startTimeMs === input.startTimeMs, + ); + if (!selected) { + return { + pid: input.pid, + signal: input.signal, + signaled: false, + message: Option.some( + `Process ${input.pid} no longer matches the selected process identity.`, + ), + }; + } + if (!canSignalCategory(selected.category)) { + return { + pid: input.pid, + signal: input.signal, + signaled: false, + message: Option.some(`Process ${input.pid} is not a signalable T3 backend descendant.`), + }; + } + return yield* Effect.try({ + try: () => { + process.kill(input.pid, input.signal); + return { + pid: input.pid, + signal: input.signal, + signaled: true, + message: Option.none(), + }; + }, + catch: (cause) => + new ProcessSignalFailed({ + pid: input.pid, + signal: input.signal, + cause, }), - ), - Effect.catch((error: ProcessDiagnosticsError) => + }).pipe( + Effect.catch((error) => Effect.succeed({ pid: input.pid, signal: input.signal, signaled: false, - message: Option.some(error.message), + message: Option.some( + error instanceof Error ? error.message : "Failed to signal process.", + ), }), ), ); @@ -572,4 +165,4 @@ export const make = Effect.gen(function* () { return ProcessDiagnostics.of({ read, signal }); }); -export const layer = Layer.effect(ProcessDiagnostics, make); +export const layer = Layer.effect(ProcessDiagnostics, make()); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts index d9c4eb06ef18..1a1943ecf469 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -1,255 +1,170 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; +import type { ResourceTelemetryHistoryWithLegacyBuckets } from "../resourceTelemetry/ResourceTelemetryHistory.ts"; import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; describe("ProcessResourceMonitor", () => { - it.effect("samples the server root process and descendants", () => - Effect.sync(() => { - const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const samples = ProcessResourceMonitor.collectMonitoredSamples({ - serverPid: 100, - sampledAt, - sampledAtMs: DateTime.toEpochMillis(sampledAt), - rows: [ - { - pid: 100, - ppid: 1, - pgid: 100, - status: "S", - cpuPercent: 2, - rssBytes: 1_000, - elapsed: "01:00", - command: "t3 server", - }, + it.effect("projects resource telemetry history into the legacy diagnostics contract", () => + Effect.gen(function* () { + const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + const history: ResourceTelemetryHistoryWithLegacyBuckets = { + readAt, + windowMs: 60_000, + bucketMs: 10_000, + sampleIntervalMs: 1_000, + retainedSampleCount: 2, + buckets: [ { - pid: 101, - ppid: 100, - pgid: 100, - status: "S", - cpuPercent: 10, - rssBytes: 2_000, - elapsed: "00:20", - command: "codex app-server", + startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), + endedAt: readAt, + avgCpuPercent: 15, + maxCpuPercent: 25, + maxRssBytes: 4_096, + ioReadBytes: 1_024, + ioWriteBytes: 2_048, + maxProcessCount: 2, }, + ], + legacyBackendBuckets: [ { - pid: 102, - ppid: 101, - pgid: 100, - status: "R", - cpuPercent: 50, - rssBytes: 3_000, - elapsed: "00:05", - command: "rg needle", + startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), + endedAt: readAt, + avgCpuPercent: 5, + maxCpuPercent: 8, + maxRssBytes: 4_096, + ioReadBytes: 1_024, + ioWriteBytes: 2_048, + maxProcessCount: 1, }, + ], + topProcesses: [ { - pid: 200, + identity: { pid: process.pid, startTimeMs: 100 }, ppid: 1, - pgid: 200, - status: "R", - cpuPercent: 99, - rssBytes: 9_000, - elapsed: "00:05", - command: "unrelated", + depth: 0, + name: "node", + command: "t3 server", + category: "server", + firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), + lastSeenAt: readAt, + currentCpuPercent: 5, + avgCpuPercent: 4, + maxCpuPercent: 8, + cpuTimeMs: 1_500, + currentRssBytes: 2_048, + peakRssBytes: 4_096, + ioReadBytes: 1_024, + ioWriteBytes: 2_048, + ioSemantics: "storage", + sampleCount: 2, }, - ], - }); - - expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]); - expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]); - expect(samples[0]?.isServerRoot).toBe(true); - expect(samples[1]?.isServerRoot).toBe(false); - }), - ); - - it.effect("rolls samples up by process and CPU time", () => - Effect.sync(() => { - const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z"); - const samples = [ - ...ProcessResourceMonitor.collectMonitoredSamples({ - serverPid: 100, - sampledAt: firstAt, - sampledAtMs: DateTime.toEpochMillis(firstAt), - rows: [ - { - pid: 100, - ppid: 1, - pgid: 100, - status: "S", - cpuPercent: 10, - rssBytes: 1_000, - elapsed: "01:00", - command: "t3 server", - }, - ], - }), - ...ProcessResourceMonitor.collectMonitoredSamples({ - serverPid: 100, - sampledAt: secondAt, - sampledAtMs: DateTime.toEpochMillis(secondAt), - rows: [ - { - pid: 100, - ppid: 1, - pgid: 100, - status: "S", - cpuPercent: 30, - rssBytes: 2_000, - elapsed: "01:05", - command: "t3 server", - }, - ], - }), - ]; - - const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ - samples, - readAt: secondAt, - readAtMs: DateTime.toEpochMillis(secondAt), - windowMs: 60_000, - bucketMs: 10_000, - lastFailure: null, - }); - - expect(Option.isNone(result.error)).toBe(true); - expect(result.topProcesses).toHaveLength(1); - expect(result.topProcesses[0]?.avgCpuPercent).toBe(20); - expect(result.topProcesses[0]?.maxCpuPercent).toBe(30); - expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2); - expect(result.totalCpuSecondsApprox).toBe(2); - expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true); - }), - ); - - it.effect("keeps a process grouped when elapsed time drifts between samples", () => - Effect.sync(() => { - const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z"); - const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z"); - const samples = [ - ...ProcessResourceMonitor.collectMonitoredSamples({ - serverPid: 100, - sampledAt: firstAt, - sampledAtMs: DateTime.toEpochMillis(firstAt), - rows: [ - { - pid: 100, - ppid: 1, - pgid: 100, - status: "S", - cpuPercent: 1, - rssBytes: 1_000, - elapsed: "01:00", - command: "t3 server", - }, - ], - }), - ...ProcessResourceMonitor.collectMonitoredSamples({ - serverPid: 100, - sampledAt: secondAt, - sampledAtMs: DateTime.toEpochMillis(secondAt), - rows: [ - { - pid: 100, - ppid: 1, - pgid: 100, - status: "S", - cpuPercent: 2, - rssBytes: 2_000, - elapsed: "01:06", - command: "t3 server", - }, - ], - }), - ]; - - const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ - samples, - readAt: secondAt, - readAtMs: DateTime.toEpochMillis(secondAt), - windowMs: 60_000, - bucketMs: 10_000, - lastFailure: null, - }); - - expect(result.topProcesses).toHaveLength(1); - expect(result.topProcesses[0]?.isServerRoot).toBe(true); - expect(result.topProcesses[0]?.sampleCount).toBe(2); - expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000); - }), - ); - - it.effect("returns all process summaries in the selected window", () => - Effect.sync(() => { - const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const samples = ProcessResourceMonitor.collectMonitoredSamples({ - serverPid: 100, - sampledAt, - sampledAtMs: DateTime.toEpochMillis(sampledAt), - rows: [ { - pid: 100, + identity: { pid: 5_000, startTimeMs: 200 }, ppid: 1, - pgid: 100, - status: "S", - cpuPercent: 1, - rssBytes: 1_000, - elapsed: "01:00", - command: "t3 server", + depth: 0, + name: "electron", + command: "electron", + category: "electron-main", + firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), + lastSeenAt: readAt, + currentCpuPercent: 50, + avgCpuPercent: 40, + maxCpuPercent: 80, + cpuTimeMs: 15_000, + currentRssBytes: 20_480, + peakRssBytes: 40_960, + ioReadBytes: 10_240, + ioWriteBytes: 20_480, + ioSemantics: "storage", + sampleCount: 2, }, - ...Array.from({ length: 35 }, (_, index) => ({ - pid: 200 + index, - ppid: index === 0 ? 100 : 199 + index, - pgid: 100, - status: "S", - cpuPercent: 35 - index, - rssBytes: 2_000 + index, - elapsed: "00:10", - command: `worker ${index}`, - })), ], - }); - - const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ - samples, - readAt: sampledAt, - readAtMs: DateTime.toEpochMillis(sampledAt), - windowMs: 60_000, - bucketMs: 10_000, - lastFailure: null, - }); - - expect(result.topProcesses).toHaveLength(36); - expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); - }), - ); - - it.effect("exposes bounded failure diagnostics while retaining the exact cause", () => - Effect.sync(() => { - const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const cause = new Error("stderr included credential=secret-value"); - const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({ - failureTag: "ProcessDiagnosticsQueryFailedError", - cause, - }); + health: { + native: { + status: "degraded", + lastSampleAt: Option.some(readAt), + lastError: Option.some("collector stalled"), + }, + desktop: { + status: "healthy", + lastSampleAt: Option.some(readAt), + lastError: Option.none(), + }, + sidecarVersion: Option.some("0.1.0"), + sidecarPid: Option.some(9_000), + restartCount: 1, + collectionDurationMicros: 250, + scannedProcessCount: 80, + retainedProcessCount: 2, + inaccessibleProcessCount: 0, + }, + }; + const telemetry: ResourceTelemetry.ResourceTelemetry["Service"] = { + latest: Effect.die("unused"), + changes: Stream.empty, + subscribe: Effect.die("unused"), + readHistory: () => Effect.succeed(history), + refresh: Effect.die("unused"), + validateProcessIdentity: () => Effect.die("unused"), + retry: Effect.die("unused"), + }; + const layer = ProcessResourceMonitor.layer.pipe( + Layer.provide( + Layer.succeed( + ResourceTelemetry.ResourceTelemetry, + ResourceTelemetry.ResourceTelemetry.of(telemetry), + ), + ), + ); - const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ - samples: [], - readAt, - readAtMs: DateTime.toEpochMillis(readAt), - windowMs: 60_000, - bucketMs: 10_000, - lastFailure: failure, - }); + const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe( + Effect.flatMap((monitor) => + monitor.readHistory({ + windowMs: 60_000, + bucketMs: 10_000, + }), + ), + Effect.provide(layer), + ); - expect(failure.cause).toBe(cause); - expect(Option.getOrThrow(result.error)).toEqual({ - failureTag: "ProcessDiagnosticsQueryFailedError", - message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).", + expect(result.totalCpuSecondsApprox).toBe(1.5); + expect(result.topProcesses).toEqual([ + { + processKey: `${process.pid}:100`, + pid: process.pid, + ppid: 1, + command: "t3 server", + depth: 0, + isServerRoot: true, + firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), + lastSeenAt: readAt, + currentCpuPercent: 5, + avgCpuPercent: 4, + maxCpuPercent: 8, + cpuSecondsApprox: 1.5, + currentRssBytes: 2_048, + maxRssBytes: 4_096, + sampleCount: 2, + }, + ]); + expect(result.buckets[0]).toMatchObject({ + avgCpuPercent: 5, + maxCpuPercent: 8, + maxRssBytes: 4_096, + maxProcessCount: 1, }); - expect(Option.getOrThrow(result.error).message).not.toContain("secret-value"); + expect(result.error).toEqual( + Option.some({ + failureTag: "ProcessDiagnosticsQueryFailedError", + message: "collector stalled", + }), + ); }), ); }); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index 6030e4172e1d..5f5e32dd28da 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -1,55 +1,14 @@ -import { - ServerProcessResourceHistoryFailureTag, - type ServerProcessResourceHistoryBucket, - type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType, - type ServerProcessResourceHistoryInput, - type ServerProcessResourceHistoryResult, - type ServerProcessResourceHistorySummary, +import type { + ResourceTelemetryProcessCategory, + ServerProcessResourceHistoryInput, + ServerProcessResourceHistoryResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; - -const SAMPLE_INTERVAL_MS = 5_000; -const RETENTION_MS = 60 * 60_000; -const MAX_RETAINED_SAMPLES = 20_000; - -export interface ProcessResourceSample { - readonly sampledAt: DateTime.Utc; - readonly sampledAtMs: number; - readonly processKey: string; - readonly pid: number; - readonly ppid: number; - readonly command: string; - readonly cpuPercent: number; - readonly rssBytes: number; - readonly depth: number; - readonly isServerRoot: boolean; -} - -export class ProcessResourceSamplingError extends Schema.TaggedErrorClass()( - "ProcessResourceSamplingError", - { - failureTag: ServerProcessResourceHistoryFailureTag, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to sample process resources (${this.failureTag}).`; - } -} - -interface MonitorState { - readonly samples: ReadonlyArray; - readonly lastFailure: ProcessResourceSamplingError | null; -} +import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; export class ProcessResourceMonitor extends Context.Service< ProcessResourceMonitor, @@ -60,270 +19,69 @@ export class ProcessResourceMonitor extends Context.Service< } >()("t3/diagnostics/ProcessResourceMonitor") {} -function dateTimeFromMillis(ms: number): DateTime.Utc { - return DateTime.makeUnsafe(ms); -} - -function sampleKey(row: Pick): string { - return `${row.pid}:${row.command}`; -} - -function findServerRootRow( - rows: ReadonlyArray, - serverPid: number, -): ProcessDiagnostics.ProcessRow | null { - return rows.find((row) => row.pid === serverPid) ?? null; -} - -export function collectMonitoredSamples(input: { - readonly rows: ReadonlyArray; - readonly serverPid: number; - readonly sampledAt: DateTime.Utc; - readonly sampledAtMs: number; -}): ReadonlyArray { - const rows = input.rows.filter( - (row) => !ProcessDiagnostics.isDiagnosticsQueryProcess(row, input.serverPid), +function isLegacyBackendCategory(category: ResourceTelemetryProcessCategory): boolean { + return ( + category === "server" || + category === "server-child" || + category === "provider-root" || + category === "terminal-root" ); - const root = findServerRootRow(rows, input.serverPid); - const descendants = ProcessDiagnostics.buildDescendantEntries(rows, input.serverPid); - const samples: ProcessResourceSample[] = []; - - if (root) { - samples.push({ - sampledAt: input.sampledAt, - sampledAtMs: input.sampledAtMs, - processKey: sampleKey(root), - pid: root.pid, - ppid: root.ppid, - command: root.command, - cpuPercent: root.cpuPercent, - rssBytes: root.rssBytes, - depth: 0, - isServerRoot: true, - }); - } - - for (const process of descendants) { - samples.push({ - sampledAt: input.sampledAt, - sampledAtMs: input.sampledAtMs, - processKey: sampleKey(process), - pid: process.pid, - ppid: process.ppid, - command: process.command, - cpuPercent: process.cpuPercent, - rssBytes: process.rssBytes, - depth: process.depth + 1, - isServerRoot: false, - }); - } - - return samples; -} - -function trimSamples( - samples: ReadonlyArray, - nowMs: number, -): ReadonlyArray { - const minSampledAtMs = nowMs - RETENTION_MS; - const retained = samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); - return retained.length <= MAX_RETAINED_SAMPLES - ? retained - : retained.slice(retained.length - MAX_RETAINED_SAMPLES); } -function summarizeProcesses( - samples: ReadonlyArray, -): ReadonlyArray { - const groups = new Map(); - for (const sample of samples) { - const processSamples = groups.get(sample.processKey) ?? []; - processSamples.push(sample); - groups.set(sample.processKey, processSamples); - } - - return [...groups.entries()] - .map(([processKey, processSamples]) => { - const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); - const first = sorted[0]!; - const latest = sorted[sorted.length - 1]!; - const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0); - const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent)); - const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes)); - const cpuSecondsApprox = sorted.reduce( - (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), - 0, - ); - - return { - processKey, - pid: latest.pid, - ppid: latest.ppid, - command: latest.command, - depth: latest.depth, - isServerRoot: latest.isServerRoot, - firstSeenAt: first.sampledAt, - lastSeenAt: latest.sampledAt, - currentCpuPercent: latest.cpuPercent, - avgCpuPercent: cpuPercentTotal / sorted.length, - maxCpuPercent, - cpuSecondsApprox, - currentRssBytes: latest.rssBytes, - maxRssBytes, - sampleCount: sorted.length, - } satisfies ServerProcessResourceHistorySummary; - }) - .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox); -} - -function buildBuckets(input: { - readonly samples: ReadonlyArray; - readonly nowMs: number; - readonly windowMs: number; - readonly bucketMs: number; -}): ReadonlyArray { - const bucketMs = Math.max(1_000, input.bucketMs); - const windowStartMs = input.nowMs - input.windowMs; - const buckets: ServerProcessResourceHistoryBucket[] = []; - - for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += bucketMs) { - const endedAtMs = Math.min(input.nowMs, startedAtMs + bucketMs); - const bucketSamples = input.samples.filter( - (sample) => - sample.sampledAtMs >= startedAtMs && - (endedAtMs === input.nowMs - ? sample.sampledAtMs <= endedAtMs - : sample.sampledAtMs < endedAtMs), - ); - const samplesByRead = new Map(); - for (const sample of bucketSamples) { - const samplesAtTime = samplesByRead.get(sample.sampledAtMs) ?? []; - samplesAtTime.push(sample); - samplesByRead.set(sample.sampledAtMs, samplesAtTime); - } - - const readTotals = [...samplesByRead.values()].map((samplesAtTime) => ({ - cpuPercent: samplesAtTime.reduce((total, sample) => total + sample.cpuPercent, 0), - rssBytes: samplesAtTime.reduce((total, sample) => total + sample.rssBytes, 0), - processCount: samplesAtTime.length, - })); - const avgCpuPercent = - readTotals.length === 0 - ? 0 - : readTotals.reduce((total, read) => total + read.cpuPercent, 0) / readTotals.length; - - buckets.push({ - startedAt: dateTimeFromMillis(startedAtMs), - endedAt: dateTimeFromMillis(endedAtMs), - avgCpuPercent, - maxCpuPercent: readTotals.length ? Math.max(...readTotals.map((read) => read.cpuPercent)) : 0, - maxRssBytes: readTotals.length ? Math.max(...readTotals.map((read) => read.rssBytes)) : 0, - maxProcessCount: readTotals.length - ? Math.max(...readTotals.map((read) => read.processCount)) - : 0, - }); - } - - return buckets; -} - -export function aggregateProcessResourceHistory(input: { - readonly samples: ReadonlyArray; - readonly readAt: DateTime.Utc; - readonly readAtMs: number; - readonly windowMs: number; - readonly bucketMs: number; - readonly lastFailure: ProcessResourceSamplingError | null; -}): ServerProcessResourceHistoryResult { - const windowMs = Math.max(1_000, input.windowMs); - const bucketMs = Math.max(1_000, input.bucketMs); - const minSampledAtMs = input.readAtMs - windowMs; - const samples = input.samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); - const topProcesses = summarizeProcesses(samples); - const totalCpuSecondsApprox = samples.reduce( - (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), - 0, - ); - - return { - readAt: input.readAt, - windowMs, - bucketMs, - sampleIntervalMs: SAMPLE_INTERVAL_MS, - retainedSampleCount: input.samples.length, - totalCpuSecondsApprox, - buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }), - topProcesses, - error: input.lastFailure - ? Option.some({ - failureTag: input.lastFailure.failureTag, - message: input.lastFailure.message, - }) - : Option.none(), - }; -} - -export const make = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const state = yield* Ref.make({ samples: [], lastFailure: null }); - - const recordSamplingFailure = (cause: { - readonly _tag: ServerProcessResourceHistoryFailureTagType; - }) => - Ref.update(state, (current) => ({ - ...current, - lastFailure: new ProcessResourceSamplingError({ - failureTag: cause._tag, - cause, +export const make = Effect.fn("makeProcessResourceMonitor")(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => + telemetry.readHistory(input).pipe( + Effect.map((history) => { + const topProcesses = history.topProcesses + .filter((entry) => isLegacyBackendCategory(entry.category)) + .map((entry) => ({ + processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`, + pid: entry.identity.pid, + ppid: entry.ppid, + command: entry.command || entry.name || "unknown", + depth: entry.depth, + isServerRoot: entry.category === "server", + firstSeenAt: entry.firstSeenAt, + lastSeenAt: entry.lastSeenAt, + currentCpuPercent: entry.currentCpuPercent, + avgCpuPercent: entry.avgCpuPercent, + maxCpuPercent: entry.maxCpuPercent, + cpuSecondsApprox: entry.cpuTimeMs / 1_000, + currentRssBytes: entry.currentRssBytes, + maxRssBytes: entry.peakRssBytes, + sampleCount: entry.sampleCount, + })); + return { + readAt: history.readAt, + windowMs: history.windowMs, + bucketMs: history.bucketMs, + sampleIntervalMs: history.sampleIntervalMs, + retainedSampleCount: history.retainedSampleCount, + totalCpuSecondsApprox: topProcesses.reduce( + (total, entry) => total + entry.cpuSecondsApprox, + 0, + ), + buckets: (history.legacyBackendBuckets ?? history.buckets).map((bucket) => ({ + startedAt: bucket.startedAt, + endedAt: bucket.endedAt, + avgCpuPercent: bucket.avgCpuPercent, + maxCpuPercent: bucket.maxCpuPercent, + maxRssBytes: bucket.maxRssBytes, + maxProcessCount: bucket.maxProcessCount, + })), + topProcesses, + error: history.health.native.lastError.pipe( + Option.map((message) => ({ + failureTag: "ProcessDiagnosticsQueryFailedError" as const, + message, + })), + ), + }; }), - })); - - const sampleOnce = Effect.gen(function* () { - const sampledAt = yield* DateTime.now; - const sampledAtMs = DateTime.toEpochMillis(sampledAt); - const rows = yield* ProcessDiagnostics.readProcessRows.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const samples = collectMonitoredSamples({ - rows, - serverPid: process.pid, - sampledAt, - sampledAtMs, - }); - yield* Ref.update(state, (current) => ({ - samples: trimSamples([...current.samples, ...samples], sampledAtMs), - lastFailure: null, - })); - }).pipe( - Effect.catchTags({ - ProcessDiagnosticsQueryTimeoutError: recordSamplingFailure, - ProcessDiagnosticsQueryFailedError: recordSamplingFailure, - ProcessDiagnosticsServerProcessSignalError: recordSamplingFailure, - ProcessDiagnosticsNotDescendantError: recordSamplingFailure, - ProcessDiagnosticsSignalFailedError: recordSamplingFailure, - }), - ); - - yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( - Effect.forkScoped, - ); - - const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => - Effect.gen(function* () { - const readAt = yield* DateTime.now; - const readAtMs = DateTime.toEpochMillis(readAt); - const current = yield* Ref.get(state); - return aggregateProcessResourceHistory({ - samples: current.samples, - readAt, - readAtMs, - windowMs: input.windowMs, - bucketMs: input.bucketMs, - lastFailure: current.lastFailure, - }); - }); return ProcessResourceMonitor.of({ readHistory }); }); -export const layer = Layer.effect(ProcessResourceMonitor, make); +export const layer = Layer.effect(ProcessResourceMonitor, make()); diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index ed870be7278a..3817ac450a2e 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -10,6 +10,7 @@ import * as OtlpSerialization from "effect/unstable/observability/OtlpSerializat import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; import * as ServerConfig from "../../config.ts"; +import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; import { ServerLoggerLive } from "../../serverLogger.ts"; import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; @@ -18,6 +19,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const attribution = yield* ResourceAttribution.ResourceAttribution; const otlpTracesUrl = config.otlpTracesUrl?.trim() || undefined; const otlpMetricsUrl = config.otlpMetricsUrl?.trim() || undefined; @@ -35,6 +37,14 @@ export const ObservabilityLive = Layer.unwrap( maxBytes: config.traceMaxBytes, maxFiles: config.traceMaxFiles, batchWindowMs: config.traceBatchWindowMs, + onFlush: (stats) => + attribution.record({ + component: "server-trace", + operation: "append", + logicalWriteBytes: stats.logicalWriteBytes, + count: stats.count, + durationMs: stats.durationMs, + }), }); const delegate = otlpTracesUrl === undefined diff --git a/apps/server/src/provider/Drivers/AmpDriver.ts b/apps/server/src/provider/Drivers/AmpDriver.ts index 73d94408001a..2c37d6faba68 100644 --- a/apps/server/src/provider/Drivers/AmpDriver.ts +++ b/apps/server/src/provider/Drivers/AmpDriver.ts @@ -26,6 +26,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { makeAmpTextGeneration } from "../../textGeneration/AmpTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -50,11 +52,13 @@ const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ }); export type AmpDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2fc11311aac..e099d52e5189 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -24,6 +24,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -57,7 +58,6 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from " const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); -const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); function isClaudeNativeCommandPath(commandPath: string): boolean { @@ -82,6 +82,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }); export type ClaudeDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem @@ -190,7 +191,6 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77dc..15d7a1ff0216 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -22,7 +22,6 @@ * @module provider/Drivers/CodexDriver */ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -32,6 +31,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -60,7 +60,6 @@ import { const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", @@ -74,6 +73,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ * registered driver and the runtime satisfies them once. */ export type CodexDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem @@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver = { Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts index fc183b31b1f4..22b7691f2fd2 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -30,6 +30,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerConfig } from "../../config.ts"; import { makeCopilotTextGeneration } from "../../textGeneration/CopilotTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -58,11 +60,13 @@ const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ }); export type CopilotDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 1c7b960ab2db..82d73854b563 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -12,6 +12,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; @@ -44,6 +45,8 @@ const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +// The fork drives Cursor through @cursor/sdk rather than the `cursor-agent` +// CLI, so updates stay manual instead of shelling out to `cursor-agent update`. const UPDATE = makeStaticProviderMaintenanceResolver( makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, @@ -52,6 +55,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( ); export type CursorDriverEnv = + | BackgroundPolicy.BackgroundPolicy | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient diff --git a/apps/server/src/provider/Drivers/DroidDriver.ts b/apps/server/src/provider/Drivers/DroidDriver.ts index f060dca36880..0d1f50a40794 100644 --- a/apps/server/src/provider/Drivers/DroidDriver.ts +++ b/apps/server/src/provider/Drivers/DroidDriver.ts @@ -13,6 +13,8 @@ import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerConfig } from "../../config.ts"; import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -43,11 +45,13 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }); export type DroidDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { diff --git a/apps/server/src/provider/Drivers/GeminiCliDriver.ts b/apps/server/src/provider/Drivers/GeminiCliDriver.ts index 393c3ba5325c..4ccc14905b30 100644 --- a/apps/server/src/provider/Drivers/GeminiCliDriver.ts +++ b/apps/server/src/provider/Drivers/GeminiCliDriver.ts @@ -26,6 +26,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerConfig } from "../../config.ts"; import { makeGeminiCliTextGeneration } from "../../textGeneration/GeminiCliTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -50,11 +52,13 @@ const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ }); export type GeminiCliDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 4eb32c20c472..112f11013161 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -1,5 +1,4 @@ import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -8,6 +7,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; @@ -40,7 +40,6 @@ import { const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); -const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); const UPDATE = makeStaticProviderMaintenanceResolver( makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, @@ -49,6 +48,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( ); export type GrokDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem @@ -136,7 +136,6 @@ export const GrokDriver: ProviderDriver = { publishSnapshot, httpClient, }), - refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Drivers/KiloDriver.ts b/apps/server/src/provider/Drivers/KiloDriver.ts index 95caa42ac559..7e5a8a6f6261 100644 --- a/apps/server/src/provider/Drivers/KiloDriver.ts +++ b/apps/server/src/provider/Drivers/KiloDriver.ts @@ -22,6 +22,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerConfig } from "../../config.ts"; import { makeKiloTextGeneration } from "../../textGeneration/KiloTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -49,10 +51,12 @@ const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ }); export type KiloDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 6342d1765904..a01e414f8116 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -13,7 +13,6 @@ * @module provider/Drivers/OpenCodeDriver */ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -23,6 +22,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -55,7 +55,6 @@ import { const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); -const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); function isOpenCodeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); @@ -78,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }); export type OpenCodeDriverEnv = + | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem @@ -166,7 +166,6 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }, ).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index adafb61a83fd..ee4837a62b8e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1354,6 +1354,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( stream: "native", }) : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const createQuery = options?.createQuery ?? @@ -1387,7 +1389,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); - const logNativeSdkMessage = Effect.fn("logNativeSdkMessage")(function* ( + const logNativeSdkMessage = Effect.fnUntraced(function* ( context: ClaudeSessionContext, message: SDKMessage, ) { @@ -3926,6 +3928,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( Effect.logError("Failed to emit Claude session shutdown event.", { cause }), ), Effect.tap(() => Queue.shutdown(runtimeEventQueue)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), ), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 68fb16b0401c..e209c7e77582 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1291,7 +1291,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => yield* Scope.close(scope, Exit.void); scopeClosed = true; - const threadLogPath = NodePath.join(tempDir, "thread-logger.log"); + const threadLogPath = NodePath.join(tempDir, "provider-native.thread-logger.log"); NodeAssert.equal(NodeFS.existsSync(threadLogPath), true); const contents = NodeFS.readFileSync(threadLogPath, "utf8"); NodeAssert.match(contents, /NATIVE: .*"message":"native flush test"/); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 38a5887cdc3e..4146121b1474 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1646,7 +1646,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); - const writeNativeEvent = Effect.fn("writeNativeEvent")(function* (event: ProviderEvent) { + const writeNativeEvent = Effect.fnUntraced(function* (event: ProviderEvent) { if (!nativeEventLogger) { return; } diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index e8fb790293a2..71060cf23a46 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -5,14 +5,30 @@ import * as NodePath from "node:path"; import { ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; -import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; +import { + makeEventNdjsonLogger, + makeEventNdjsonLogStore, + type PendingRecord, + writeBatchedMessages, +} from "./EventNdjsonLogger.ts"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +function ownedLogPath(basePath: string, segment: string): string { + const basename = NodePath.basename(basePath); + const extension = NodePath.extname(basename); + const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename; + return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`); +} + function parseLogLine(line: string) { const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); assert.notEqual(match, null); @@ -87,8 +103,8 @@ describe("EventNdjsonLogger", () => { ); yield* logger.close(); - const threadOnePath = NodePath.join(tempDir, "thread-1.log"); - const threadTwoPath = NodePath.join(tempDir, "thread-2.log"); + const threadOnePath = ownedLogPath(basePath, "thread-1"); + const threadTwoPath = ownedLogPath(basePath, "thread-2"); assert.equal(NodeFS.existsSync(threadOnePath), true); assert.equal(NodeFS.existsSync(threadTwoPath), true); @@ -129,7 +145,7 @@ describe("EventNdjsonLogger", () => { yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); yield* logger.close(); - const globalPath = NodePath.join(tempDir, "_global.log"); + const globalPath = ownedLogPath(basePath, "_global"); assert.equal(NodeFS.existsSync(globalPath), true); const lines = NodeFS.readFileSync(globalPath, "utf8") .trim() @@ -138,9 +154,9 @@ describe("EventNdjsonLogger", () => { assert.equal(lines.length, 2); assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); - assert.equal(lines[0]?.stream, "CANON"); + assert.equal(lines[0]?.stream, "ORCH"); assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); - assert.equal(lines[1]?.stream, "CANON"); + assert.equal(lines[1]?.stream, "ORCH"); assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); } finally { NodeFS.rmSync(tempDir, { recursive: true, force: true }); @@ -148,6 +164,197 @@ describe("EventNdjsonLogger", () => { }), ); + it.effect("shares one thread writer across native and canonical streams", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const native = store.logger("native"); + const canonical = store.logger("canonical"); + const threadId = ThreadId.make("thread-shared"); + + yield* native.write({ id: "native-event" }, threadId); + yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); + yield* store.close(); + + const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") + .trim() + .split("\n") + .map(parseLogLine); + + assert.deepEqual( + lines.map(({ stream, payload }) => ({ stream, payload })), + [ + { stream: "NTIVE", payload: '{"id":"native-event"}' }, + { + stream: "CANON", + payload: '{"type":"item.completed","id":"canonical-event"}', + }, + ], + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("keeps shared store views non-owning when one adapter closes", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const native = store.logger("native"); + const canonical = store.logger("canonical"); + const threadId = ThreadId.make("thread-shared-close"); + + yield* native.write({ id: "before-close" }, threadId); + yield* native.close(); + yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId); + yield* store.close(); + + const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8") + .trim() + .split("\n") + .map(parseLogLine); + + assert.deepEqual( + lines.map(({ stream, payload }) => ({ stream, payload })), + [ + { stream: "NTIVE", payload: '{"id":"before-close"}' }, + { + stream: "CANON", + payload: '{"type":"item.completed","id":"after-close"}', + }, + ], + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("flushes an active batch without a permanent polling loop", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const threadPath = ownedLogPath(basePath, "thread-batched"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); + yield* store + .logger("native") + .write({ id: "batched-event" }, ThreadId.make("thread-batched")); + + assert.equal(NodeFS.existsSync(threadPath), false); + yield* TestClock.adjust(1_000); + assert.equal(NodeFS.existsSync(threadPath), true); + yield* store.close(); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("does not strand a later batch after an interrupted write", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const threadPath = ownedLogPath(basePath, "thread-interrupted"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); + const logger = store.logger("native"); + const interruptedWrite = yield* logger + .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted")) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Fiber.interrupt(interruptedWrite); + yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted")); + + yield* TestClock.adjust(1_000); + + assert.equal(NodeFS.existsSync(threadPath), true); + assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}'); + yield* store.close(); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("drops transient canonical events before serialization", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const canonical = store.logger("canonical"); + const native = store.logger("native"); + const threadId = ThreadId.make("thread-filtered"); + const circularDelta: Record = { type: "content.delta" }; + circularDelta["self"] = circularDelta; + + yield* canonical.write(circularDelta, threadId); + yield* canonical.write({ type: "item.completed", id: "final" }, threadId); + yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); + yield* store.close(); + + const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") + .trim() + .split("\n") + .map(parseLogLine); + + assert.deepEqual( + lines.map(({ stream, payload }) => ({ stream, payload })), + [ + { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, + { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, + ], + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("contains hostile event accessors inside guarded serialization", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { + stream: "canonical", + batchWindowMs: 0, + }); + assert.exists(logger); + if (!logger) return; + const hostile = new Proxy( + { id: "hostile" }, + { + get(_target, property) { + if (property === "type") throw new Error("blocked"); + return undefined; + }, + }, + ); + + yield* logger.write(hostile, ThreadId.make("thread-hostile")); + yield* logger.close(); + + const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); + assert.notInclude(contents, "blocked"); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + it.effect("serializes concurrent first writes for the same segment", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); @@ -172,7 +379,7 @@ describe("EventNdjsonLogger", () => { ); yield* logger.close(); - const globalPath = NodePath.join(tempDir, "_global.log"); + const globalPath = ownedLogPath(basePath, "_global"); assert.equal(NodeFS.existsSync(globalPath), true); const lines = NodeFS.readFileSync(globalPath, "utf8") .trim() @@ -196,19 +403,19 @@ describe("EventNdjsonLogger", () => { const basePath = NodePath.join(tempDir, "provider-native.ndjson"); try { - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "native", + const store = yield* makeEventNdjsonLogStore(basePath, { maxBytes: 120, maxFiles: 2, + batchWindowMs: 0, }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } + const native = store.logger("native"); + const canonical = store.logger("canonical"); for (let index = 0; index < 10; index += 1) { + const logger = index % 2 === 0 ? native : canonical; yield* logger.write( { + type: "session.started", threadId: "provider-thread-rotate", id: `evt-${index}`, payload: "x".repeat(40), @@ -216,9 +423,9 @@ describe("EventNdjsonLogger", () => { ThreadId.make("thread-rotate"), ); } - yield* logger.close(); + yield* store.close(); - const fileStem = "thread-rotate.log"; + const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate")); const matchingFiles = NodeFS.readdirSync(tempDir) .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) .toSorted(); @@ -240,4 +447,143 @@ describe("EventNdjsonLogger", () => { } }), ); + + it.effect("enforces aggregate age and byte retention on startup", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const expiredPath = ownedLogPath(basePath, "expired"); + const oldPath = ownedLogPath(basePath, "old"); + const newPath = ownedLogPath(basePath, "new"); + const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); + const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); + const ignoredPath = NodePath.join(tempDir, "ignored.txt"); + + try { + yield* TestClock.setTime(1_800_000_000_000); + const now = yield* Clock.currentTimeMillis; + for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { + NodeFS.writeFileSync(filePath, "x".repeat(40)); + } + NodeFS.writeFileSync( + legacyLogPath, + "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", + ); + NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); + NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); + NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); + NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); + + const store = yield* makeEventNdjsonLogStore(basePath, { + maxAgeMs: 10_000, + maxTotalBytes: 60, + }); + yield* store.close(); + + assert.equal(NodeFS.existsSync(expiredPath), false); + assert.equal(NodeFS.existsSync(legacyLogPath), false); + assert.equal(NodeFS.existsSync(oldPath), false); + assert.equal(NodeFS.existsSync(newPath), true); + assert.equal(NodeFS.existsSync(unrelatedLogPath), true); + assert.equal(NodeFS.existsSync(ignoredPath), true); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("does not prune an active thread sink during an unrelated flush", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const activePath = ownedLogPath(basePath, "active"); + + try { + yield* TestClock.setTime(1_800_000_000_000); + const store = yield* makeEventNdjsonLogStore(basePath, { + batchWindowMs: 0, + maxAgeMs: 1, + retentionCheckIntervalMs: 1, + }); + const logger = store.logger("native"); + + yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); + assert.equal(NodeFS.existsSync(activePath), true); + + yield* TestClock.adjust("2 millis"); + yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); + + assert.equal(NodeFS.existsSync(activePath), true); + yield* store.close(); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it("attributes batches that were written before a later chunk fails", () => { + const records: ReadonlyArray = [ + { + stream: "native", + threadSegment: "thread", + line: "first", + bytes: 5, + }, + { + stream: "canonical", + threadSegment: "thread", + line: "second", + bytes: 6, + }, + ]; + const attributed: Array = []; + let writes = 0; + + assert.throws(() => + writeBatchedMessages( + { + write: () => { + writes += 1; + if (writes === 2) throw new Error("simulated disk exhaustion"); + }, + }, + records, + 5, + (written) => attributed.push(...written), + ), + ); + assert.deepEqual(attributed, [records[0]]); + }); + + it.effect("reports logical provider log writes to resource attribution", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); + + try { + const attribution = yield* ResourceAttribution.make(); + const logger = yield* makeEventNdjsonLogger(basePath, { + stream: "native", + batchWindowMs: 0, + attribution, + }); + assert.notEqual(logger, undefined); + if (!logger) { + return; + } + + yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); + yield* logger.close(); + + const snapshot = yield* attribution.snapshot; + assert.equal(snapshot.entries.length, 1); + assert.equal(snapshot.entries[0]?.component, "provider-event-log"); + assert.equal(snapshot.entries[0]?.operation, "native.append"); + assert.equal(snapshot.entries[0]?.count, 1); + assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); }); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index 0b9a87aeb238..4c05243de5db 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -1,10 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off /** - * Provider event logger helper. + * Best-effort provider event logging with one shared writer per thread. * - * Best-effort writer for observability logs. Each record is formatted as a - * single effect-style text line in a thread-scoped file. Failures are - * downgraded to warnings so provider runtime behavior is unaffected. + * Native and canonical views share batching, rotation, and retention state so + * they cannot race while appending to the same thread-scoped file. */ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -12,46 +11,147 @@ import * as NodePath from "node:path"; import type { ThreadId } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { errorTag } from "@t3tools/shared/observability"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; +import type { ResourceAttribution } from "../../resourceTelemetry/ResourceAttribution.ts"; -const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; +const MEBIBYTE = 1024 * 1024; +const DAY_MS = 24 * 60 * 60 * 1_000; +const DEFAULT_MAX_BYTES = 10 * MEBIBYTE; const DEFAULT_MAX_FILES = 10; -const DEFAULT_BATCH_WINDOW_MS = 200; +const DEFAULT_BATCH_WINDOW_MS = 1_000; +const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE; +const DEFAULT_MAX_AGE_MS = 14 * DAY_MS; +const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000; +const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE; +const DEFAULT_MAX_BUFFERED_RECORDS = 512; const GLOBAL_THREAD_SEGMENT = "_global"; const LOG_SCOPE = "provider-observability"; const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const transientCanonicalEventTypes = new Set([ + "content.delta", + "hook.progress", + "item.updated", + "task.progress", + "thread.realtime.audio.delta", + "tool.progress", + "turn.proposed.delta", +]); + export type EventNdjsonStream = "native" | "canonical" | "orchestration"; export interface EventNdjsonLogger { readonly filePath: string; - write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; - close: () => Effect.Effect; + readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; + readonly close: () => Effect.Effect; } -export interface EventNdjsonLoggerOptions { - readonly stream: EventNdjsonStream; +export interface EventNdjsonLogStore { + readonly filePath: string; + readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; + readonly close: () => Effect.Effect; +} + +export interface EventNdjsonLogStoreOptions { readonly maxBytes?: number; readonly maxFiles?: number; readonly batchWindowMs?: number; + readonly maxTotalBytes?: number; + readonly maxAgeMs?: number; + readonly retentionCheckIntervalMs?: number; + readonly maxBufferedBytes?: number; + readonly maxBufferedRecords?: number; + readonly attribution?: ResourceAttribution["Service"]; +} + +export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { + readonly stream: EventNdjsonStream; +} + +export class EventNdjsonLogConfigurationError extends Schema.TaggedErrorClass()( + "EventNdjsonLogConfigurationError", + { + filePath: Schema.String, + option: Schema.String, + value: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`; + } +} + +export class EventNdjsonLogDirectoryError extends Schema.TaggedErrorClass()( + "EventNdjsonLogDirectoryError", + { + directory: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create provider event log directory '${this.directory}'`; + } +} + +export type EventNdjsonLogStoreError = + | EventNdjsonLogConfigurationError + | EventNdjsonLogDirectoryError; + +interface ResolvedOptions { + readonly maxBytes: number; + readonly maxFiles: number; + readonly batchWindowMs: number; + readonly maxTotalBytes: number; + readonly maxAgeMs: number; + readonly retentionCheckIntervalMs: number; + readonly maxBufferedBytes: number; + readonly maxBufferedRecords: number; + readonly attribution: ResourceAttribution["Service"] | undefined; } -interface ThreadWriter { - writeMessage: (message: string) => Effect.Effect; - close: () => Effect.Effect; +export interface PendingRecord { + readonly stream: EventNdjsonStream; + readonly threadSegment: string; + readonly line: string; + readonly bytes: number; } -interface LoggerState { - readonly threadWriters: Map; - readonly failedSegments: Set; +interface StoreState { + readonly pending: ReadonlyArray; + readonly pendingBytes: number; + readonly sinks: ReadonlyMap; + readonly flushScheduled: boolean; readonly closed: boolean; + readonly lastRetentionAt: number; +} + +interface AttributionSummary { + readonly stream: EventNdjsonStream; + readonly count: number; + readonly logicalWriteBytes: number; +} + +interface FileOperationFailure { + readonly filePath: string; + readonly cause: unknown; +} + +interface RetentionResult { + readonly failures: ReadonlyArray; +} + +interface DrainResult { + readonly attributions: ReadonlyArray; + readonly failures: ReadonlyArray; } function logWarning(message: string, context: Record): Effect.Effect { @@ -63,232 +163,458 @@ function resolveThreadSegment(raw: string | null | undefined): string { return normalized ?? GLOBAL_THREAD_SEGMENT; } -function formatLoggerMessage(message: unknown): string { - if (Array.isArray(message)) { - return message.map((part) => (typeof part === "string" ? part : String(part))).join(" "); +function resolveStreamLabel(stream: EventNdjsonStream): string { + return stream === "native" ? "NATIVE" : stream === "orchestration" ? "ORCH" : "CANON"; +} + +function providerLogPrefix(filePath: string): string { + const basename = NodePath.basename(filePath); + const extension = NodePath.extname(basename); + return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`; +} + +function providerLogPath(directory: string, prefix: string, threadSegment: string): string { + return NodePath.join(directory, `${prefix}${threadSegment}.log`); +} + +function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { + if (stream !== "canonical" || typeof event !== "object" || event === null) { + return true; + } + try { + const type = Reflect.get(event, "type"); + return typeof type !== "string" || !transientCanonicalEventTypes.has(type); + } catch { + return true; } - return typeof message === "string" ? message : String(message); } -function makeLineLogger(streamLabel: string): Logger.Logger { - return Logger.make( - ({ date, message }) => - `[${date.toISOString()}] ${streamLabel}: ${formatLoggerMessage(message)}\n`, - ); +export function writeBatchedMessages( + sink: Pick, + records: ReadonlyArray, + maxBytes: number, + onWritten: (records: ReadonlyArray) => void, +): void { + let pendingRecords: Array = []; + let pendingBytes = 0; + + const flush = () => { + if (pendingRecords.length === 0) return; + const writtenRecords = pendingRecords; + sink.write(writtenRecords.map((record) => record.line).join("")); + onWritten(writtenRecords); + pendingRecords = []; + pendingBytes = 0; + }; + + for (const record of records) { + if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) { + flush(); + } + pendingRecords.push(record); + pendingBytes += record.bytes; + if (pendingBytes >= maxBytes) { + flush(); + } + } + flush(); } -function resolveStreamLabel(stream: EventNdjsonStream): string { - switch (stream) { - case "native": - return "NATIVE"; - case "canonical": - case "orchestration": - default: - return "CANON"; +function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean { + if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false; + if (fileName.startsWith(filePrefix)) return true; + + const descriptor = NodeFS.openSync(filePath, "r"); + try { + const header = Buffer.alloc(256); + const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0); + // NTIVE is the pre-fork native tag; keep accepting it so log files written + // before the label widened to NATIVE still validate. + return /^\[[^\]\r\n]+\] (?:NATIVE|NTIVE|CANON|ORCH): /u.test( + header.toString("utf8", 0, bytesRead), + ); + } finally { + NodeFS.closeSync(descriptor); } } -const toLogMessage = Effect.fn("toLogMessage")(function* ( - event: unknown, -): Effect.fn.Return { - return yield* encodeUnknownJsonString(event).pipe( - Effect.catch((error) => - logWarning("failed to serialize provider event log record", { - errorTag: errorTag(error), - }).pipe(Effect.as(undefined)), - ), - ); -}); +function enforceRetention(input: { + readonly directory: string; + readonly maxTotalBytes: number; + readonly maxAgeMs: number; + readonly activeFilePaths: ReadonlySet; + readonly filePrefix: string; + readonly now: number; +}): RetentionResult { + const failures: Array = []; + const files: Array<{ filePath: string; mtimeMs: number; size: number }> = []; + + let entries: ReadonlyArray; + try { + entries = NodeFS.readdirSync(input.directory, { withFileTypes: true }); + } catch (cause) { + return { failures: [{ filePath: input.directory, cause }] }; + } -const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { - readonly filePath: string; - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; - readonly streamLabel: string; -}): Effect.fn.Return { - const sinkResult = yield* Effect.sync(() => { + for (const entry of entries) { + if (!entry.isFile()) continue; + const filePath = NodePath.join(input.directory, entry.name); try { - return { - ok: true as const, - sink: new RotatingFileSink({ - filePath: input.filePath, - maxBytes: input.maxBytes, - maxFiles: input.maxFiles, - throwOnError: true, - }), - }; - } catch (error) { - return { ok: false as const, error }; + if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue; + const stat = NodeFS.statSync(filePath); + files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size }); + } catch (cause) { + failures.push({ filePath, cause }); } + } + + let totalBytes = files.reduce((total, file) => total + file.size, 0); + const remove = (file: (typeof files)[number]) => { + if (input.activeFilePaths.has(file.filePath)) return false; + try { + NodeFS.rmSync(file.filePath, { force: true }); + totalBytes -= file.size; + return true; + } catch (cause) { + failures.push({ filePath: file.filePath, cause }); + return false; + } + }; + + const retained = files.filter((file) => { + if (input.now - file.mtimeMs <= input.maxAgeMs) return true; + return !remove(file); }); - if (!sinkResult.ok) { - yield* logWarning("failed to initialize provider thread log file", { - filePath: input.filePath, - errorTag: errorTag(sinkResult.error), - }); - return undefined; + for (const file of retained.toSorted( + (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath), + )) { + if (totalBytes <= input.maxTotalBytes) break; + remove(file); } - const sink = sinkResult.sink; - const scope = yield* Scope.make(); - const lineLogger = makeLineLogger(input.streamLabel); - const batchedLogger = yield* Logger.batched(lineLogger, { - window: input.batchWindowMs, - flush: Effect.fn("makeThreadWriter.flush")(function* (messages) { - const flushResult = yield* Effect.sync(() => { - try { - for (const message of messages) { - sink.write(message); - } - return { ok: true as const }; - } catch (error) { - return { ok: false as const, error }; - } - }); + return { failures }; +} + +function validateOption(input: { + readonly filePath: string; + readonly option: string; + readonly value: number; + readonly minimum: number; +}): EventNdjsonLogConfigurationError | undefined { + if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined; + return new EventNdjsonLogConfigurationError(input); +} + +function resolveOptions( + filePath: string, + options: EventNdjsonLogStoreOptions, +): Effect.Effect { + const resolved = { + maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, + maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES, + batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS, + maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, + maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS, + retentionCheckIntervalMs: + options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS, + maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES, + maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS, + attribution: options.attribution, + } satisfies ResolvedOptions; + + const validations = [ + ["maxBytes", resolved.maxBytes, 1], + ["maxFiles", resolved.maxFiles, 1], + ["batchWindowMs", resolved.batchWindowMs, 0], + ["maxTotalBytes", resolved.maxTotalBytes, 1], + ["maxAgeMs", resolved.maxAgeMs, 1], + ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1], + ["maxBufferedBytes", resolved.maxBufferedBytes, 1], + ["maxBufferedRecords", resolved.maxBufferedRecords, 1], + ] as const; + + for (const [option, value, minimum] of validations) { + const error = validateOption({ filePath, option, value, minimum }); + if (error) return Effect.fail(error); + } + return Effect.succeed(resolved); +} - if (!flushResult.ok) { - yield* logWarning("provider event log batch flush failed", { - filePath: input.filePath, - errorTag: errorTag(flushResult.error), +function drainPending(input: { + readonly directory: string; + readonly options: ResolvedOptions; + readonly state: StoreState; + readonly filePrefix: string; + readonly now: number; + readonly timerFired: boolean; + readonly close: boolean; +}): readonly [DrainResult, StoreState] { + if (input.state.closed) { + return [{ attributions: [], failures: [] }, input.state]; + } + + const sinks = new Map(input.state.sinks); + const failures: Array = []; + const attributionByStream = new Map< + EventNdjsonStream, + { count: number; logicalWriteBytes: number } + >(); + const recordsBySegment = new Map>(); + + for (const record of input.state.pending) { + const records = recordsBySegment.get(record.threadSegment) ?? []; + records.push(record); + recordsBySegment.set(record.threadSegment, records); + } + + for (const [threadSegment, records] of recordsBySegment) { + const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment); + let sink = sinks.get(threadSegment); + if (!sink) { + try { + sink = new RotatingFileSink({ + filePath, + maxBytes: input.options.maxBytes, + maxFiles: input.options.maxFiles, + throwOnError: true, }); + sinks.set(threadSegment, sink); + } catch (cause) { + failures.push({ filePath, cause }); + continue; } - }), - }).pipe(Effect.provideService(Scope.Scope, scope)); + } - const loggerLayer = Logger.layer([batchedLogger], { mergeWithExisting: false }); + try { + writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => { + for (const record of writtenRecords) { + const current = attributionByStream.get(record.stream) ?? { + count: 0, + logicalWriteBytes: 0, + }; + attributionByStream.set(record.stream, { + count: current.count + 1, + logicalWriteBytes: current.logicalWriteBytes + record.bytes, + }); + } + }); + } catch (cause) { + sinks.delete(threadSegment); + failures.push({ filePath, cause }); + } + } - return { - writeMessage(message: string) { - return Effect.log(message).pipe(Effect.provide(loggerLayer)); + const retentionDue = + input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; + const retention = retentionDue + ? enforceRetention({ + directory: input.directory, + maxTotalBytes: input.options.maxTotalBytes, + maxAgeMs: input.options.maxAgeMs, + activeFilePaths: new Set( + Array.from(sinks.keys(), (threadSegment) => + providerLogPath(input.directory, input.filePrefix, threadSegment), + ), + ), + filePrefix: input.filePrefix, + now: input.now, + }) + : { failures: [] }; + + return [ + { + attributions: Array.from(attributionByStream, ([stream, value]) => ({ + stream, + ...value, + })), + failures: [...failures, ...retention.failures], }, - close() { - return Scope.close(scope, Exit.void); + { + pending: [], + pendingBytes: 0, + sinks, + flushScheduled: input.timerFired ? false : input.state.flushScheduled, + closed: input.close, + lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt, }, - } satisfies ThreadWriter; + ]; +} + +const serializeEvent = Effect.fnUntraced(function* (event: unknown) { + return yield* encodeUnknownJsonString(event).pipe( + Effect.catch((error) => + logWarning("failed to serialize provider event log record", { + errorTag: errorTag(error), + }).pipe(Effect.as(undefined)), + ), + ); }); -export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function* ( +export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( filePath: string, - options: EventNdjsonLoggerOptions, -): Effect.fn.Return { - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; - const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS; - const streamLabel = resolveStreamLabel(options.stream); - - const directoryReady = yield* Effect.sync(() => { - try { - NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true }); - return true; - } catch (error) { - return { ok: false as const, error }; - } + options: EventNdjsonLogStoreOptions = {}, +): Effect.fn.Return { + const resolved = yield* resolveOptions(filePath, options); + const directory = NodePath.dirname(filePath); + const filePrefix = providerLogPrefix(filePath); + + yield* Effect.try({ + try: () => NodeFS.mkdirSync(directory, { recursive: true }), + catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }), }); - if (directoryReady !== true) { - yield* logWarning("failed to create provider event log directory", { - filePath, - errorTag: errorTag(directoryReady.error), + + const initializedAt = yield* Clock.currentTimeMillis; + const initialRetention = yield* Effect.sync(() => + enforceRetention({ + directory, + maxTotalBytes: resolved.maxTotalBytes, + maxAgeMs: resolved.maxAgeMs, + activeFilePaths: new Set(), + filePrefix, + now: initializedAt, + }), + ); + for (const failure of initialRetention.failures) { + yield* logWarning("provider event log retention failed", { + filePath: failure.filePath, + errorTag: errorTag(failure.cause), }); - return undefined; } - const stateRef = yield* SynchronizedRef.make({ - threadWriters: new Map(), - failedSegments: new Set(), + const stateRef = yield* SynchronizedRef.make({ + pending: [], + pendingBytes: 0, + sinks: new Map(), + flushScheduled: false, closed: false, + lastRetentionAt: initializedAt, }); - - const resolveThreadWriter = Effect.fn("resolveThreadWriter")(function* ( - threadSegment: string, - ): Effect.fn.Return { - return yield* SynchronizedRef.modifyEffect(stateRef, (state) => { - if (state.failedSegments.has(threadSegment)) { - return Effect.succeed([undefined, state] as const); - } - - const existing = state.threadWriters.get(threadSegment); - if (existing) { - return Effect.succeed([existing, state] as const); - } - - return makeThreadWriter({ - filePath: NodePath.join(NodePath.dirname(filePath), `${threadSegment}.log`), - maxBytes, - maxFiles, - batchWindowMs, - streamLabel, - }).pipe( - Effect.map((writer) => { - if (!writer) { - const nextFailedSegments = new Set(state.failedSegments); - nextFailedSegments.add(threadSegment); - return [ - undefined, - { - ...state, - failedSegments: nextFailedSegments, - }, - ] as const; - } - - const nextThreadWriters = new Map(state.threadWriters); - nextThreadWriters.set(threadSegment, writer); - return [ - writer, - { - ...state, - threadWriters: nextThreadWriters, - }, - ] as const; + const timerScope = yield* Scope.make(); + + const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) { + const startedAt = yield* Clock.currentTimeMillis; + const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) => + Effect.sync(() => + drainPending({ + directory, + options: resolved, + state, + filePrefix, + now: startedAt, + timerFired, + close, }), - ); - }); - }); + ), + ); - const write = Effect.fn("write")(function* (event: unknown, threadId: ThreadId | null) { - const state = yield* SynchronizedRef.get(stateRef); - if (state.closed) { - return; + for (const failure of result.failures) { + yield* logWarning("provider event log write or retention failed", { + filePath: failure.filePath, + errorTag: errorTag(failure.cause), + }); } - const threadSegment = resolveThreadSegment(threadId); - const message = yield* toLogMessage(event); - if (!message) { - return; + if (resolved.attribution && result.attributions.length > 0) { + const completedAt = yield* Clock.currentTimeMillis; + const durationMs = Math.max(0, completedAt - startedAt); + const totalBytes = result.attributions.reduce( + (total, entry) => total + entry.logicalWriteBytes, + 0, + ); + yield* Effect.forEach( + result.attributions, + (entry) => + resolved.attribution?.record({ + component: "provider-event-log", + operation: `${entry.stream}.append`, + logicalWriteBytes: entry.logicalWriteBytes, + count: entry.count, + durationMs: + totalBytes === 0 + ? 0 + : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)), + }) ?? Effect.void, + { discard: true }, + ); } + }); - const writer = yield* resolveThreadWriter(threadSegment); - if (!writer) { - return; - } + const scheduleFlush = Effect.fnUntraced(function* () { + yield* Effect.forkIn( + Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))), + timerScope, + { startImmediately: true }, + ); + }); - yield* writer.writeMessage(message); + const close = Effect.fnUntraced(function* () { + yield* flush(false, true); + yield* Scope.close(timerScope, Exit.void); }); - const close = Effect.fn("close")(function* () { - yield* SynchronizedRef.modifyEffect(stateRef, (state) => - Effect.gen(function* () { - for (const writer of state.threadWriters.values()) { - yield* writer.close(); + const loggerViews = new Map(); + const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { + const existing = loggerViews.get(stream); + if (existing) return existing; + + const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) { + if (!shouldPersist(stream, event)) return; + const payload = yield* serializeEvent(event); + if (payload === undefined) return; + + const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`; + const bytes = Buffer.byteLength(line); + const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => { + if (state.closed) { + return Effect.succeed([{ flush: false }, state] as const); } + const pending = [ + ...state.pending, + { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, + ]; + const pendingBytes = state.pendingBytes + bytes; + const flush = + resolved.batchWindowMs === 0 || + pending.length >= resolved.maxBufferedRecords || + pendingBytes >= resolved.maxBufferedBytes; + const schedule = !flush && !state.flushScheduled; + const nextState = { + ...state, + pending, + pendingBytes, + flushScheduled: state.flushScheduled || schedule, + }; + return (schedule ? scheduleFlush() : Effect.void).pipe( + Effect.as([{ flush }, nextState] as const), + ); + }).pipe(Effect.uninterruptible); + + if (action.flush) { + yield* flush(false, false); + } + }); - return [ - undefined, - { - threadWriters: new Map(), - failedSegments: new Set(), - closed: true, - }, - ] as const; - }), - ); - }); + const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; + loggerViews.set(stream, view); + return view; + }; - return { - filePath, - write, - close, - } satisfies EventNdjsonLogger; + return { filePath, logger, close } satisfies EventNdjsonLogStore; +}); + +export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( + filePath: string, + options: EventNdjsonLoggerOptions, +): Effect.fn.Return { + const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( + Effect.catch((error) => + logWarning(error.message, { error }).pipe( + Effect.as(undefined), + ), + ), + ); + if (!store) return undefined; + return { ...store.logger(options.stream), close: store.close }; }); diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts index 711aa6e76b68..a4f0b01e8193 100644 --- a/apps/server/src/provider/Layers/ProviderEventLoggers.ts +++ b/apps/server/src/provider/Layers/ProviderEventLoggers.ts @@ -1,6 +1,6 @@ /** - * ProviderEventLoggers — single observability service that owns the two - * shared NDJSON streams the provider runtime writes: + * ProviderEventLoggers — single observability service that owns the shared + * provider event log store and exposes its two runtime views: * * - `native` — provider-protocol events as the SDK emits them, written * from inside each `Adapter` factory. @@ -13,17 +13,14 @@ * not at the boot Layer. There is no longer a single `makeAdapterLive(options)` * call site where we can hand an `EventNdjsonLogger` in by hand. * - Multiple driver instances per kind (`codex_personal`, `codex_work`) - * should share one underlying log writer per stream — opening N writers - * against the same rotating file would race the rotation logic. Owning - * the loggers on a single tag keeps that invariant intact. + * must share one underlying log store — opening N writers against the + * same rotating file would race the rotation logic. Owning the loggers on + * a single tag keeps that invariant intact. * - Tests can swap one (or both) loggers with in-memory recorders by * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of * juggling per-Layer option threading. * - * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when - * the target directory cannot be created; we forward that as `undefined` - * rather than failing the boot Layer, matching the previous best-effort - * behavior of `server.ts`. + * Both fields are optional because observability must not prevent startup. * * @module provider/Layers/ProviderEventLoggers */ @@ -32,24 +29,23 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { ServerConfig } from "../../config.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -export interface ProviderEventLoggersShape { - readonly native: EventNdjsonLogger | undefined; - readonly canonical: EventNdjsonLogger | undefined; -} +import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; +import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; /** * Shared logger pair for native + canonical provider event streams. * * Service value is intentionally a struct of two optional loggers rather * than two parallel tags. Construction site is one place - * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read - * one tag and pluck the field they need. + * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the + * field they need. */ export class ProviderEventLoggers extends Context.Service< ProviderEventLoggers, - ProviderEventLoggersShape + { + readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; + readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; + } >()("t3/provider/Layers/ProviderEventLoggers") {} /** @@ -57,29 +53,38 @@ export class ProviderEventLoggers extends Context.Service< * + canonical logging entirely. Keeps the tag non-optional in the type * system while letting the runtime treat absence as a no-op. */ -export const NoOpProviderEventLoggers: ProviderEventLoggersShape = { +export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { native: undefined, canonical: undefined, }; /** - * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`. - * If the directory create fails for either stream, the corresponding field - * is `undefined` and writes from that stream become no-ops downstream. + * Builds both stream views over one shared store. Setup failures are logged + * and downgraded to the no-op service so diagnostics never block startup. */ -export const ProviderEventLoggersLive = Layer.effect( - ProviderEventLoggers, - Effect.gen(function* () { - const { providerEventLogPath } = yield* ServerConfig; - const native = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "native", - }); - const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "canonical", - }); - return { - native, - canonical, - } satisfies ProviderEventLoggersShape; - }), -); +export const make = Effect.gen(function* () { + const { providerEventLogPath } = yield* ServerConfig; + const attribution = yield* ResourceAttribution.ResourceAttribution; + const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { + attribution, + }).pipe( + Effect.catch((error) => + Effect.logWarning(error.message, { error }).pipe( + Effect.annotateLogs({ scope: "provider-observability" }), + Effect.as(undefined), + ), + ), + ); + + if (!store) { + return ProviderEventLoggers.of(NoOpProviderEventLoggers); + } + + yield* Effect.addFinalizer(() => store.close()); + return ProviderEventLoggers.of({ + native: store.logger("native"), + canonical: store.logger("canonical"), + }); +}); + +export const layer = Layer.effect(ProviderEventLoggers, make); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index ba39ec04a7f2..96654864686b 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -34,10 +34,13 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -56,6 +59,37 @@ const TestHttpClientLive = Layer.succeed( ), ); +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: true, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), +}); + const makeCodexConfig = (overrides: Partial): CodexSettings => ({ enabled: false, binaryPath: "codex", @@ -109,6 +143,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { prefix: "provider-instance-registry-test", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), @@ -247,6 +282,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { prefix: "provider-instance-registry-all-drivers-test", }).pipe( Layer.provideMerge(infraLayer), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index d0e4f826f0ae..ace9dd04f5f8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -37,6 +38,7 @@ import { makePendingClaudeProvider, normalizeClaudeCliEffort, } from "./ClaudeProvider.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -70,6 +72,7 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, @@ -78,6 +81,35 @@ const TestHttpClientLive = Layer.succeed( ), ); +const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: true, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), +}); + function selectDescriptor( id: string, label: string, @@ -302,6 +334,11 @@ function makeMutableServerSettingsService( get streamChanges() { return Stream.fromPubSub(changes); }, + get subscribeChanges() { + return PubSub.subscribe(changes).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ); + }, } satisfies ServerSettingsModule.ServerSettingsService["Service"]; }); } @@ -1005,6 +1042,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te prefix: "t3-provider-registry-merged-persist-", }), ), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(NodeServices.layer), ), ).pipe(Scope.provide(scope)); @@ -1240,6 +1278,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te prefix: "t3-provider-registry-refresh-failure-", }), ), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(NodeServices.layer), ), ).pipe(Scope.provide(scope)); @@ -1347,6 +1386,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te prefix: "t3-provider-registry-sync-failure-", }), ), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(NodeServices.layer), ), ).pipe(Scope.provide(scope)); @@ -1451,6 +1491,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will // genuinely spawn a subprocess. The missing-binary ENOENT is @@ -1549,6 +1590,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( Scope.provide(scope), @@ -1675,6 +1717,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( Scope.provide(scope), @@ -1735,6 +1778,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { if (command === "cursor-agent") { diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index ba1f01fe2b9e..5bfd3e14cfd7 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -1,16 +1,28 @@ import { describe, it, assert } from "@effect/vitest"; -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const fastModeCapabilities = createModelCapabilities({ optionDescriptors: [ { @@ -87,6 +99,43 @@ const refreshedSnapshotSecond: ServerProvider = { message: "Refreshed provider availability again.", }; +function makeBackgroundPolicyLayer(shouldRunScopeWork: boolean) { + return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: true, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + hasDemand: () => Effect.succeed(shouldRunScopeWork), + shouldRunScopeWork: () => Effect.succeed(shouldRunScopeWork), + shouldRunOpportunisticWork: Effect.succeed(shouldRunScopeWork), + }); +} + +const BackgroundPolicyAlwaysRunLayer = makeBackgroundPolicyLayer(true); +const BackgroundPolicyNeverRunLayer = makeBackgroundPolicyLayer(false); +const ServerSettingsTestLayer = ServerSettingsService.layerTest(); +const AlwaysRunTestLayer = Layer.merge(BackgroundPolicyAlwaysRunLayer, ServerSettingsTestLayer); +const NeverRunTestLayer = Layer.merge(BackgroundPolicyNeverRunLayer, ServerSettingsTestLayer); + const enrichedSnapshotSecond: ServerProvider = { ...refreshedSnapshotSecond, checkedAt: "2026-04-10T00:00:04.000Z", @@ -140,7 +189,125 @@ describe("makeManagedServerProvider", () => { assert.deepStrictEqual(latest, refreshedSnapshot); assert.strictEqual(yield* Ref.get(checkCalls), 1); }), - ), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 second", + }); + + yield* Deferred.await(initialCheckDone); + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ).pipe(Effect.provide(Layer.mergeAll(NeverRunTestLayer, TestClock.layer()))), + ); + + it.effect("disables periodic provider refreshes when the explicit interval is zero", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: 0, + }); + + yield* Deferred.await(initialCheckDone); + yield* TestClock.adjust("5 minutes"); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), + ); + + it.effect("wakes a sleeping provider refresh loop when its interval changes", () => + Effect.scoped( + Effect.gen(function* () { + const initialServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerHealthRefreshInterval: Duration.hours(1), + }; + const serverSettingsRef = yield* Ref.make(initialServerSettings); + const serverSettingsChanges = yield* PubSub.unbounded(); + const serverSettingsLayer = Layer.succeed( + ServerSettingsService, + ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(serverSettingsRef), + updateSettings: () => Effect.die(new Error("unused in this test")), + streamChanges: Stream.empty, + subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }), + ); + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + const periodicCheckDone = yield* Deferred.make(); + + yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) + : Deferred.succeed(periodicCheckDone, undefined).pipe(Effect.ignore), + ), + Effect.as(refreshedSnapshot), + ), + }).pipe(Effect.provide(Layer.merge(BackgroundPolicyAlwaysRunLayer, serverSettingsLayer))); + + yield* Deferred.await(initialCheckDone); + const nextServerSettings = { + ...initialServerSettings, + providerHealthRefreshInterval: Duration.seconds(1), + }; + yield* Ref.set(serverSettingsRef, nextServerSettings); + yield* PubSub.publish(serverSettingsChanges, nextServerSettings); + yield* Effect.yieldNow; + + yield* TestClock.adjust("999 millis"); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + yield* TestClock.adjust("1 millis"); + yield* Deferred.await(periodicCheckDone); + assert.strictEqual(yield* Ref.get(checkCalls), 2); + }), + ).pipe(Effect.provide(TestClock.layer())), ); it.effect("reruns the provider check when streamed settings change", () => @@ -185,7 +352,7 @@ describe("makeManagedServerProvider", () => { assert.deepStrictEqual(latest, refreshedSnapshotSecond); assert.strictEqual(yield* Ref.get(checkCalls), 2); }), - ), + ).pipe(Effect.provide(AlwaysRunTestLayer)), ); it.effect("streams supplemental snapshot updates after the base provider check completes", () => @@ -223,7 +390,7 @@ describe("makeManagedServerProvider", () => { assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); assert.deepStrictEqual(latest, enrichedSnapshot); }), - ), + ).pipe(Effect.provide(AlwaysRunTestLayer)), ); it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => @@ -284,6 +451,6 @@ describe("makeManagedServerProvider", () => { ]); assert.deepStrictEqual(latest, enrichedSnapshotSecond); }), - ), + ).pipe(Effect.provide(AlwaysRunTestLayer)), ); }); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index bbf301fa4077..d2b6b52e8f1c 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,16 +1,23 @@ -import type { ServerProvider } from "@t3tools/contracts"; +import { + DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, + type ServerProvider, + ServerSettingsError, +} from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Fiber from "effect/Fiber"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; -import { ServerSettingsError } from "@t3tools/contracts"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; @@ -33,7 +40,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; }) => Effect.Effect; readonly refreshInterval?: Duration.Input; -}): Effect.fn.Return { +}): Effect.fn.Return< + ServerProviderShape, + ServerSettingsError, + Scope.Scope | BackgroundPolicy.BackgroundPolicy | ServerSettingsService +> { + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const serverSettings = yield* ServerSettingsService; const refreshSemaphore = yield* Semaphore.make(1); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), @@ -134,13 +147,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const hasProviderStatusDemand = Effect.gen(function* () { + const state = yield* Ref.get(snapshotStateRef); + const instanceId = state.snapshot.instanceId; + const [genericDemand, instanceDemand] = yield* Effect.all([ + backgroundPolicy.shouldRunScopeWork({ type: "provider-status" }), + backgroundPolicy.shouldRunScopeWork({ type: "provider-status", instanceId }), + ]); + return genericDemand || instanceDemand; + }); + + const getRefreshInterval = + input.refreshInterval !== undefined + ? Effect.succeed(input.refreshInterval) + : serverSettings.getSettings.pipe( + Effect.map( + (settings) => + resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, + ), + Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL), + ); + + const refreshIntervalChanges = yield* Queue.sliding(1); + if (input.refreshInterval === undefined) { + const serverSettingsChanges = yield* serverSettings.subscribeChanges; + yield* serverSettingsChanges.pipe( + Stream.map((settings) => + Duration.toMillis( + resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, + ), + ), + Stream.changes, + Stream.runForEach(() => Queue.offer(refreshIntervalChanges, undefined).pipe(Effect.asVoid)), + Effect.forkScoped, + ); + } + yield* Stream.runForEach(input.streamSettings, (nextSettings) => Effect.asVoid(applySnapshot(nextSettings)), ).pipe(Effect.forkScoped); yield* Effect.forever( - Effect.sleep(input.refreshInterval ?? "60 seconds").pipe( - Effect.flatMap(() => refreshSnapshot()), + getRefreshInterval.pipe( + Effect.flatMap((refreshInterval) => + Effect.raceFirst( + Effect.sleep( + Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) <= 0 + ? "60 seconds" + : refreshInterval, + ).pipe(Effect.as(true)), + Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), + ).pipe( + Effect.flatMap((intervalElapsed) => + intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 + ? hasProviderStatusDemand.pipe( + Effect.flatMap((shouldRefresh) => + shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, + ), + ) + : Effect.void, + ), + ), + ), Effect.ignoreCause({ log: true }), ), ).pipe(Effect.forkScoped); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts new file mode 100644 index 000000000000..50c8ea2b7632 --- /dev/null +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts @@ -0,0 +1,105 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import { assert, describe, expect } from "vite-plus/test"; + +import { + type DesktopTelemetryReceiverHealth, + initialDesktopTelemetryContactAt, + isDesktopTelemetryContactStale, + recordDesktopTelemetrySampleHealth, + requireDesktopTelemetryWriteProgress, + resolveDesktopTelemetrySnapshotStaleAfterMs, + writeAllToFileDescriptor, +} from "./DesktopTelemetryReceiver.ts"; + +describe("DesktopTelemetryReceiver", () => { + it("degrades a hello-only stream after the first-sample deadline", () => { + expect(isDesktopTelemetryContactStale(Option.some(1_000), 90_999)).toBe(false); + expect(isDesktopTelemetryContactStale(Option.some(1_000), 91_000)).toBe(true); + expect(isDesktopTelemetryContactStale(Option.none(), 1_000_000)).toBe(false); + }); + + it("starts the stale deadline as soon as a telemetry descriptor is opened", () => { + expect(initialDesktopTelemetryContactAt(7, 1_000)).toEqual(Option.some(1_000)); + expect(initialDesktopTelemetryContactAt(undefined, 1_000)).toEqual(Option.none()); + }); + + it("keeps the snapshot deadline beyond the configured idle polling interval", () => { + expect(resolveDesktopTelemetrySnapshotStaleAfterMs(30_000, 120_000)).toBe(150_000); + expect(resolveDesktopTelemetrySnapshotStaleAfterMs(60_000, 600_000)).toBe(630_000); + expect(resolveDesktopTelemetrySnapshotStaleAfterMs(1_000, 1_000)).toBe(90_000); + }); + + it.effect("publishes the latest sample timestamp while health remains healthy", () => + Effect.scoped( + Effect.gen(function* () { + const initialSample = DateTime.makeUnsafe(1_000); + const nextSample = DateTime.makeUnsafe(2_000); + const health = yield* Ref.make({ + status: "healthy", + lastSampleAt: Option.some(initialSample), + lastError: Option.none(), + }); + const healthChanges = yield* PubSub.sliding(4); + const subscription = yield* PubSub.subscribe(healthChanges); + + yield* recordDesktopTelemetrySampleHealth(health, healthChanges, nextSample); + const published = yield* PubSub.take(subscription).pipe(Effect.timeout("1 second")); + + expect(DateTime.toEpochMillis(Option.getOrThrow(published.lastSampleAt))).toBe(2_000); + }), + ), + ); + + it.effect("writes control messages through the asynchronous descriptor path", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const directory = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-desktop-telemetry-control-test-"), + ); + const path = NodePath.join(directory, "control.ndjson"); + return { + directory, + path, + fd: NodeFS.openSync(path, "w"), + }; + }), + ({ fd, path }) => + Effect.gen(function* () { + const payload = Buffer.from('{"type":"setDiagnosticsDemand","enabled":true}\n'); + yield* writeAllToFileDescriptor(fd, payload); + NodeFS.fsyncSync(fd); + + assert.equal(NodeFS.readFileSync(path, "utf8"), payload.toString("utf8")); + }), + ({ directory, fd }) => + Effect.sync(() => { + NodeFS.closeSync(fd); + NodeFS.rmSync(directory, { recursive: true, force: true }); + }), + ), + ); + + it.effect("models a zero-byte control write as a stalled descriptor", () => + Effect.gen(function* () { + const error = yield* requireDesktopTelemetryWriteProgress(7, 42, 0).pipe(Effect.flip); + + expect(error._tag).toBe("DesktopTelemetryControlStalled"); + expect(error.fd).toBe(7); + expect(error.remainingBytes).toBe(42); + expect(error.message).toBe( + "Desktop telemetry control stalled on fd 7 with 42 bytes remaining.", + ); + yield* requireDesktopTelemetryWriteProgress(7, 42, 1); + }), + ); +}); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts new file mode 100644 index 000000000000..1fca5696ab67 --- /dev/null +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -0,0 +1,662 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; + +import * as NodeStream from "@effect/platform-node/NodeStream"; +import { + DesktopHostTelemetryMessage, + type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, + type DesktopHostTelemetrySnapshot, + DesktopTelemetryControlMessage, + type ResourceTelemetrySourceStatus, +} from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Ndjson from "effect/unstable/encoding/Ndjson"; + +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts"; + +const INITIAL_SAMPLE_DEADLINE_MS = 90_000; +const MIN_SNAPSHOT_STALE_AFTER_MS = 90_000; +const STALE_GRACE_MS = 30_000; +const DEFAULT_HOST_POWER_ACTIVE_INTERVAL_MS = 30_000; +const DEFAULT_HOST_POWER_IDLE_INTERVAL_MS = 120_000; +const STALE_CHECK_INTERVAL = Duration.seconds(30); + +export class DesktopTelemetryDescriptorUnavailable extends Schema.TaggedErrorClass()( + "DesktopTelemetryDescriptorUnavailable", + { + mode: Schema.String, + }, +) { + override get message(): string { + return `Desktop telemetry descriptor is unavailable in '${this.mode}' mode.`; + } +} + +export class DesktopTelemetryProtocolMismatch extends Schema.TaggedErrorClass()( + "DesktopTelemetryProtocolMismatch", + { + expectedVersion: Schema.Number, + receivedVersion: Schema.Number, + }, +) { + override get message(): string { + return `Desktop telemetry protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`; + } +} + +export class DesktopTelemetryDecodeFailed extends Schema.TaggedErrorClass()( + "DesktopTelemetryDecodeFailed", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to decode desktop telemetry."; + } +} + +export class DesktopTelemetryStreamFailed extends Schema.TaggedErrorClass()( + "DesktopTelemetryStreamFailed", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop telemetry stream on fd ${this.fd} failed.`; + } +} + +export class DesktopTelemetryStreamClosed extends Schema.TaggedErrorClass()( + "DesktopTelemetryStreamClosed", + { + fd: Schema.Number, + }, +) { + override get message(): string { + return `Desktop telemetry stream on fd ${this.fd} closed.`; + } +} + +export class DesktopTelemetryStale extends Schema.TaggedErrorClass()( + "DesktopTelemetryStale", + { + fd: Schema.Number, + staleAfterMs: Schema.Number, + }, +) { + override get message(): string { + return `Desktop telemetry on fd ${this.fd} has not updated for ${this.staleAfterMs}ms.`; + } +} + +export type DesktopTelemetryReceiverError = + | DesktopTelemetryDescriptorUnavailable + | DesktopTelemetryProtocolMismatch + | DesktopTelemetryDecodeFailed + | DesktopTelemetryStreamFailed + | DesktopTelemetryStreamClosed; + +export class DesktopTelemetryControlFailed extends Schema.TaggedErrorClass()( + "DesktopTelemetryControlFailed", + { + fd: Schema.Number, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop telemetry control '${this.operation}' failed on fd ${this.fd}.`; + } +} + +export class DesktopTelemetryControlStalled extends Schema.TaggedErrorClass()( + "DesktopTelemetryControlStalled", + { + fd: Schema.Number, + remainingBytes: Schema.Number, + }, +) { + override get message(): string { + return `Desktop telemetry control stalled on fd ${this.fd} with ${this.remainingBytes} bytes remaining.`; + } +} + +export type DesktopTelemetryControlError = + | DesktopTelemetryControlFailed + | DesktopTelemetryControlStalled; + +export interface DesktopTelemetryReceiverHealth { + readonly status: ResourceTelemetrySourceStatus; + readonly lastSampleAt: Option.Option; + readonly lastError: Option.Option; +} + +export class DesktopTelemetryReceiver extends Context.Service< + DesktopTelemetryReceiver, + { + readonly latest: Effect.Effect>; + readonly changes: Stream.Stream; + readonly subscribe: Effect.Effect< + { + readonly latest: Option.Option; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; + readonly health: Effect.Effect; + readonly subscribeHealth: Effect.Effect< + { + readonly latest: DesktopTelemetryReceiverHealth; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; + readonly setDiagnosticsDemand: ( + enabled: boolean, + ) => Effect.Effect; + } +>()("t3/resourceTelemetry/DesktopTelemetryReceiver") {} + +const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage); +const encodeControlMessage = Schema.encodeEffect( + Schema.fromJsonString(DesktopTelemetryControlMessage), +); +const isDescriptorUnavailable = Schema.is(DesktopTelemetryDescriptorUnavailable); +const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch); +const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed); +const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed); + +export function isDesktopTelemetryContactStale( + lastContactAtMs: Option.Option, + nowMs: number, +): boolean { + return Option.exists( + lastContactAtMs, + (lastContact) => nowMs - lastContact >= INITIAL_SAMPLE_DEADLINE_MS, + ); +} + +export function resolveDesktopTelemetrySnapshotStaleAfterMs( + activeIntervalMs: number, + idleIntervalMs: number, +): number { + return Math.max( + MIN_SNAPSHOT_STALE_AFTER_MS, + Math.max(activeIntervalMs, idleIntervalMs) + STALE_GRACE_MS, + ); +} + +export function initialDesktopTelemetryContactAt( + desktopTelemetryFd: number | undefined, + nowMs: number, +): Option.Option { + return desktopTelemetryFd === undefined ? Option.none() : Option.some(nowMs); +} + +export const recordDesktopTelemetrySampleHealth = Effect.fn( + "resourceTelemetry.desktopTelemetryReceiver.recordSampleHealth", +)(function* ( + health: Ref.Ref, + healthChanges: PubSub.PubSub, + sampledAt: DateTime.Utc, +) { + const next: DesktopTelemetryReceiverHealth = { + status: "healthy", + lastSampleAt: Option.some(sampledAt), + lastError: Option.none(), + }; + yield* Ref.set(health, next); + yield* PubSub.publish(healthChanges, next); +}); + +function normalizeReceiverError(error: unknown): DesktopTelemetryReceiverError { + if ( + isDescriptorUnavailable(error) || + isProtocolMismatch(error) || + isDecodeFailed(error) || + isStreamFailed(error) + ) { + return error; + } + return new DesktopTelemetryDecodeFailed({ cause: error }); +} + +function messageVersion(value: unknown): number | undefined { + if (typeof value !== "object" || value === null) return undefined; + const version = Reflect.get(value, "version"); + return typeof version === "number" ? version : undefined; +} + +export const writeAllToFileDescriptor = Effect.fn( + "resourceTelemetry.desktopTelemetryReceiver.writeAllToFileDescriptor", +)(function* (fd: number, payload: Buffer) { + let offset = 0; + while (offset < payload.byteLength) { + const written = yield* Effect.callback( + (resume, signal) => { + if (signal.aborted) return; + try { + NodeFS.write( + fd, + payload, + offset, + payload.byteLength - offset, + null, + (error, bytesWritten) => { + if (error) { + resume( + Effect.fail( + new DesktopTelemetryControlFailed({ + fd, + operation: "write", + cause: error, + }), + ), + ); + return; + } + resume(Effect.succeed(bytesWritten)); + }, + ); + } catch (cause) { + resume( + Effect.fail( + new DesktopTelemetryControlFailed({ + fd, + operation: "write", + cause, + }), + ), + ); + } + }, + ); + yield* requireDesktopTelemetryWriteProgress(fd, payload.byteLength - offset, written); + offset += written; + } +}); + +export function requireDesktopTelemetryWriteProgress( + fd: number, + remainingBytes: number, + written: number, +): Effect.Effect { + return written > 0 + ? Effect.void + : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes })); +} + +export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { + const config = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const latest = yield* Ref.make(Option.none()); + const receiverStartedAt = yield* DateTime.now; + const lastContactAtMs = yield* Ref.make( + initialDesktopTelemetryContactAt( + config.desktopTelemetryFd, + DateTime.toEpochMillis(receiverStartedAt), + ), + ); + const snapshotStaleAfterMs = yield* Ref.make( + resolveDesktopTelemetrySnapshotStaleAfterMs( + DEFAULT_HOST_POWER_ACTIVE_INTERVAL_MS, + DEFAULT_HOST_POWER_IDLE_INTERVAL_MS, + ), + ); + const changes = yield* PubSub.sliding(8); + const healthChanges = yield* PubSub.sliding(4); + const controlMutex = yield* Semaphore.make(1); + const snapshotMutex = yield* Semaphore.make(1); + const health = yield* Ref.make({ + status: config.desktopTelemetryFd === undefined ? "unavailable" : "starting", + lastSampleAt: Option.none(), + lastError: + config.desktopTelemetryFd === undefined + ? Option.some( + new DesktopTelemetryDescriptorUnavailable({ + mode: config.mode, + }).message, + ) + : Option.none(), + }); + const updateHealth = ( + update: (current: DesktopTelemetryReceiverHealth) => DesktopTelemetryReceiverHealth, + ) => + Ref.modify(health, (current) => { + const next = update(current); + return [next, next]; + }).pipe( + Effect.flatMap((next) => PubSub.publish(healthChanges, next)), + Effect.asVoid, + ); + const updateSampleHealth = (sampledAt: DateTime.Utc) => + recordDesktopTelemetrySampleHealth(health, healthChanges, sampledAt); + + const sendControlMessage = (message: DesktopTelemetryControlMessage) => + controlMutex.withPermits(1)( + Effect.gen(function* () { + const fd = config.desktopTelemetryControlFd; + if (fd === undefined) return; + const encoded = yield* encodeControlMessage(message).pipe( + Effect.mapError( + (cause) => + new DesktopTelemetryControlFailed({ + fd, + operation: "encode", + cause, + }), + ), + ); + yield* writeAllToFileDescriptor(fd, Buffer.from(`${encoded}\n`)).pipe( + Effect.tapError((error) => + updateHealth((current) => ({ + ...current, + status: "degraded", + lastError: Option.some(error.message), + })), + ), + ); + }), + ); + const setDiagnosticsDemand: DesktopTelemetryReceiver["Service"]["setDiagnosticsDemand"] = ( + enabled, + ) => + sendControlMessage({ + version: 1, + type: "setDiagnosticsDemand", + enabled, + }); + + const sendHostPowerIntervals = ( + settings: Parameters[0], + ) => { + const resolved = resolveServerBackgroundActivitySettings(settings); + const activeIntervalMs = Math.max( + 1, + Math.round(Duration.toMillis(resolved.hostPowerMonitorActiveInterval)), + ); + const idleIntervalMs = Math.max( + 1, + Math.round(Duration.toMillis(resolved.hostPowerMonitorIdleInterval)), + ); + return sendControlMessage({ + version: 1, + type: "setHostPowerIntervals", + activeIntervalMs, + idleIntervalMs, + }).pipe( + Effect.andThen( + Ref.set( + snapshotStaleAfterMs, + resolveDesktopTelemetrySnapshotStaleAfterMs(activeIntervalMs, idleIntervalMs), + ), + ), + ); + }; + if (config.desktopTelemetryControlFd !== undefined) { + const settingsChanges = yield* serverSettings.subscribeChanges; + const settings = yield* serverSettings.getSettings; + yield* sendHostPowerIntervals(settings).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to configure desktop host-power intervals", { + cause: String(cause), + }), + ), + ); + yield* settingsChanges.pipe( + Stream.runForEach((settings) => + sendHostPowerIntervals(settings).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to update desktop host-power intervals", { + cause: String(cause), + }), + ), + ), + ), + Effect.forkScoped, + ); + } + + if (config.desktopTelemetryFd !== undefined) { + const fd = config.desktopTelemetryFd; + const readable = yield* Effect.acquireRelease( + Effect.try({ + try: () => + NodeFS.createReadStream("", { + fd, + autoClose: true, + }), + catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), + }), + (stream) => + Effect.sync(() => { + stream.destroy(); + }), + ); + + const messages: Stream.Stream = + NodeStream.fromReadable({ + evaluate: () => readable, + closeOnDone: true, + onError: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), + }).pipe( + Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), + Stream.mapEffect( + ( + value, + ): Effect.Effect< + DesktopHostTelemetryMessageValue, + DesktopTelemetryProtocolMismatch | DesktopTelemetryDecodeFailed + > => { + const version = messageVersion(value); + if (version !== undefined && version !== 1) { + return Effect.fail( + new DesktopTelemetryProtocolMismatch({ + expectedVersion: 1, + receivedVersion: version, + }), + ); + } + return decodeMessage(value).pipe( + Effect.mapError((cause) => new DesktopTelemetryDecodeFailed({ cause })), + ); + }, + ), + Stream.mapError(normalizeReceiverError), + ); + + yield* messages.pipe( + Stream.runForEach((message) => { + const recordContact = DateTime.now.pipe( + Effect.flatMap((now) => + Ref.set(lastContactAtMs, Option.some(DateTime.toEpochMillis(now))), + ), + ); + if (message.type === "desktopTelemetryHello") { + return recordContact.pipe( + Effect.andThen( + updateHealth( + (current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "healthy", + lastError: Option.none(), + }), + ), + ), + ); + } + + const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs); + return snapshotMutex.withPermits(1)( + recordContact.pipe( + Effect.andThen(Ref.set(latest, Option.some(message))), + Effect.andThen(updateSampleHealth(sampledAt)), + Effect.andThen(PubSub.publish(changes, message)), + Effect.asVoid, + ), + ); + }), + Effect.andThen( + updateHealth( + (current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "stopped", + lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message), + }), + ), + ), + Effect.catch((error) => + updateHealth( + (current): DesktopTelemetryReceiverHealth => ({ + ...current, + status: "degraded", + lastError: Option.some(error.message), + }), + ), + ), + Effect.forkScoped, + ); + + yield* Effect.forever( + Effect.sleep(STALE_CHECK_INTERVAL).pipe( + Effect.andThen( + snapshotMutex.withPermits(1)( + Effect.gen(function* () { + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const staleAfterMs = yield* Ref.get(snapshotStaleAfterMs); + const staleSnapshot = yield* Ref.modify(latest, (current) => { + if ( + Option.isNone(current) || + current.value.power.stale || + nowMs - current.value.sampledAtUnixMs < staleAfterMs + ) { + return [Option.none(), current] as const; + } + const stale: DesktopHostTelemetrySnapshot = { + ...current.value, + power: { ...current.value.power, stale: true }, + }; + return [Option.some(stale), Option.some(stale)] as const; + }); + if (Option.isNone(staleSnapshot)) { + const lastContact = yield* Ref.get(lastContactAtMs); + if (!isDesktopTelemetryContactStale(lastContact, nowMs)) return; + const staleMessage = new DesktopTelemetryStale({ + fd, + staleAfterMs: INITIAL_SAMPLE_DEADLINE_MS, + }).message; + const changed = yield* Ref.modify(health, (current) => { + if ( + current.status === "stopped" || + Option.isSome(current.lastSampleAt) || + (current.status === "degraded" && + Option.contains(current.lastError, staleMessage)) + ) { + return [Option.none(), current] as const; + } + const next: DesktopTelemetryReceiverHealth = { + ...current, + status: "degraded", + lastError: Option.some(staleMessage), + }; + return [Option.some(next), next] as const; + }); + if (Option.isSome(changed)) { + yield* PubSub.publish(healthChanges, changed.value); + } + return; + } + yield* updateHealth((currentHealth) => ({ + ...currentHealth, + status: currentHealth.status === "stopped" ? "stopped" : "degraded", + lastError: + currentHealth.status === "stopped" + ? currentHealth.lastError + : Option.some(new DesktopTelemetryStale({ fd, staleAfterMs }).message), + })); + yield* PubSub.publish(changes, staleSnapshot.value); + }), + ), + ), + ), + ).pipe(Effect.forkScoped); + } + + return DesktopTelemetryReceiver.of({ + latest: Ref.get(latest), + changes: Stream.fromPubSub(changes), + subscribe: snapshotMutex.withPermits(1)( + Effect.gen(function* () { + const initial = yield* Ref.get(latest); + const subscription = yield* PubSub.subscribe(changes); + return { + latest: initial, + changes: Stream.fromSubscription(subscription), + }; + }), + ), + health: Ref.get(health), + subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), + setDiagnosticsDemand, + }); +}); + +export const layer = Layer.effect(DesktopTelemetryReceiver, make()); + +export const layerTest = ( + overrides: Partial = {}, +): Layer.Layer => { + const latest = overrides.latest ?? Effect.succeedNone; + const changes = overrides.changes ?? Stream.empty; + const health = + overrides.health ?? + Effect.succeed({ + status: "unavailable" as const, + lastSampleAt: Option.none(), + lastError: Option.some("Desktop telemetry test implementation is unavailable."), + }); + return Layer.succeed( + DesktopTelemetryReceiver, + DesktopTelemetryReceiver.of({ + latest, + changes, + subscribe: + overrides.subscribe ?? + latest.pipe( + Effect.map((initial) => ({ + latest: initial, + changes, + })), + ), + health, + subscribeHealth: + overrides.subscribeHealth ?? + health.pipe( + Effect.map((initial) => ({ + latest: initial, + changes: Stream.empty, + })), + ), + setDiagnosticsDemand: () => Effect.void, + ...overrides, + }), + ); +}; diff --git a/apps/server/src/resourceTelemetry/Model.test.ts b/apps/server/src/resourceTelemetry/Model.test.ts new file mode 100644 index 000000000000..94690e3967bc --- /dev/null +++ b/apps/server/src/resourceTelemetry/Model.test.ts @@ -0,0 +1,581 @@ +import { + type DesktopElectronProcessMetric, + type DesktopHostTelemetrySnapshot, + type ResourceMonitorProcessSample, + type ResourceMonitorSnapshotEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +import { emptyTelemetryCounters, mergeProcesses, type MergeProcessesResult } from "./Model.ts"; + +const SERVER_PID = 100; +const BASE_TIME_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z")); + +function processSample( + input: Partial & + Pick, +): ResourceMonitorProcessSample { + return { + runTimeMs: 1_000, + name: `process-${input.pid}`, + command: `process-${input.pid}`, + status: "Running", + cpuPercent: 0, + cpuTimeMs: 0, + residentBytes: 1_024, + virtualBytes: 2_048, + ioReadBytes: 0, + ioWriteBytes: 0, + ioSemantics: "storage", + ...input, + }; +} + +function nativeSnapshot( + sampledAtUnixMs: number, + processes: ReadonlyArray, + sequence = 1, +): ResourceMonitorSnapshotEvent { + return { + version: 2, + type: "snapshot", + sequence, + sampledAtUnixMs, + collectionDurationMicros: 250, + scannedProcessCount: processes.length, + retainedProcessCount: processes.length, + inaccessibleProcessCount: 0, + processes: [...processes], + }; +} + +function electronMetric( + input: Partial & + Pick, +): DesktopElectronProcessMetric { + return { + cpuPercent: 0, + idleWakeupsPerSecond: 0, + workingSetBytes: 1_024, + peakWorkingSetBytes: 2_048, + ...input, + }; +} + +function desktopSnapshot( + sampledAtUnixMs: number, + electronProcesses: ReadonlyArray, +): DesktopHostTelemetrySnapshot { + const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); + return { + version: 1, + type: "desktopTelemetry", + sequence: 1, + sampledAtUnixMs, + electronPid: electronProcesses[0]?.pid ?? 10_000, + power: { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "false", + lowPowerMode: "unknown", + thermalState: "nominal", + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: Option.none(), + electronProcesses: [...electronProcesses], + }; +} + +function merge(input: { + readonly native: ResourceMonitorSnapshotEvent; + readonly desktop?: DesktopHostTelemetrySnapshot; + readonly previous?: MergeProcessesResult; + readonly sidecarPid?: number; +}): MergeProcessesResult { + return mergeProcesses({ + serverPid: SERVER_PID, + sidecarPid: Option.fromUndefinedOr(input.sidecarPid), + fallbackSampledAtMs: input.native.sampledAtUnixMs, + nativeSnapshot: Option.some(input.native), + desktopSnapshot: Option.fromUndefinedOr(input.desktop), + previous: input.previous?.previous ?? new Map(), + counters: input.previous?.counters ?? emptyTelemetryCounters(), + updatePrevious: true, + }); +} + +describe("resource telemetry process model", () => { + it("builds complete descendant depths and isolates monitor overhead", () => { + const result = merge({ + sidecarPid: 900, + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }), + processSample({ pid: 201, ppid: 200, startTimeMs: 3_000 }), + processSample({ pid: 202, ppid: 201, startTimeMs: 4_000 }), + processSample({ pid: 900, ppid: SERVER_PID, startTimeMs: 5_000 }), + ]), + }); + + expect(result.processes.map((process) => [process.identity.pid, process.depth])).toEqual([ + [100, 0], + [200, 1], + [201, 2], + [202, 3], + [900, 1], + ]); + expect(result.processes.find((process) => process.identity.pid === 900)?.category).toBe( + "resource-monitor", + ); + expect(result.groups.backend.processCount).toBe(4); + expect(result.groups.monitor.processCount).toBe(1); + expect(result.groups.monitor.processStarts).toBe(1); + expect(result.groups.allT3.processStarts).toBe(5); + }); + + it("deduplicates Electron metrics and classifies Electron descendants", () => { + const electronStart = 10_000; + const result = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + processSample({ pid: 300, ppid: 1, startTimeMs: electronStart }), + processSample({ pid: 301, ppid: 300, startTimeMs: electronStart + 1 }), + ]), + desktop: desktopSnapshot(BASE_TIME_MS, [ + electronMetric({ + pid: 300, + creationTimeMs: electronStart + 500, + type: "Browser", + name: "electron", + }), + electronMetric({ + pid: 301, + creationTimeMs: electronStart + 500, + type: "Utility", + name: "network-service", + }), + ]), + }); + + expect(result.processes.filter((process) => process.identity.pid === 300)).toHaveLength(1); + expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe( + "electron-main", + ); + expect(result.processes.find((process) => process.identity.pid === 301)?.category).toBe( + "electron-utility", + ); + expect(result.processes.find((process) => process.identity.pid === 301)?.depth).toBe(1); + expect(result.groups.electron.processCount).toBe(2); + }); + + it("ignores stale Electron metrics after PID reuse", () => { + const result = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 50_000 }), + ]), + desktop: desktopSnapshot(BASE_TIME_MS, [ + electronMetric({ + pid: 300, + creationTimeMs: 10_000, + type: "Browser", + }), + ]), + }); + + expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe( + "server-child", + ); + expect(result.groups.electron.processCount).toBe(0); + }); + + it("derives cumulative CPU time for synthetic Electron-only processes", () => { + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + ]), + desktop: desktopSnapshot(BASE_TIME_MS, [ + electronMetric({ + pid: 300, + creationTimeMs: 10_000, + type: "Browser", + cpuPercent: 50, + }), + ]), + }); + const second = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 1_000, + [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })], + 2, + ), + desktop: desktopSnapshot(BASE_TIME_MS + 1_000, [ + electronMetric({ + pid: 300, + creationTimeMs: 10_000, + type: "Browser", + cpuPercent: 50, + }), + ]), + }); + + expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(500); + expect(second.groups.electron.cpuTimeMs).toBe(500); + }); + + it("uses the native timestamp for native cumulative-counter deltas", () => { + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_000, + }), + ]), + desktop: desktopSnapshot(BASE_TIME_MS + 10_000, []), + }); + const second = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 1_000, + [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_500, + }), + ], + 2, + ), + desktop: desktopSnapshot(BASE_TIME_MS + 11_000, []), + }); + + expect(second.processes[0]?.cpuPercent).toBe(50); + expect(second.groups.backend.cpuTimeMs).toBe(500); + }); + + it("does not advance synthetic CPU time when reusing the same desktop sample", () => { + const metric = electronMetric({ + pid: 300, + creationTimeMs: 10_000, + type: "Browser", + cpuPercent: 50, + }); + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + ]), + desktop: desktopSnapshot(BASE_TIME_MS, [metric]), + }); + const second = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 1_000, + [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })], + 2, + ), + desktop: desktopSnapshot(BASE_TIME_MS, [metric]), + }); + + expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(0); + expect(second.groups.electron.cpuTimeMs).toBe(0); + }); + + it("does not apply an explicit Electron root to a reused PID", () => { + const first = mergeProcesses({ + serverPid: SERVER_PID, + sidecarPid: Option.none(), + electronRootPids: new Set([300]), + fallbackSampledAtMs: BASE_TIME_MS, + nativeSnapshot: Option.some( + nativeSnapshot(BASE_TIME_MS, [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + processSample({ pid: 300, ppid: 1, startTimeMs: 10_000 }), + ]), + ), + desktopSnapshot: Option.some( + desktopSnapshot(BASE_TIME_MS, [ + electronMetric({ + pid: 300, + creationTimeMs: 10_000, + type: "Browser", + }), + ]), + ), + previous: new Map(), + counters: emptyTelemetryCounters(), + updatePrevious: true, + }); + const reused = mergeProcesses({ + serverPid: SERVER_PID, + sidecarPid: Option.none(), + electronRootPids: new Set([300]), + fallbackSampledAtMs: BASE_TIME_MS + 1_000, + nativeSnapshot: Option.some( + nativeSnapshot( + BASE_TIME_MS + 1_000, + [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 20_000 }), + ], + 2, + ), + ), + desktopSnapshot: Option.none(), + previous: first.previous, + counters: first.counters, + updatePrevious: true, + }); + + expect(reused.processes.find((process) => process.identity.pid === 300)?.category).toBe( + "server-child", + ); + expect(reused.groups.electron.processCount).toBe(0); + }); + + it("derives rates from cumulative counters and preserves I/O semantics", () => { + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_000, + ioReadBytes: 10_000, + ioWriteBytes: 20_000, + ioSemantics: "all-io", + }), + ]), + }); + const second = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 1_000, + [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_250, + ioReadBytes: 12_000, + ioWriteBytes: 23_000, + ioSemantics: "all-io", + }), + ], + 2, + ), + }); + const server = second.processes[0]!; + + expect(server.cpuPercent).toBe(25); + expect(server.ioReadBytesPerSecond).toBe(2_000); + expect(server.ioWriteBytesPerSecond).toBe(3_000); + expect(server.ioSemantics).toBe("all-io"); + expect(second.groups.backend.cpuTimeMs).toBe(250); + expect(second.groups.backend.ioReadBytes).toBe(2_000); + expect(second.groups.backend.ioWriteBytes).toBe(3_000); + }); + + it("derives deltas at the constrained 15-second sampling cadence", () => { + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_000, + ioReadBytes: 10_000, + ioWriteBytes: 20_000, + }), + ]), + }); + const second = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 15_000, + [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 2_500, + ioReadBytes: 25_000, + ioWriteBytes: 50_000, + }), + ], + 2, + ), + }); + + expect(second.processes[0]?.cpuPercent).toBe(10); + expect(second.processes[0]?.ioReadBytesPerSecond).toBe(1_000); + expect(second.processes[0]?.ioWriteBytesPerSecond).toBe(2_000); + expect(second.groups.backend.cpuTimeMs).toBe(1_500); + expect(second.groups.backend.ioReadBytes).toBe(15_000); + expect(second.groups.backend.ioWriteBytes).toBe(30_000); + }); + + it("preserves native rates while applying a desktop-only update", () => { + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_000, + ioReadBytes: 10_000, + ioWriteBytes: 20_000, + }), + ]), + }); + const second = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 1_000, + [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_250, + ioReadBytes: 12_000, + ioWriteBytes: 23_000, + }), + ], + 2, + ), + }); + const desktopOnly = mergeProcesses({ + serverPid: SERVER_PID, + sidecarPid: Option.none(), + fallbackSampledAtMs: BASE_TIME_MS + 1_000, + nativeSnapshot: Option.some( + nativeSnapshot( + BASE_TIME_MS + 1_000, + [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_250, + ioReadBytes: 12_000, + ioWriteBytes: 23_000, + }), + ], + 2, + ), + ), + desktopSnapshot: Option.some(desktopSnapshot(BASE_TIME_MS + 1_500, [])), + previous: second.previous, + counters: second.counters, + updatePrevious: false, + }); + + expect(desktopOnly.processes[0]?.cpuPercent).toBe(25); + expect(desktopOnly.processes[0]?.ioReadBytesPerSecond).toBe(2_000); + expect(desktopOnly.processes[0]?.ioWriteBytesPerSecond).toBe(3_000); + expect(desktopOnly.sampledAtMs).toBe(BASE_TIME_MS + 1_500); + }); + + it("resets deltas when counters decrease or the sampling gap is unsafe", () => { + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 1_000, + ioReadBytes: 10_000, + ioWriteBytes: 20_000, + }), + ]), + }); + const decreased = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 1_000, + [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 100, + ioReadBytes: 100, + ioWriteBytes: 200, + }), + ], + 2, + ), + }); + const delayed = merge({ + previous: decreased, + native: nativeSnapshot( + BASE_TIME_MS + 90_000, + [ + processSample({ + pid: SERVER_PID, + ppid: 1, + startTimeMs: 1_000, + cpuTimeMs: 10_000, + ioReadBytes: 100_000, + ioWriteBytes: 200_000, + }), + ], + 3, + ), + }); + + expect(decreased.processes[0]?.cpuPercent).toBe(0); + expect(decreased.processes[0]?.ioReadBytesPerSecond).toBe(0); + expect(decreased.processes[0]?.ioWriteBytesPerSecond).toBe(0); + expect(delayed.processes[0]?.cpuPercent).toBe(0); + expect(delayed.processes[0]?.ioReadBytesPerSecond).toBe(0); + expect(delayed.processes[0]?.ioWriteBytesPerSecond).toBe(0); + expect(delayed.groups.backend.cpuTimeMs).toBe(0); + expect(delayed.groups.backend.ioReadBytes).toBe(0); + expect(delayed.groups.backend.ioWriteBytes).toBe(0); + }); + + it("treats reused PIDs as an exit plus a new process", () => { + const first = merge({ + native: nativeSnapshot(BASE_TIME_MS, [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }), + ]), + }); + const second = merge({ + previous: first, + native: nativeSnapshot( + BASE_TIME_MS + 1_000, + [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), + processSample({ + pid: 200, + ppid: SERVER_PID, + startTimeMs: 9_000, + cpuTimeMs: 999, + ioReadBytes: 999, + ioWriteBytes: 999, + }), + ], + 2, + ), + }); + const reused = second.processes.find((process) => process.identity.pid === 200)!; + + expect(reused.identity.startTimeMs).toBe(9_000); + expect(reused.cpuPercent).toBe(0); + expect(reused.ioReadBytesPerSecond).toBe(0); + expect(second.groups.backend.processStarts).toBe(3); + expect(second.groups.backend.processExits).toBe(1); + }); +}); diff --git a/apps/server/src/resourceTelemetry/Model.ts b/apps/server/src/resourceTelemetry/Model.ts new file mode 100644 index 000000000000..a198c4b311f7 --- /dev/null +++ b/apps/server/src/resourceTelemetry/Model.ts @@ -0,0 +1,608 @@ +import type { + DesktopElectronProcessMetric, + DesktopHostTelemetrySnapshot, + ResourceMonitorProcessSample, + ResourceMonitorSnapshotEvent, + ResourceTelemetryAggregate, + ResourceTelemetryProcess, + ResourceTelemetryProcessCategory, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +const MAX_DELTA_INTERVAL_MS = 30_000; +const ELECTRON_IDENTITY_TOLERANCE_MS = 2_000; + +export interface ProcessState { + readonly process: ResourceTelemetryProcess; + readonly sampledAtMs: number; +} + +export interface GroupCounters { + readonly cpuTimeMs: number; + readonly ioReadBytes: number; + readonly ioWriteBytes: number; + readonly processStarts: number; + readonly processExits: number; +} + +export interface TelemetryCounters { + readonly backend: GroupCounters; + readonly electron: GroupCounters; + readonly monitor: GroupCounters; + readonly allT3: GroupCounters; +} + +export interface ProcessDelta { + readonly identityKey: string; + readonly category: ResourceTelemetryProcessCategory; + readonly cpuTimeMs: number; + readonly ioReadBytes: number; + readonly ioWriteBytes: number; +} + +export interface MergeProcessesInput { + readonly serverPid: number; + readonly sidecarPid: Option.Option; + readonly fallbackSampledAtMs: number; + readonly nativeSnapshot: Option.Option; + readonly desktopSnapshot: Option.Option; + readonly electronRootPids?: ReadonlySet; + readonly electronRootStartTimes?: ReadonlyMap; + readonly previous: ReadonlyMap; + readonly counters: TelemetryCounters; + readonly updatePrevious: boolean; +} + +export interface MergeProcessesResult { + readonly sampledAtMs: number; + readonly processes: ReadonlyArray; + readonly previous: ReadonlyMap; + readonly counters: TelemetryCounters; + readonly groups: { + readonly backend: ResourceTelemetryAggregate; + readonly electron: ResourceTelemetryAggregate; + readonly monitor: ResourceTelemetryAggregate; + readonly allT3: ResourceTelemetryAggregate; + }; + readonly deltas: ReadonlyArray; +} + +export const emptyGroupCounters = (): GroupCounters => ({ + cpuTimeMs: 0, + ioReadBytes: 0, + ioWriteBytes: 0, + processStarts: 0, + processExits: 0, +}); + +export const emptyTelemetryCounters = (): TelemetryCounters => ({ + backend: emptyGroupCounters(), + electron: emptyGroupCounters(), + monitor: emptyGroupCounters(), + allT3: emptyGroupCounters(), +}); + +export function processIdentityKey(pid: number, startTimeMs: number): string { + return `${pid}:${startTimeMs}`; +} + +function finiteNonNegative(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function categoryGroup( + category: ResourceTelemetryProcessCategory, +): "backend" | "electron" | "monitor" { + if (category === "resource-monitor") return "monitor"; + if (category.startsWith("electron-")) return "electron"; + return "backend"; +} + +function electronCategory(metric: DesktopElectronProcessMetric): ResourceTelemetryProcessCategory { + switch (metric.type) { + case "Browser": + return "electron-main"; + case "Tab": + return "electron-renderer"; + case "GPU": + return "electron-gpu"; + default: + return "electron-utility"; + } +} + +function inferredElectronCategory( + process: ResourceMonitorProcessSample, +): ResourceTelemetryProcessCategory { + const command = process.command.toLowerCase(); + if (command.includes("--type=renderer")) return "electron-renderer"; + if (command.includes("--type=gpu-process")) return "electron-gpu"; + return "electron-utility"; +} + +function matchElectronMetric( + process: ResourceMonitorProcessSample, + metricsByPid: ReadonlyMap, +): DesktopElectronProcessMetric | undefined { + const metric = metricsByPid.get(process.pid); + if (!metric) return undefined; + return Math.abs(metric.creationTimeMs - process.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS + ? metric + : undefined; +} + +function syntheticNativeSample( + metric: DesktopElectronProcessMetric, + sampledAtMs: number, + previous: ProcessState | undefined, +): ResourceMonitorProcessSample { + const cpuTimeMs = + metric.cumulativeCpuSeconds !== undefined + ? Math.max(0, Math.round(metric.cumulativeCpuSeconds * 1_000)) + : previous + ? previous.process.cpuTimeMs + + Math.max(0, ((sampledAtMs - previous.sampledAtMs) * metric.cpuPercent) / 100) + : 0; + return { + pid: metric.pid, + ppid: 0, + startTimeMs: metric.creationTimeMs, + runTimeMs: Math.max(0, sampledAtMs - metric.creationTimeMs), + name: metric.name ?? metric.serviceName ?? metric.type, + command: metric.name ?? metric.serviceName ?? metric.type, + status: "Running", + cpuPercent: metric.cpuPercent, + cpuTimeMs, + residentBytes: metric.workingSetBytes, + virtualBytes: 0, + ioReadBytes: 0, + ioWriteBytes: 0, + ioSemantics: "storage", + }; +} + +function processDepths( + processes: ReadonlyArray, + roots: ReadonlySet, +): ReadonlyMap { + const childrenByParent = new Map(); + for (const process of processes) { + const children = childrenByParent.get(process.ppid) ?? []; + children.push(process.pid); + childrenByParent.set(process.ppid, children); + } + + const depths = new Map(); + const queue = [...roots].map((pid) => ({ pid, depth: 0 })); + while (queue.length > 0) { + const current = queue.shift(); + if (!current || depths.has(current.pid)) continue; + depths.set(current.pid, current.depth); + for (const childPid of childrenByParent.get(current.pid) ?? []) { + queue.push({ pid: childPid, depth: current.depth + 1 }); + } + } + return depths; +} + +function isElectronDescendant( + pid: number, + processesByPid: ReadonlyMap, + electronPids: ReadonlySet, +): boolean { + const visited = new Set(); + let currentPid = pid; + while (!visited.has(currentPid)) { + visited.add(currentPid); + if (electronPids.has(currentPid)) return true; + const current = processesByPid.get(currentPid); + if (!current || current.ppid <= 0 || current.ppid === currentPid) return false; + currentPid = current.ppid; + } + return false; +} + +function hasElectronAncestor( + process: ResourceMonitorProcessSample, + processesByPid: ReadonlyMap, + electronPids: ReadonlySet, +): boolean { + const visited = new Set(); + let currentPid = process.ppid; + while (currentPid > 0 && !visited.has(currentPid)) { + visited.add(currentPid); + if (electronPids.has(currentPid)) return true; + const current = processesByPid.get(currentPid); + if (!current || current.ppid === currentPid) return false; + currentPid = current.ppid; + } + return false; +} + +function orderProcessTree( + processes: ReadonlyArray, + rootPids: ReadonlyArray, +): ReadonlyArray { + const processesByPid = new Map(processes.map((process) => [process.identity.pid, process])); + const childrenByParent = new Map(); + for (const process of processes) { + const children = childrenByParent.get(process.ppid) ?? []; + children.push(process); + childrenByParent.set(process.ppid, children); + } + for (const children of childrenByParent.values()) { + children.sort((left, right) => left.identity.pid - right.identity.pid); + } + + const ordered: ResourceTelemetryProcess[] = []; + const visited = new Set(); + const visit = (process: ResourceTelemetryProcess): void => { + if (visited.has(process.identity.pid)) return; + visited.add(process.identity.pid); + ordered.push(process); + for (const child of childrenByParent.get(process.identity.pid) ?? []) { + visit(child); + } + }; + + for (const rootPid of rootPids) { + const root = processesByPid.get(rootPid); + if (root) visit(root); + } + for (const process of processes.toSorted( + (left, right) => left.depth - right.depth || left.identity.pid - right.identity.pid, + )) { + visit(process); + } + return ordered; +} + +function delta(input: { + readonly current: number; + readonly previous: number; + readonly elapsedMs: number; +}): number { + if ( + input.elapsedMs <= 0 || + input.elapsedMs > MAX_DELTA_INTERVAL_MS || + input.current < input.previous + ) { + return 0; + } + return input.current - input.previous; +} + +function incrementCounters(counters: GroupCounters, update: Partial): GroupCounters { + return { + cpuTimeMs: counters.cpuTimeMs + (update.cpuTimeMs ?? 0), + ioReadBytes: counters.ioReadBytes + (update.ioReadBytes ?? 0), + ioWriteBytes: counters.ioWriteBytes + (update.ioWriteBytes ?? 0), + processStarts: counters.processStarts + (update.processStarts ?? 0), + processExits: counters.processExits + (update.processExits ?? 0), + }; +} + +function applyLifecycleCounters(input: { + readonly counters: TelemetryCounters; + readonly deltas: ReadonlyArray; + readonly current: ReadonlyMap; + readonly previous: ReadonlyMap; +}): TelemetryCounters { + let backend = input.counters.backend; + let electron = input.counters.electron; + let monitor = input.counters.monitor; + let allT3 = input.counters.allT3; + for (const processDelta of input.deltas) { + const group = categoryGroup(processDelta.category); + switch (group) { + case "backend": + backend = incrementCounters(backend, processDelta); + break; + case "electron": + electron = incrementCounters(electron, processDelta); + break; + case "monitor": + monitor = incrementCounters(monitor, processDelta); + break; + } + allT3 = incrementCounters(allT3, processDelta); + } + + for (const [identityKey, current] of input.current) { + if (input.previous.has(identityKey)) continue; + const group = categoryGroup(current.process.category); + switch (group) { + case "backend": + backend = incrementCounters(backend, { processStarts: 1 }); + break; + case "electron": + electron = incrementCounters(electron, { processStarts: 1 }); + break; + case "monitor": + monitor = incrementCounters(monitor, { processStarts: 1 }); + break; + } + allT3 = incrementCounters(allT3, { processStarts: 1 }); + } + + for (const [identityKey, previous] of input.previous) { + if (input.current.has(identityKey)) continue; + const group = categoryGroup(previous.process.category); + switch (group) { + case "backend": + backend = incrementCounters(backend, { processExits: 1 }); + break; + case "electron": + electron = incrementCounters(electron, { processExits: 1 }); + break; + case "monitor": + monitor = incrementCounters(monitor, { processExits: 1 }); + break; + } + allT3 = incrementCounters(allT3, { processExits: 1 }); + } + + return { backend, electron, monitor, allT3 }; +} + +function aggregate( + processes: ReadonlyArray, + counters: GroupCounters, +): ResourceTelemetryAggregate { + return { + processCount: processes.length, + currentCpuPercent: processes.reduce((total, process) => total + process.cpuPercent, 0), + cpuTimeMs: counters.cpuTimeMs, + currentRssBytes: processes.reduce((total, process) => total + process.residentBytes, 0), + peakRssBytes: processes.reduce((total, process) => total + process.peakResidentBytes, 0), + ioReadBytes: counters.ioReadBytes, + ioWriteBytes: counters.ioWriteBytes, + ioReadBytesPerSecond: processes.reduce( + (total, process) => total + process.ioReadBytesPerSecond, + 0, + ), + ioWriteBytesPerSecond: processes.reduce( + (total, process) => total + process.ioWriteBytesPerSecond, + 0, + ), + processStarts: counters.processStarts, + processExits: counters.processExits, + }; +} + +export function mergeProcesses(input: MergeProcessesInput): MergeProcessesResult { + const nativeProcesses = Option.match(input.nativeSnapshot, { + onNone: () => [] as ReadonlyArray, + onSome: (snapshot) => snapshot.processes, + }); + const electronMetrics = Option.match(input.desktopSnapshot, { + onNone: () => [] as ReadonlyArray, + onSome: (snapshot) => snapshot.electronProcesses, + }); + const sampledAtMs = Option.match(input.nativeSnapshot, { + onNone: () => + Option.match(input.desktopSnapshot, { + onNone: () => input.fallbackSampledAtMs, + onSome: (snapshot) => snapshot.sampledAtUnixMs, + }), + onSome: (native) => + Option.match(input.desktopSnapshot, { + onNone: () => native.sampledAtUnixMs, + onSome: (desktop) => Math.max(native.sampledAtUnixMs, desktop.sampledAtUnixMs), + }), + }); + const nativeSampledAtMs = Option.map( + input.nativeSnapshot, + (snapshot) => snapshot.sampledAtUnixMs, + ); + const desktopSampledAtMs = Option.map( + input.desktopSnapshot, + (snapshot) => snapshot.sampledAtUnixMs, + ); + const nativeProcessPids = new Set(nativeProcesses.map((process) => process.pid)); + const nativeByPid = new Map(nativeProcesses.map((process) => [process.pid, process])); + const metricsByPid = new Map(); + for (const metric of electronMetrics) { + const nativeProcess = nativeByPid.get(metric.pid); + if (!nativeProcess) { + nativeByPid.set( + metric.pid, + syntheticNativeSample( + metric, + Option.getOrElse(desktopSampledAtMs, () => sampledAtMs), + input.previous.get(processIdentityKey(metric.pid, metric.creationTimeMs)), + ), + ); + metricsByPid.set(metric.pid, metric); + continue; + } + if ( + Math.abs(metric.creationTimeMs - nativeProcess.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS + ) { + metricsByPid.set(metric.pid, metric); + } + } + const processes = [...nativeByPid.values()]; + const processesByPid = new Map(processes.map((process) => [process.pid, process])); + const requestedElectronRootPids = input.electronRootPids ?? new Set(); + const explicitElectronRootPids = new Set( + [...requestedElectronRootPids].filter((pid) => { + const process = processesByPid.get(pid); + if (!process) return false; + const expectedStartTime = input.electronRootStartTimes?.get(pid); + if (expectedStartTime !== undefined) { + return Math.abs(process.startTimeMs - expectedStartTime) <= ELECTRON_IDENTITY_TOLERANCE_MS; + } + if (metricsByPid.has(pid)) return true; + return [...input.previous.values()].some( + (previous) => + previous.process.category === "electron-main" && + previous.process.identity.pid === pid && + previous.process.identity.startTimeMs === process.startTimeMs, + ); + }), + ); + const electronPids = new Set([...metricsByPid.keys(), ...explicitElectronRootPids]); + const electronRootPids = [ + ...explicitElectronRootPids, + ...[...electronPids] + .filter((pid) => { + if (explicitElectronRootPids.has(pid)) return false; + const process = processesByPid.get(pid); + return process === undefined + ? true + : !hasElectronAncestor(process, processesByPid, electronPids); + }) + .toSorted((left, right) => left - right), + ].filter((pid, index, values) => values.indexOf(pid) === index); + const rootPids = [input.serverPid, ...electronRootPids]; + const roots = new Set(rootPids); + const depths = processDepths(processes, roots); + const childrenByParent = new Map(); + for (const process of processes) { + const children = childrenByParent.get(process.ppid) ?? []; + children.push(process.pid); + childrenByParent.set(process.ppid, children); + } + + const nextPrevious = new Map(); + const processDeltas: ProcessDelta[] = []; + const normalized = processes.map((process): ResourceTelemetryProcess => { + const identityKey = processIdentityKey(process.pid, process.startTimeMs); + const previous = input.previous.get(identityKey); + const counterSampledAtMs = nativeProcessPids.has(process.pid) + ? Option.getOrElse(nativeSampledAtMs, () => sampledAtMs) + : Option.getOrElse(desktopSampledAtMs, () => sampledAtMs); + const elapsedMs = previous ? counterSampledAtMs - previous.sampledAtMs : 0; + const cpuTimeDelta = previous + ? delta({ + current: process.cpuTimeMs, + previous: previous.process.cpuTimeMs, + elapsedMs, + }) + : 0; + const ioReadDelta = previous + ? delta({ + current: process.ioReadBytes, + previous: previous.process.ioReadBytes, + elapsedMs, + }) + : 0; + const ioWriteDelta = previous + ? delta({ + current: process.ioWriteBytes, + previous: previous.process.ioWriteBytes, + elapsedMs, + }) + : 0; + const electronMetric = matchElectronMetric(process, metricsByPid); + const category: ResourceTelemetryProcessCategory = + process.pid === input.serverPid + ? "server" + : Option.contains(input.sidecarPid, process.pid) + ? "resource-monitor" + : explicitElectronRootPids.has(process.pid) + ? "electron-main" + : electronMetric + ? electronCategory(electronMetric) + : isElectronDescendant(process.pid, processesByPid, electronPids) + ? inferredElectronCategory(process) + : "server-child"; + const firstSeenAt = previous?.process.firstSeenAt ?? DateTime.makeUnsafe(sampledAtMs); + const preservePreviousRates = !input.updatePrevious && previous !== undefined; + const cpuPercent = preservePreviousRates + ? previous.process.cpuPercent + : previous && elapsedMs > 0 && elapsedMs <= MAX_DELTA_INTERVAL_MS + ? (cpuTimeDelta / elapsedMs) * 100 + : finiteNonNegative(process.cpuPercent); + const normalizedProcess: ResourceTelemetryProcess = { + identity: { + pid: process.pid, + startTimeMs: process.startTimeMs, + }, + ppid: process.ppid, + childPids: [...(childrenByParent.get(process.pid) ?? [])].toSorted( + (left, right) => left - right, + ), + depth: depths.get(process.pid) ?? 0, + name: process.name, + command: process.command, + status: process.status, + category, + ...(electronMetric ? { electronType: electronMetric.type } : {}), + ...(electronMetric?.serviceName ? { electronServiceName: electronMetric.serviceName } : {}), + cpuPercent: finiteNonNegative(cpuPercent), + cpuTimeMs: process.cpuTimeMs, + residentBytes: process.residentBytes, + peakResidentBytes: Math.max( + process.residentBytes, + electronMetric?.peakWorkingSetBytes ?? 0, + previous?.process.peakResidentBytes ?? 0, + ), + virtualBytes: process.virtualBytes, + ioReadBytes: process.ioReadBytes, + ioWriteBytes: process.ioWriteBytes, + ioReadBytesPerSecond: preservePreviousRates + ? previous.process.ioReadBytesPerSecond + : elapsedMs > 0 + ? finiteNonNegative((ioReadDelta * 1_000) / elapsedMs) + : 0, + ioWriteBytesPerSecond: preservePreviousRates + ? previous.process.ioWriteBytesPerSecond + : elapsedMs > 0 + ? finiteNonNegative((ioWriteDelta * 1_000) / elapsedMs) + : 0, + ioSemantics: process.ioSemantics, + ...(electronMetric ? { idleWakeupsPerSecond: electronMetric.idleWakeupsPerSecond } : {}), + runTimeMs: process.runTimeMs, + firstSeenAt, + lastSeenAt: DateTime.makeUnsafe(sampledAtMs), + }; + nextPrevious.set(identityKey, { + process: normalizedProcess, + sampledAtMs: counterSampledAtMs, + }); + processDeltas.push({ + identityKey, + category, + cpuTimeMs: cpuTimeDelta, + ioReadBytes: ioReadDelta, + ioWriteBytes: ioWriteDelta, + }); + return normalizedProcess; + }); + const ordered = orderProcessTree(normalized, rootPids); + + const counters = input.updatePrevious + ? applyLifecycleCounters({ + counters: input.counters, + deltas: processDeltas, + current: nextPrevious, + previous: input.previous, + }) + : input.counters; + const backendProcesses = ordered.filter( + (process) => categoryGroup(process.category) === "backend", + ); + const electronProcesses = ordered.filter( + (process) => categoryGroup(process.category) === "electron", + ); + const monitorProcesses = ordered.filter( + (process) => categoryGroup(process.category) === "monitor", + ); + + return { + sampledAtMs, + processes: ordered, + previous: input.updatePrevious ? nextPrevious : input.previous, + counters, + groups: { + backend: aggregate(backendProcesses, counters.backend), + electron: aggregate(electronProcesses, counters.electron), + monitor: aggregate(monitorProcesses, counters.monitor), + allT3: aggregate(ordered, counters.allT3), + }, + deltas: processDeltas, + }; +} diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts new file mode 100644 index 000000000000..61a67d116069 --- /dev/null +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -0,0 +1,241 @@ +import type { HostPowerSnapshot } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +import { + NativeTelemetryRequestTimedOut, + NativeTelemetryStreamClosed, + canCommandNativeTelemetrySidecar, + canRequestNativeTelemetryRetry, + commitCollectionControlUpdate, + nativeTelemetrySupervisorFailureMessage, + retainRecentNativeTelemetryFailures, + resolveNativeSampleIntervalMs, + synchronizeCollectionControlOnStart, +} from "./NativeTelemetryClient.ts"; + +const basePower: HostPowerSnapshot = { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "false", + lowPowerMode: "false", + thermalState: "nominal", + stale: false, + updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), +}; + +describe("resolveNativeSampleIntervalMs", () => { + it("keeps a recovery cadence while suspended and backs off under host constraints", () => { + expect(resolveNativeSampleIntervalMs({ ...basePower, suspended: true }, 1)).toBe(15_000); + expect(resolveNativeSampleIntervalMs({ ...basePower, locked: "true" }, 1)).toBe(15_000); + expect(resolveNativeSampleIntervalMs({ ...basePower, lowPowerMode: "true" }, 1)).toBe(15_000); + expect(resolveNativeSampleIntervalMs({ ...basePower, thermalState: "critical" }, 1)).toBe( + 15_000, + ); + expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); + }); + + it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { + const unknown: HostPowerSnapshot = { + ...basePower, + source: "unknown", + stale: true, + }; + expect(resolveNativeSampleIntervalMs(unknown, 0)).toBe(5_000); + expect(resolveNativeSampleIntervalMs(unknown, 1)).toBe(1_000); + expect( + resolveNativeSampleIntervalMs( + { ...basePower, stale: true, locked: "true", suspended: true }, + 0, + ), + ).toBe(5_000); + expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); + }); +}); + +describe("canRequestNativeTelemetryRetry", () => { + it("only accepts retry while the supervisor is waiting without a live sidecar", () => { + expect(canRequestNativeTelemetryRetry("degraded", false)).toBe(true); + expect(canRequestNativeTelemetryRetry("unavailable", false)).toBe(true); + expect(canRequestNativeTelemetryRetry("degraded", true)).toBe(false); + expect(canRequestNativeTelemetryRetry("healthy", false)).toBe(false); + expect(canRequestNativeTelemetryRetry("starting", false)).toBe(false); + }); +}); + +describe("canCommandNativeTelemetrySidecar", () => { + it("keeps on-demand recovery commands available while a live sidecar is degraded", () => { + expect(canCommandNativeTelemetrySidecar("healthy", true)).toBe(true); + expect(canCommandNativeTelemetrySidecar("degraded", true)).toBe(true); + expect(canCommandNativeTelemetrySidecar("unavailable", true)).toBe(false); + expect(canCommandNativeTelemetrySidecar("degraded", false)).toBe(false); + }); +}); + +describe("NativeTelemetryRequestTimedOut", () => { + it("models history and sample request deadlines without a fabricated cause", () => { + const historyTimeout = new NativeTelemetryRequestTimedOut({ + operation: "readHistory", + timeoutMs: 15_000, + }); + const sampleTimeout = new NativeTelemetryRequestTimedOut({ + operation: "sampleNow", + timeoutMs: 5_000, + }); + + expect(historyTimeout.message).toBe( + "Resource monitor 'readHistory' request timed out after 15000ms.", + ); + expect(sampleTimeout.message).toBe( + "Resource monitor 'sampleNow' request timed out after 5000ms.", + ); + expect("cause" in historyTimeout).toBe(false); + expect("cause" in sampleTimeout).toBe(false); + }); +}); + +describe("native telemetry supervisor failures", () => { + it("distinguishes a closed event stream from a process exit", () => { + expect(new NativeTelemetryStreamClosed().message).toBe( + "Resource monitor event stream closed unexpectedly.", + ); + }); + + it("keeps defect details out of the caller-visible health message", () => { + const secret = "credential=do-not-expose"; + const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret))); + + expect(message).toBe("Resource monitor supervisor stopped unexpectedly."); + expect(message).not.toContain(secret); + }); +}); + +describe("retainRecentNativeTelemetryFailures", () => { + it("expires old failures so an isolated crash restarts from the initial backoff", () => { + expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); + expect(retainRecentNativeTelemetryFailures([30_000, 60_000], 90_000)).toEqual([30_000, 60_000]); + }); +}); + +describe("commitCollectionControlUpdate", () => { + it.effect("retains desired demand and retries unapplied sidecar state", () => + Effect.gen(function* () { + const initial = { + hostPower: basePower, + liveSubscriberCount: 0, + sampleIntervalMs: 5_000, + }; + const desired = yield* Ref.make(initial); + const applied = yield* Ref.make(initial); + const failure = new Error("sidecar write failed"); + const receivedStates: Array = []; + + const received = yield* commitCollectionControlUpdate( + desired, + applied, + (current) => ({ + ...current, + liveSubscriberCount: 1, + sampleIntervalMs: 1_000, + }), + (previous, next) => { + receivedStates.push([previous.sampleIntervalMs, next.sampleIntervalMs]); + return Effect.fail(failure); + }, + ).pipe(Effect.flip); + + expect(received).toBe(failure); + expect(yield* Ref.get(desired)).toEqual({ + ...initial, + liveSubscriberCount: 1, + sampleIntervalMs: 1_000, + }); + expect(yield* Ref.get(applied)).toEqual(initial); + + yield* commitCollectionControlUpdate( + desired, + applied, + (current) => current, + (previous, next) => { + receivedStates.push([previous.sampleIntervalMs, next.sampleIntervalMs]); + return Effect.void; + }, + ); + expect(receivedStates).toEqual([ + [5_000, 1_000], + [5_000, 1_000], + ]); + expect(yield* Ref.get(applied)).toEqual(yield* Ref.get(desired)); + }), + ); + + it.effect("serializes startup synchronization with runtime control updates", () => + Effect.gen(function* () { + const initial = { + hostPower: basePower, + liveSubscriberCount: 0, + sampleIntervalMs: 5_000, + }; + const desired = yield* Ref.make(initial); + const applied = yield* Ref.make(initial); + const ready = yield* Ref.make(false); + const mutex = yield* Semaphore.make(1); + const startupApplying = yield* Deferred.make(); + const releaseStartup = yield* Deferred.make(); + const appliedIntervals: Array = []; + + const startupFiber = yield* synchronizeCollectionControlOnStart( + mutex, + desired, + applied, + (control) => + Effect.sync(() => { + appliedIntervals.push(control.sampleIntervalMs); + }).pipe( + Effect.andThen(Deferred.succeed(startupApplying, undefined)), + Effect.andThen(Deferred.await(releaseStartup)), + Effect.asVoid, + ), + Ref.set(ready, true), + ).pipe(Effect.forkChild); + yield* Deferred.await(startupApplying); + + const updateFiber = yield* mutex + .withPermits(1)( + commitCollectionControlUpdate( + desired, + applied, + (current) => ({ + ...current, + liveSubscriberCount: 1, + sampleIntervalMs: 1_000, + }), + (_previous, next) => + Effect.gen(function* () { + expect(yield* Ref.get(ready)).toBe(true); + appliedIntervals.push(next.sampleIntervalMs); + }), + ), + ) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Ref.get(desired)).toEqual(initial); + + yield* Deferred.succeed(releaseStartup, undefined); + yield* Fiber.join(startupFiber); + yield* Fiber.join(updateFiber); + + expect(appliedIntervals).toEqual([5_000, 1_000]); + expect(yield* Ref.get(applied)).toEqual(yield* Ref.get(desired)); + }), + ); +}); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts new file mode 100644 index 000000000000..e8d81cc4c1c0 --- /dev/null +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -0,0 +1,1024 @@ +import type { + HostPowerSnapshot, + ResourceMonitorCapabilities, + ResourceMonitorCommand, + ResourceMonitorEvent, + ResourceMonitorExternalProcess, + ResourceMonitorHelloEvent, + ResourceMonitorSnapshotEvent, + ResourceTelemetrySourceStatus, +} from "@t3tools/contracts"; +import { + RESOURCE_MONITOR_PROTOCOL_VERSION, + ResourceMonitorCommand as ResourceMonitorCommandSchema, + ResourceMonitorEvent as ResourceMonitorEventSchema, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Ndjson from "effect/unstable/encoding/Ndjson"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; +import { ServerConfig } from "../config.ts"; +import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts"; + +const SAMPLE_INTERVAL_MS = 1_000; +const UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS = 5_000; +const BATTERY_SAMPLE_INTERVAL_MS = 5_000; +const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000; +const HANDSHAKE_TIMEOUT = Duration.seconds(5); +const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5); +const HISTORY_REQUEST_TIMEOUT = Duration.seconds(15); +const INITIAL_RESTART_DELAY = Duration.millis(500); +const MAX_RESTART_DELAY = Duration.seconds(10); +const FAILURE_WINDOW_MS = 60_000; +const MAX_FAILURES_PER_WINDOW = 5; + +export class NativeTelemetrySpawnFailed extends Schema.TaggedErrorClass()( + "NativeTelemetrySpawnFailed", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to start resource monitor '${this.path}'.`; + } +} + +export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass()( + "NativeTelemetryHandshakeTimedOut", + { + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Resource monitor handshake timed out after ${this.timeoutMs}ms.`; + } +} + +export class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass()( + "NativeTelemetryRequestTimedOut", + { + operation: Schema.Literals(["readHistory", "sampleNow"]), + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Resource monitor '${this.operation}' request timed out after ${this.timeoutMs}ms.`; + } +} + +export class NativeTelemetryProtocolMismatch extends Schema.TaggedErrorClass()( + "NativeTelemetryProtocolMismatch", + { + expectedVersion: Schema.Number, + receivedVersion: Schema.Number, + }, +) { + override get message(): string { + return `Resource monitor protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`; + } +} + +export class NativeTelemetryDecodeFailed extends Schema.TaggedErrorClass()( + "NativeTelemetryDecodeFailed", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to decode resource monitor output."; + } +} + +export class NativeTelemetryCommandFailed extends Schema.TaggedErrorClass()( + "NativeTelemetryCommandFailed", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Resource monitor command '${this.operation}' failed.`; + } +} + +export class NativeTelemetryExited extends Schema.TaggedErrorClass()( + "NativeTelemetryExited", + { + exitCode: Schema.Number, + }, +) { + override get message(): string { + return `Resource monitor exited with code ${this.exitCode}.`; + } +} + +export class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass()( + "NativeTelemetryStreamClosed", + {}, +) { + override get message(): string { + return "Resource monitor event stream closed unexpectedly."; + } +} + +export class NativeTelemetryUnavailable extends Schema.TaggedErrorClass()( + "NativeTelemetryUnavailable", + { + reason: Schema.String, + }, +) { + override get message(): string { + return `Resource monitor is unavailable: ${this.reason}`; + } +} + +export type NativeTelemetryClientError = + | ResourceMonitorBinary.ResourceMonitorBinaryError + | NativeTelemetrySpawnFailed + | NativeTelemetryHandshakeTimedOut + | NativeTelemetryRequestTimedOut + | NativeTelemetryProtocolMismatch + | NativeTelemetryDecodeFailed + | NativeTelemetryCommandFailed + | NativeTelemetryExited + | NativeTelemetryStreamClosed + | NativeTelemetryUnavailable; + +export interface NativeTelemetryClientHealth { + readonly status: ResourceTelemetrySourceStatus; + readonly hello: Option.Option; + readonly lastSampleAt: Option.Option; + readonly lastError: Option.Option; + readonly restartCount: number; + readonly sampleIntervalMs: number; +} + +export interface NativeTelemetrySnapshot { + readonly generation: number; + readonly snapshot: ResourceMonitorSnapshotEvent; +} + +export class NativeTelemetryClient extends Context.Service< + NativeTelemetryClient, + { + readonly capabilities: Effect.Effect; + readonly snapshots: Stream.Stream; + readonly readHistory: ( + windowMs: number, + ) => Effect.Effect, NativeTelemetryClientError>; + readonly setExternalProcesses: ( + processes: ReadonlyArray, + ) => Effect.Effect; + readonly setHostPowerState: ( + snapshot: HostPowerSnapshot, + ) => Effect.Effect; + readonly sampleNow: Effect.Effect; + readonly retry: Effect.Effect; + readonly health: Effect.Effect; + readonly subscribeHealth: Effect.Effect< + { + readonly latest: NativeTelemetryClientHealth; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; + } +>()("t3/resourceTelemetry/NativeTelemetryClient") {} + +interface ClientState { + readonly status: ResourceTelemetrySourceStatus; + readonly handle: Option.Option; + readonly hello: Option.Option; + readonly lastSampleAt: Option.Option; + readonly lastError: Option.Option; + readonly restartCount: number; +} + +export interface CollectionControl { + readonly hostPower: HostPowerSnapshot; + readonly liveSubscriberCount: number; + readonly sampleIntervalMs: number; +} + +interface PendingHistoryRequest { + readonly deferred: Deferred.Deferred< + ReadonlyArray, + NativeTelemetryClientError + >; + readonly snapshots: ReadonlyArray; +} + +const initialState: ClientState = { + status: "starting", + handle: Option.none(), + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.none(), + restartCount: 0, +}; + +function toHealth(state: ClientState, sampleIntervalMs: number): NativeTelemetryClientHealth { + return { + status: state.status, + hello: state.hello, + lastSampleAt: state.lastSampleAt, + lastError: state.lastError, + restartCount: state.restartCount, + sampleIntervalMs, + }; +} + +function isThermallyConstrained(snapshot: HostPowerSnapshot): boolean { + return snapshot.thermalState === "serious" || snapshot.thermalState === "critical"; +} + +export function resolveNativeSampleIntervalMs( + snapshot: HostPowerSnapshot, + liveSubscriberCount: number, +): number { + if (snapshot.stale || snapshot.source === "unknown") { + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; + } + if ( + snapshot.suspended || + snapshot.locked === "true" || + snapshot.lowPowerMode === "true" || + isThermallyConstrained(snapshot) + ) { + return CONSTRAINED_SAMPLE_INTERVAL_MS; + } + if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; + return SAMPLE_INTERVAL_MS; +} + +export function commitCollectionControlUpdate( + desiredState: Ref.Ref, + appliedState: Ref.Ref, + update: (current: CollectionControl) => CollectionControl, + apply: (previous: CollectionControl, next: CollectionControl) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const [previousDesired, next] = yield* Ref.modify(desiredState, (previous) => { + const next = update(previous); + return [[previous, next] as const, next]; + }); + const previousApplied = yield* Ref.get(appliedState); + yield* apply(previousApplied, next); + yield* Ref.set(appliedState, next); + return [previousDesired, next] as const; + }); +} + +export function synchronizeCollectionControlOnStart( + mutex: Semaphore.Semaphore, + desiredState: Ref.Ref, + appliedState: Ref.Ref, + apply: (control: CollectionControl) => Effect.Effect, + markReady: Effect.Effect, +) { + return mutex.withPermits(1)( + Effect.gen(function* () { + const control = yield* Ref.get(desiredState); + yield* apply(control); + yield* Ref.set(appliedState, control); + yield* markReady; + return control; + }), + ); +} + +const decodeMonitorEvent: ( + value: unknown, +) => Effect.Effect = Schema.decodeUnknownEffect( + ResourceMonitorEventSchema, +); +const encodeMonitorCommand = Schema.encodeEffect( + Schema.fromJsonString(ResourceMonitorCommandSchema), +); +const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch); +const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed); +const isCommandFailed = Schema.is(NativeTelemetryCommandFailed); + +function eventVersion(value: unknown): number | undefined { + if (typeof value !== "object" || value === null) return undefined; + const version = Reflect.get(value, "version"); + return typeof version === "number" ? version : undefined; +} + +function restartDelay(attempt: number): Duration.Duration { + return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); +} + +export function retainRecentNativeTelemetryFailures( + failures: ReadonlyArray, + now: number, +): ReadonlyArray { + return failures.filter((failedAt) => now - failedAt <= FAILURE_WINDOW_MS); +} + +function errorMessage(error: NativeTelemetryClientError): string { + return error.message; +} + +export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause): string { + return "Resource monitor supervisor stopped unexpectedly."; +} + +export function canRequestNativeTelemetryRetry( + status: ResourceTelemetrySourceStatus, + hasHandle: boolean, +): boolean { + return status !== "healthy" && status !== "starting" && !hasHandle; +} + +export function canCommandNativeTelemetrySidecar( + status: ResourceTelemetrySourceStatus, + hasHandle: boolean, +): boolean { + return hasHandle && (status === "healthy" || status === "degraded"); +} + +export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { + const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const config = yield* ServerConfig; + const initializedAt = yield* DateTime.now; + const state = yield* Ref.make(initialState); + const collectionControl = yield* Ref.make({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: initializedAt, + }, + liveSubscriberCount: 0, + sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS, + }); + const appliedCollectionControl = yield* Ref.make(yield* Ref.get(collectionControl)); + const externalProcesses = yield* Ref.make>([]); + const pendingSamples = yield* Ref.make( + new Map>(), + ); + const pendingHistories = yield* Ref.make(new Map()); + const snapshots = yield* PubSub.sliding(8); + const healthChanges = yield* PubSub.sliding(4); + const retryQueue = yield* Queue.sliding(1); + const commandMutex = yield* Semaphore.make(1); + const controlMutex = yield* Semaphore.make(1); + const currentHealth = Effect.all([Ref.get(state), Ref.get(collectionControl)]).pipe( + Effect.map(([current, control]) => toHealth(current, control.sampleIntervalMs)), + ); + const publishHealth = currentHealth.pipe( + Effect.flatMap((health) => PubSub.publish(healthChanges, health)), + Effect.asVoid, + ); + + const failPending = (error: NativeTelemetryClientError) => + Effect.gen(function* () { + const samples = yield* Ref.getAndSet(pendingSamples, new Map()); + const histories = yield* Ref.getAndSet(pendingHistories, new Map()); + yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), { + discard: true, + }); + yield* Effect.forEach( + histories.values(), + (request) => Deferred.fail(request.deferred, error), + { discard: true }, + ); + }); + + const writeCommand = ( + handle: ChildProcessSpawner.ChildProcessHandle, + command: ResourceMonitorCommand, + ): Effect.Effect => + commandMutex.withPermits(1)( + encodeMonitorCommand(command).pipe( + Effect.map((encoded) => `${encoded}\n`), + Effect.mapError( + (cause) => + new NativeTelemetryCommandFailed({ + operation: command.type, + cause, + }), + ), + Effect.flatMap((encoded) => + Stream.run(Stream.encodeText(Stream.make(encoded)), handle.stdin), + ), + Effect.mapError( + (cause) => + new NativeTelemetryCommandFailed({ + operation: command.type, + cause, + }), + ), + ), + ); + + const processEvent = ( + event: ResourceMonitorEvent, + helloDeferred: Deferred.Deferred, + generation: number, + ): Effect.Effect => { + switch (event.type) { + case "hello": + return Ref.update(state, (current) => ({ + ...current, + status: "starting" as const, + hello: Option.some(event), + lastError: Option.none(), + })).pipe( + Effect.andThen(publishHealth), + Effect.andThen(Deferred.succeed(helloDeferred, event)), + Effect.asVoid, + ); + case "snapshot": + return Effect.gen(function* () { + const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; + const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); + yield* Ref.update(state, (current) => ({ + ...current, + status: "healthy" as const, + lastSampleAt: Option.some(sampledAt), + lastError: Option.none(), + })); + yield* publishHealth; + yield* PubSub.publish(snapshots, nativeSnapshot); + if (event.requestId) { + const deferred = yield* Ref.modify(pendingSamples, (pending) => { + const next = new Map(pending); + const current = next.get(event.requestId!); + next.delete(event.requestId!); + return [Option.fromUndefinedOr(current), next]; + }); + if (Option.isSome(deferred)) { + yield* Deferred.succeed(deferred.value, nativeSnapshot); + } + } + }); + case "historyChunk": + return Effect.gen(function* () { + const latestSnapshot = event.snapshots.at(-1); + yield* Ref.update(state, (current) => ({ + ...current, + status: "healthy" as const, + lastSampleAt: latestSnapshot + ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) + : current.lastSampleAt, + lastError: Option.none(), + })); + yield* publishHealth; + const completed = yield* Ref.modify(pendingHistories, (pending) => { + const request = pending.get(event.requestId); + if (!request) return [Option.none(), pending] as const; + const snapshots = [...request.snapshots, ...event.snapshots]; + const next = new Map(pending); + if (event.done) { + next.delete(event.requestId); + return [Option.some({ deferred: request.deferred, snapshots }), next] as const; + } + next.set(event.requestId, { deferred: request.deferred, snapshots }); + return [Option.none(), next] as const; + }); + if (Option.isSome(completed)) { + yield* Deferred.succeed(completed.value.deferred, completed.value.snapshots); + } + }); + case "error": + return Ref.update(state, (current) => ({ + ...current, + status: "degraded" as const, + lastError: Option.some(event.message), + })).pipe( + Effect.andThen(publishHealth), + Effect.andThen( + event.recoverable + ? Effect.void + : Effect.fail( + new NativeTelemetryCommandFailed({ + operation: event.code, + cause: event.message, + }), + ), + ), + ); + } + }; + + const runAttempt: Effect.Effect = Effect.scoped( + Effect.gen(function* () { + const executablePath = yield* binary.resolve; + const command = ChildProcess.make(executablePath, [], { + cwd: config.cwd, + stdin: { + stream: "pipe", + endOnDone: false, + }, + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: Duration.seconds(2), + }); + const handle = yield* Effect.acquireRelease( + spawner + .spawn(command) + .pipe( + Effect.mapError( + (cause) => new NativeTelemetrySpawnFailed({ path: executablePath, cause }), + ), + ), + (child) => child.kill().pipe(Effect.ignore), + ); + yield* Ref.update(state, (current) => ({ + ...current, + status: "starting" as const, + handle: Option.some(handle), + hello: Option.none(), + })); + yield* publishHealth; + const generation = (yield* Ref.get(state)).restartCount; + + const helloDeferred = yield* Deferred.make(); + const eventFiber = yield* handle.stdout.pipe( + Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), + Stream.mapEffect( + ( + value, + ): Effect.Effect< + ResourceMonitorEvent, + NativeTelemetryProtocolMismatch | NativeTelemetryDecodeFailed + > => { + const version = eventVersion(value); + if (version !== undefined && version !== RESOURCE_MONITOR_PROTOCOL_VERSION) { + return Effect.fail( + new NativeTelemetryProtocolMismatch({ + expectedVersion: RESOURCE_MONITOR_PROTOCOL_VERSION, + receivedVersion: version, + }), + ); + } + return decodeMonitorEvent(value).pipe( + Effect.mapError((cause) => new NativeTelemetryDecodeFailed({ cause })), + ); + }, + ), + Stream.runForEach((event) => processEvent(event, helloDeferred, generation)), + Effect.mapError((cause) => + isProtocolMismatch(cause) || isDecodeFailed(cause) || isCommandFailed(cause) + ? cause + : new NativeTelemetryDecodeFailed({ cause }), + ), + Effect.forkScoped, + ); + yield* handle.stderr.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped); + + const hello = yield* Deferred.await(helloDeferred).pipe( + Effect.timeoutOption(HANDSHAKE_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new NativeTelemetryHandshakeTimedOut({ + timeoutMs: Duration.toMillis(HANDSHAKE_TIMEOUT), + }), + ), + onSome: Effect.succeed, + }), + ), + ); + yield* synchronizeCollectionControlOnStart( + controlMutex, + collectionControl, + appliedCollectionControl, + (control) => + Effect.gen(function* () { + yield* writeCommand(handle, { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "configure", + rootPid: process.pid, + sampleIntervalMs: control.sampleIntervalMs, + externalProcesses: [...(yield* Ref.get(externalProcesses))], + }); + if (control.liveSubscriberCount > 0) { + yield* writeCommand(handle, { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "setStreaming", + enabled: true, + }); + } + }), + Ref.update(state, (current) => ({ + ...current, + status: "healthy" as const, + hello: Option.some(hello), + })).pipe(Effect.andThen(publishHealth)), + ); + + yield* writeCommand(handle, { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "setExternalProcesses", + processes: [...(yield* Ref.get(externalProcesses))], + }); + + const exitEffect = handle.exitCode.pipe( + Effect.mapError( + (cause) => + new NativeTelemetryCommandFailed({ + operation: "waitForExit", + cause, + }), + ), + Effect.flatMap((exitCode) => + Effect.fail(new NativeTelemetryExited({ exitCode: Number(exitCode) })), + ), + ); + const decoderEffect = Fiber.join(eventFiber).pipe( + Effect.andThen(Effect.fail(new NativeTelemetryStreamClosed())), + ); + return yield* Effect.raceFirst(exitEffect, decoderEffect); + }), + ).pipe( + Effect.ensuring( + Ref.update(state, (current) => ({ + ...current, + handle: Option.none(), + })), + ), + ); + + yield* Effect.gen(function* () { + let failures: ReadonlyArray = []; + let restartAttempt = 0; + + while (true) { + const result = yield* Effect.result(runAttempt); + if (Result.isSuccess(result)) { + return; + } + + const error = result.failure; + const now = DateTime.toEpochMillis(yield* DateTime.now); + const recentFailures = retainRecentNativeTelemetryFailures(failures, now); + if (recentFailures.length === 0) { + restartAttempt = 0; + } + failures = [...recentFailures, now]; + const exhausted = failures.length >= MAX_FAILURES_PER_WINDOW; + yield* Ref.update(state, (current) => ({ + ...current, + status: exhausted ? ("unavailable" as const) : ("degraded" as const), + hello: Option.none(), + lastError: Option.some(errorMessage(error)), + restartCount: current.restartCount + 1, + })); + yield* publishHealth; + yield* failPending(error); + + if (exhausted) { + yield* Queue.take(retryQueue); + failures = []; + restartAttempt = 0; + yield* Ref.update(state, (current) => ({ + ...current, + status: "starting" as const, + hello: Option.none(), + lastError: Option.none(), + })); + yield* publishHealth; + continue; + } + + const manuallyRetried = yield* Effect.raceFirst( + Effect.sleep(restartDelay(restartAttempt)).pipe(Effect.as(false)), + Queue.take(retryQueue).pipe(Effect.as(true)), + ); + restartAttempt = manuallyRetried ? 0 : restartAttempt + 1; + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Ref.update(state, (current) => ({ + ...current, + status: "unavailable" as const, + hello: Option.none(), + lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)), + })).pipe( + Effect.andThen(publishHealth), + Effect.andThen( + Effect.logWarning("Resource monitor supervisor failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ), + Effect.forkScoped, + ); + + const applyCollectionControl = Effect.fn( + "resourceTelemetry.nativeTelemetryClient.applyCollectionControl", + )(function* (previous: CollectionControl, next: CollectionControl) { + const current = yield* Ref.get(state); + if (canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { + const handle = Option.getOrThrow(current.handle); + if (previous.sampleIntervalMs !== next.sampleIntervalMs) { + yield* writeCommand(handle, { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "setSampleInterval", + sampleIntervalMs: next.sampleIntervalMs, + }); + } + const wasStreaming = previous.liveSubscriberCount > 0; + const isStreaming = next.liveSubscriberCount > 0; + if (wasStreaming !== isStreaming) { + yield* writeCommand(handle, { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "setStreaming", + enabled: isStreaming, + }); + } + } + }); + + const updateCollectionControl = (update: (current: CollectionControl) => CollectionControl) => + controlMutex.withPermits(1)( + commitCollectionControlUpdate( + collectionControl, + appliedCollectionControl, + update, + applyCollectionControl, + ).pipe(Effect.ensuring(publishHealth), Effect.asVoid), + ); + + const setHostPowerState: NativeTelemetryClient["Service"]["setHostPowerState"] = (hostPower) => + updateCollectionControl((current) => ({ + ...current, + hostPower, + sampleIntervalMs: resolveNativeSampleIntervalMs(hostPower, current.liveSubscriberCount), + })); + + const changeLiveSubscriberCount = Effect.fn( + "resourceTelemetry.nativeTelemetryClient.changeLiveSubscriberCount", + )(function* (delta: 1 | -1) { + yield* updateCollectionControl((current) => { + const liveSubscriberCount = Math.max(0, current.liveSubscriberCount + delta); + return { + ...current, + liveSubscriberCount, + sampleIntervalMs: resolveNativeSampleIntervalMs(current.hostPower, liveSubscriberCount), + }; + }); + }); + + const liveSnapshots = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(snapshots); + yield* Effect.acquireRelease(changeLiveSubscriberCount(1), () => + changeLiveSubscriberCount(-1).pipe(Effect.ignore), + ); + return Stream.fromSubscription(subscription); + }), + ); + + const setExternalProcesses: NativeTelemetryClient["Service"]["setExternalProcesses"] = ( + processes, + ) => + Effect.gen(function* () { + yield* Ref.set(externalProcesses, [...processes]); + const current = yield* Ref.get(state); + if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) return; + yield* writeCommand(Option.getOrThrow(current.handle), { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "setExternalProcesses", + processes: [...processes], + }); + }); + + const readHistory: NativeTelemetryClient["Service"]["readHistory"] = (windowMs) => + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { + return yield* new NativeTelemetryUnavailable({ + reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), + }); + } + const requestId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new NativeTelemetryCommandFailed({ + operation: "createHistoryRequestId", + cause, + }), + ), + ); + const deferred = yield* Deferred.make< + ReadonlyArray, + NativeTelemetryClientError + >(); + yield* Ref.update(pendingHistories, (pending) => { + const next = new Map(pending); + next.set(requestId, { deferred, snapshots: [] }); + return next; + }); + return yield* writeCommand(Option.getOrThrow(current.handle), { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "readHistory", + requestId, + windowMs: Math.max(0, Math.round(windowMs)), + }).pipe( + Effect.andThen( + Deferred.await(deferred).pipe( + Effect.timeoutOption(HISTORY_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new NativeTelemetryRequestTimedOut({ + operation: "readHistory", + timeoutMs: Duration.toMillis(HISTORY_REQUEST_TIMEOUT), + }), + ), + onSome: Effect.succeed, + }), + ), + ), + ), + Effect.ensuring( + Ref.update(pendingHistories, (pending) => { + const next = new Map(pending); + next.delete(requestId); + return next; + }), + ), + ); + }); + + const sampleNow: NativeTelemetryClient["Service"]["sampleNow"] = Effect.gen(function* () { + const current = yield* Ref.get(state); + if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { + return yield* new NativeTelemetryUnavailable({ + reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), + }); + } + + const requestId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new NativeTelemetryCommandFailed({ + operation: "createRequestId", + cause, + }), + ), + ); + const deferred = yield* Deferred.make(); + yield* Ref.update(pendingSamples, (pending) => { + const next = new Map(pending); + next.set(requestId, deferred); + return next; + }); + return yield* writeCommand(Option.getOrThrow(current.handle), { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "sampleNow", + requestId, + }).pipe( + Effect.andThen( + Deferred.await(deferred).pipe( + Effect.timeoutOption(SAMPLE_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new NativeTelemetryRequestTimedOut({ + operation: "sampleNow", + timeoutMs: Duration.toMillis(SAMPLE_REQUEST_TIMEOUT), + }), + ), + onSome: Effect.succeed, + }), + ), + ), + ), + Effect.ensuring( + Ref.update(pendingSamples, (pending) => { + const next = new Map(pending); + next.delete(requestId); + return next; + }), + ), + ); + }); + + const health = currentHealth; + + return NativeTelemetryClient.of({ + capabilities: Ref.get(state).pipe( + Effect.flatMap((current) => + Option.match(current.hello, { + onNone: () => + Effect.fail( + new NativeTelemetryUnavailable({ + reason: Option.getOrElse(current.lastError, () => "handshake is incomplete"), + }), + ), + onSome: (hello) => Effect.succeed(hello.capabilities), + }), + ), + ), + snapshots: liveSnapshots, + readHistory, + setExternalProcesses, + setHostPowerState, + sampleNow, + retry: Ref.get(state).pipe( + Effect.flatMap((current) => + !canRequestNativeTelemetryRetry(current.status, Option.isSome(current.handle)) + ? Effect.succeed(false) + : Queue.offer(retryQueue, undefined).pipe(Effect.as(true)), + ), + ), + health, + subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, health), + }); +}); + +export const layer = Layer.effect(NativeTelemetryClient, make()); + +export const layerTest = ( + overrides: Partial = {}, +): Layer.Layer => { + const health = + overrides.health ?? + Effect.succeed({ + status: "unavailable" as const, + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.some("Resource monitor test implementation is unavailable."), + restartCount: 0, + sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS, + }); + return Layer.succeed( + NativeTelemetryClient, + NativeTelemetryClient.of({ + capabilities: Effect.succeed({ + cumulativeCpuTime: true, + currentCpuPercent: true, + residentMemory: true, + virtualMemory: true, + ioBytes: true, + processStartTime: true, + processTree: true, + }), + snapshots: Stream.empty, + readHistory: () => + Effect.fail( + new NativeTelemetryUnavailable({ + reason: "No resource monitor history was configured for this test.", + }), + ), + setExternalProcesses: () => Effect.void, + setHostPowerState: () => Effect.void, + sampleNow: Effect.fail( + new NativeTelemetryUnavailable({ + reason: "No resource monitor sample was configured for this test.", + }), + ), + retry: Effect.succeed(false), + health, + subscribeHealth: + overrides.subscribeHealth ?? + health.pipe( + Effect.map((initial) => ({ + latest: initial, + changes: Stream.empty, + })), + ), + ...overrides, + }), + ); +}; diff --git a/apps/server/src/resourceTelemetry/ResourceAttribution.ts b/apps/server/src/resourceTelemetry/ResourceAttribution.ts new file mode 100644 index 000000000000..374564bb7d88 --- /dev/null +++ b/apps/server/src/resourceTelemetry/ResourceAttribution.ts @@ -0,0 +1,71 @@ +import type { ResourceAttributionEntry, ResourceAttributionSnapshot } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +export interface ResourceAttributionRecord { + readonly component: string; + readonly operation: string; + readonly logicalReadBytes?: number; + readonly logicalWriteBytes?: number; + readonly count?: number; + readonly durationMs?: number; +} + +export class ResourceAttribution extends Context.Service< + ResourceAttribution, + { + readonly record: (input: ResourceAttributionRecord) => Effect.Effect; + readonly snapshot: Effect.Effect; + } +>()("t3/resourceTelemetry/ResourceAttribution") {} + +function key(input: Pick): string { + return `${input.component}\u0000${input.operation}`; +} + +function nonNegativeInteger(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.round(value)); +} + +export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () { + const entries = yield* Ref.make(new Map()); + + const record: ResourceAttribution["Service"]["record"] = (input) => + Ref.update(entries, (current) => { + const next = new Map(current); + const entryKey = key(input); + const existing = next.get(entryKey); + next.set(entryKey, { + component: input.component, + operation: input.operation, + logicalReadBytes: + (existing?.logicalReadBytes ?? 0) + nonNegativeInteger(input.logicalReadBytes, 0), + logicalWriteBytes: + (existing?.logicalWriteBytes ?? 0) + nonNegativeInteger(input.logicalWriteBytes, 0), + count: (existing?.count ?? 0) + nonNegativeInteger(input.count, 1), + durationMs: (existing?.durationMs ?? 0) + nonNegativeInteger(input.durationMs, 0), + }); + return next; + }); + + return ResourceAttribution.of({ + record, + snapshot: Effect.gen(function* () { + const readAt = yield* DateTime.now; + const current = yield* Ref.get(entries); + return { + readAt, + entries: [...current.values()].toSorted( + (left, right) => right.logicalWriteBytes - left.logicalWriteBytes, + ), + }; + }), + }); +}); + +export const layer = Layer.effect(ResourceAttribution, make()); diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts new file mode 100644 index 000000000000..4c3afa97abfa --- /dev/null +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -0,0 +1,124 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { ServerConfig } from "../config.ts"; +import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; + +describe("ResourceMonitorBinary", () => { + it.effect("resolves an executable override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const binaryPath = `${baseDir}/t3-resource-monitor`; + yield* fileSystem.writeFileString(binaryPath, "binary"); + yield* fileSystem.chmod(binaryPath, 0o755); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), + Effect.provideService(HostProcessEnvironment, { + T3CODE_RESOURCE_MONITOR_PATH: binaryPath, + }), + ); + + assert.equal(yield* service.resolve, binaryPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolves an executable override on an unsupported platform", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const binaryPath = `${baseDir}/custom-resource-monitor`; + yield* fileSystem.writeFileString(binaryPath, "binary"); + yield* fileSystem.chmod(binaryPath, 0o755); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "freebsd"), + Effect.provideService(HostProcessArchitecture, "ia32"), + Effect.provideService(HostProcessEnvironment, { + T3CODE_RESOURCE_MONITOR_PATH: binaryPath, + }), + ); + + assert.equal(yield* service.resolve, binaryPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a non-executable POSIX override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const binaryPath = `${baseDir}/t3-resource-monitor`; + yield* fileSystem.writeFileString(binaryPath, "binary"); + yield* fileSystem.chmod(binaryPath, 0o644); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "gnu"), + Effect.provideService(HostProcessEnvironment, { + T3CODE_RESOURCE_MONITOR_PATH: binaryPath, + }), + ); + const error = yield* Effect.flip(service.resolve); + + assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryNotExecutable); + assert.equal(error.path, binaryPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects unsupported platform and architecture pairs", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "freebsd"), + Effect.provideService(HostProcessArchitecture, "ia32"), + Effect.provideService(HostProcessEnvironment, {}), + ); + const error = yield* Effect.flip(service.resolve); + + assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects bundled glibc binaries on musl Linux hosts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), + Effect.provideService(HostProcessEnvironment, {}), + ); + const error = yield* Effect.flip(service.resolve); + + assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts new file mode 100644 index 000000000000..1f14df518660 --- /dev/null +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -0,0 +1,226 @@ +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../config.ts"; + +export class ResourceMonitorBinaryUnsupported extends Schema.TaggedErrorClass()( + "ResourceMonitorBinaryUnsupported", + { + platform: Schema.String, + architecture: Schema.String, + }, +) { + override get message(): string { + return `Resource monitoring is unsupported on ${this.platform}/${this.architecture}.`; + } +} + +export class ResourceMonitorBinaryNotFound extends Schema.TaggedErrorClass()( + "ResourceMonitorBinaryNotFound", + { + platform: Schema.String, + architecture: Schema.String, + candidates: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Resource monitor binary was not found for ${this.platform}/${this.architecture}.`; + } +} + +export class ResourceMonitorBinaryNotExecutable extends Schema.TaggedErrorClass()( + "ResourceMonitorBinaryNotExecutable", + { + path: Schema.String, + mode: Schema.Number, + }, +) { + override get message(): string { + return `Resource monitor binary at '${this.path}' is not executable.`; + } +} + +export type ResourceMonitorBinaryError = + | ResourceMonitorBinaryUnsupported + | ResourceMonitorBinaryNotFound + | ResourceMonitorBinaryNotExecutable; + +export class ResourceMonitorBinary extends Context.Service< + ResourceMonitorBinary, + { + readonly resolve: Effect.Effect; + } +>()("t3/resourceTelemetry/ResourceMonitorBinary") {} + +function binaryName(platform: NodeJS.Platform): string { + return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; +} + +export type ResourceMonitorLinuxLibc = "gnu" | "musl"; + +function detectResourceMonitorLinuxLibc(): ResourceMonitorLinuxLibc { + try { + const report = process.report?.getReport() as + | { + readonly header?: { + readonly glibcVersionRuntime?: unknown; + }; + } + | undefined; + return typeof report?.header?.glibcVersionRuntime === "string" ? "gnu" : "musl"; + } catch { + return "musl"; + } +} + +export const ResourceMonitorHostLinuxLibc = Context.Reference( + "t3/resourceTelemetry/ResourceMonitorHostLinuxLibc", + { + defaultValue: detectResourceMonitorLinuxLibc, + }, +); + +export function resourceMonitorPlatformKey( + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, +): string | undefined { + if ( + (platform !== "darwin" && platform !== "linux" && platform !== "win32") || + (architecture !== "arm64" && architecture !== "x64") + ) { + return undefined; + } + return `${platform}-${architecture}`; +} + +export function resourceMonitorRustTarget( + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, + linuxLibc: ResourceMonitorLinuxLibc, +): string | undefined { + if (platform === "darwin") { + return architecture === "arm64" + ? "aarch64-apple-darwin" + : architecture === "x64" + ? "x86_64-apple-darwin" + : undefined; + } + if (platform === "linux") { + if (linuxLibc !== "gnu") { + return undefined; + } + return architecture === "arm64" + ? "aarch64-unknown-linux-gnu" + : architecture === "x64" + ? "x86_64-unknown-linux-gnu" + : undefined; + } + if (platform === "win32") { + return architecture === "arm64" + ? "aarch64-pc-windows-msvc" + : architecture === "x64" + ? "x86_64-pc-windows-msvc" + : undefined; + } + return undefined; +} + +export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const environment = yield* HostProcessEnvironment; + const linuxLibc = yield* ResourceMonitorHostLinuxLibc; + const executableName = binaryName(platform); + const platformKey = resourceMonitorPlatformKey(platform, architecture); + const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc); + const overrideCandidates = [ + environment.T3CODE_RESOURCE_MONITOR_PATH, + config.resourceMonitorPath, + ].filter((candidate): candidate is string => Boolean(candidate)); + const bundledCandidates = + platformKey === undefined || rustTarget === undefined + ? [] + : [ + path.resolve(import.meta.dirname, "resource-monitor", platformKey, executableName), + path.resolve(import.meta.dirname, "resource-monitor", executableName), + path.resolve(import.meta.dirname, "../resource-monitor", executableName), + path.resolve( + import.meta.dirname, + "../../../../native/resource-monitor/target", + rustTarget, + "release", + executableName, + ), + path.resolve( + import.meta.dirname, + "../../../native/resource-monitor/target", + rustTarget, + "release", + executableName, + ), + path.resolve( + import.meta.dirname, + "../../../../native/resource-monitor/target/release", + executableName, + ), + path.resolve( + import.meta.dirname, + "../../../../native/resource-monitor/target/debug", + executableName, + ), + ]; + if (overrideCandidates.length === 0 && bundledCandidates.length === 0) { + return ResourceMonitorBinary.of({ + resolve: Effect.fail( + new ResourceMonitorBinaryUnsupported({ + platform, + architecture, + }), + ), + }); + } + + const candidates = [...overrideCandidates, ...bundledCandidates]; + + const resolve: ResourceMonitorBinary["Service"]["resolve"] = Effect.gen(function* () { + for (const candidate of candidates) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!exists) continue; + + if (platform !== "win32") { + const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); + if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) { + return yield* new ResourceMonitorBinaryNotExecutable({ + path: candidate, + mode: stat.value.mode, + }); + } + } + + return candidate; + } + + return yield* new ResourceMonitorBinaryNotFound({ + platform, + architecture, + candidates, + }); + }); + + return ResourceMonitorBinary.of({ resolve }); +}); + +export const layer = Layer.effect(ResourceMonitorBinary, make()); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts new file mode 100644 index 000000000000..a96423607baf --- /dev/null +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts @@ -0,0 +1,647 @@ +import type { + DesktopHostTelemetrySnapshot, + ResourceMonitorProcessSample, + ResourceMonitorSnapshotEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; +import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; +import * as ResourceAttribution from "./ResourceAttribution.ts"; +import * as ResourceTelemetry from "./ResourceTelemetry.ts"; + +function processSample( + input: Partial & + Pick, +): ResourceMonitorProcessSample { + return { + runTimeMs: 1_000, + name: `process-${input.pid}`, + command: `process-${input.pid}`, + status: "Running", + cpuPercent: 0, + cpuTimeMs: 0, + residentBytes: 1_024, + virtualBytes: 2_048, + ioReadBytes: 0, + ioWriteBytes: 0, + ioSemantics: "storage", + ...input, + }; +} + +function nativeSnapshot(input: { + readonly sequence: number; + readonly sampledAtUnixMs: number; + readonly childCpuTimeMs: number; + readonly childWriteBytes: number; + readonly externalProcesses?: ResourceMonitorSnapshotEvent["externalProcesses"]; +}): ResourceMonitorSnapshotEvent { + const processes = [ + processSample({ + pid: process.pid, + ppid: 1, + startTimeMs: 100, + cpuTimeMs: input.sequence * 10, + }), + processSample({ + pid: 4_242, + ppid: process.pid, + startTimeMs: 200, + name: "codex", + command: "codex app-server", + cpuTimeMs: input.childCpuTimeMs, + ioWriteBytes: input.childWriteBytes, + }), + processSample({ + pid: 5_000, + ppid: 1, + startTimeMs: 300, + name: "electron", + command: "electron", + cpuTimeMs: input.sequence * 20, + }), + processSample({ + pid: 9_000, + ppid: process.pid, + startTimeMs: 400, + name: "t3-resource-monitor", + command: "t3-resource-monitor", + cpuTimeMs: input.sequence * 5, + }), + ]; + return { + version: 2, + type: "snapshot", + sequence: input.sequence, + sampledAtUnixMs: input.sampledAtUnixMs, + collectionDurationMicros: 300, + scannedProcessCount: 80, + retainedProcessCount: processes.length, + inaccessibleProcessCount: 1, + ...(input.externalProcesses === undefined + ? {} + : { externalProcesses: input.externalProcesses }), + processes, + }; +} + +function nativeGeneration( + snapshot: ResourceMonitorSnapshotEvent, + generation: number, +): NativeTelemetryClient.NativeTelemetrySnapshot { + return { generation, snapshot }; +} + +function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot { + const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); + return { + version: 1, + type: "desktopTelemetry", + sequence: 1, + sampledAtUnixMs, + electronPid: 5_000, + power: { + source: "electron-main", + idle: "false", + idleSeconds: 2, + locked: "false", + suspended: false, + onBattery: "true", + lowPowerMode: "unknown", + thermalState: "fair", + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: Option.some(90), + electronProcesses: [ + { + pid: 5_000, + creationTimeMs: 300, + type: "Browser", + name: "electron", + cpuPercent: 2, + cumulativeCpuSeconds: 0.02, + idleWakeupsPerSecond: 3, + workingSetBytes: 4_096, + peakWorkingSetBytes: 8_192, + }, + ], + }; +} + +describe("ResourceTelemetry", () => { + it.effect("enables live native and Electron collection only while changes are retained", () => + Effect.gen(function* () { + const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); + const sample = nativeSnapshot({ + sequence: 1, + sampledAtUnixMs, + childCpuTimeMs: 100, + childWriteBytes: 1_000, + }); + const demandChanges = yield* Ref.make>([]); + const nativeLayer = NativeTelemetryClient.layerTest({ + sampleNow: Effect.succeed(nativeGeneration(sample, 0)), + health: Effect.succeed({ + status: "healthy", + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.none(), + restartCount: 0, + sampleIntervalMs: 1_000, + }), + }); + const desktopLayer = DesktopTelemetryReceiver.layerTest({ + latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), + setDiagnosticsDemand: (enabled) => + Ref.update(demandChanges, (changes) => [...changes, enabled]), + }); + const telemetryLayer = ResourceTelemetry.layer.pipe( + Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), + ); + + const live = yield* Stream.runHead( + Effect.gen(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + return telemetry.changes; + }).pipe(Stream.unwrap), + ).pipe(Effect.provide(telemetryLayer)); + + expect(Option.isSome(live)).toBe(true); + expect(yield* Ref.get(demandChanges)).toEqual([true, false]); + }), + ); + + it.effect("releases live demand when acquisition is interrupted by an idle collector", () => + Effect.gen(function* () { + const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); + const demandChanges = yield* Ref.make>([]); + const nativeLayer = NativeTelemetryClient.layerTest({ + sampleNow: Effect.never, + health: Effect.succeed({ + status: "healthy", + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.none(), + restartCount: 0, + sampleIntervalMs: 1_000, + }), + }); + const desktopLayer = DesktopTelemetryReceiver.layerTest({ + latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), + setDiagnosticsDemand: (enabled) => + Ref.update(demandChanges, (changes) => [...changes, enabled]), + }); + const telemetryLayer = ResourceTelemetry.layer.pipe( + Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), + ); + + const resultFiber = yield* Stream.runHead( + Effect.gen(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + return telemetry.changes; + }).pipe(Stream.unwrap), + ).pipe(Effect.timeoutOption("10 millis"), Effect.provide(telemetryLayer), Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 millis"); + const result = yield* Fiber.join(resultFiber); + + expect(Option.isNone(result)).toBe(true); + expect(yield* Ref.get(demandChanges)).toEqual([true, false]); + }), + ); + + it.effect( + "attributes an initial Electron root from the identity recorded by the native snapshot", + () => + Effect.gen(function* () { + const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); + const sample = nativeSnapshot({ + sequence: 1, + sampledAtUnixMs, + childCpuTimeMs: 100, + childWriteBytes: 1_000, + externalProcesses: [{ pid: 5_000, startTimeMs: 300 }], + }); + const desktop = { + ...desktopSnapshot(sampledAtUnixMs), + electronProcesses: [], + }; + const nativeLayer = NativeTelemetryClient.layerTest({ + sampleNow: Effect.succeed(nativeGeneration(sample, 0)), + health: Effect.succeed({ + status: "healthy", + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.none(), + restartCount: 0, + sampleIntervalMs: 1_000, + }), + }); + const desktopLayer = DesktopTelemetryReceiver.layerTest({ + latest: Effect.succeedSome(desktop), + }); + const telemetryLayer = ResourceTelemetry.layer.pipe( + Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), + ); + + const snapshot = yield* Effect.gen(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + return yield* telemetry.refresh; + }).pipe(Effect.provide(telemetryLayer)); + + expect(snapshot.processes.find((entry) => entry.identity.pid === 5_000)?.category).toBe( + "electron-main", + ); + expect(snapshot.groups.electron.processStarts).toBe(1); + expect(snapshot.groups.backend.processStarts).toBe(3); + }), + ); + + it.effect("rejects buffered snapshots from an earlier sidecar generation", () => + Effect.scoped( + Effect.gen(function* () { + const startedAt = DateTime.toEpochMillis(yield* DateTime.now); + const stale = nativeSnapshot({ + sequence: 100, + sampledAtUnixMs: startedAt + 1_000, + childCpuTimeMs: 100, + childWriteBytes: 1_000, + }); + const current = nativeSnapshot({ + sequence: 1, + sampledAtUnixMs: startedAt + 2_000, + childCpuTimeMs: 200, + childWriteBytes: 2_000, + }); + const nativeSnapshots = + yield* PubSub.unbounded(); + const nativeLayer = NativeTelemetryClient.layerTest({ + snapshots: Stream.fromPubSub(nativeSnapshots), + sampleNow: Effect.never, + health: Effect.succeed({ + status: "healthy", + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.none(), + restartCount: 1, + sampleIntervalMs: 1_000, + }), + }); + const telemetryLayer = ResourceTelemetry.layer.pipe( + Layer.provide( + Layer.mergeAll( + nativeLayer, + DesktopTelemetryReceiver.layerTest(), + ResourceAttribution.layer, + ), + ), + ); + + yield* Effect.gen(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + const subscription = yield* telemetry.subscribe; + const nextSnapshot = yield* Stream.runHead(subscription.changes).pipe(Effect.forkChild); + + yield* PubSub.publish(nativeSnapshots, nativeGeneration(stale, 0)); + yield* Effect.yieldNow; + expect((yield* telemetry.latest).health.scannedProcessCount).toBe(0); + + yield* PubSub.publish(nativeSnapshots, nativeGeneration(current, 1)); + const received = yield* Fiber.join(nextSnapshot); + + expect(Option.isSome(received)).toBe(true); + expect(DateTime.toEpochMillis(Option.getOrThrow(received).readAt)).toBe( + current.sampledAtUnixMs, + ); + }).pipe(Effect.provide(telemetryLayer)); + }), + ), + ); + + it.effect("retains desktop health changes while aggregate telemetry initializes", () => + Effect.scoped( + Effect.gen(function* () { + const health = yield* Ref.make({ + status: "starting", + lastSampleAt: Option.none(), + lastError: Option.none(), + }); + const healthChanges = + yield* PubSub.sliding(4); + const healthSubscribed = yield* Deferred.make(); + const finishDesktopSnapshot = yield* Deferred.make(); + const desktopLayer = DesktopTelemetryReceiver.layerTest({ + health: Ref.get(health), + subscribeHealth: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(healthChanges); + const latest = yield* Ref.get(health); + yield* Deferred.succeed(healthSubscribed, undefined); + return { + latest, + changes: Stream.fromSubscription(subscription), + }; + }), + subscribe: Deferred.await(finishDesktopSnapshot).pipe( + Effect.as({ + latest: Option.none(), + changes: Stream.empty, + }), + ), + }); + const telemetryLayer = ResourceTelemetry.layer.pipe( + Layer.provide( + Layer.mergeAll( + NativeTelemetryClient.layerTest(), + desktopLayer, + ResourceAttribution.layer, + ), + ), + ); + const resultFiber = yield* Effect.gen(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + while (true) { + const snapshot = yield* telemetry.latest; + if (snapshot.health.desktop.status === "degraded") return snapshot; + yield* Effect.yieldNow; + } + }).pipe(Effect.provide(telemetryLayer), Effect.timeout("1 second"), Effect.forkChild); + + yield* Deferred.await(healthSubscribed); + const degraded: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth = { + status: "degraded", + lastSampleAt: Option.none(), + lastError: Option.some("desktop telemetry failed"), + }; + yield* Ref.set(health, degraded); + yield* PubSub.publish(healthChanges, degraded); + yield* Deferred.succeed(finishDesktopSnapshot, undefined); + + const snapshot = yield* Fiber.join(resultFiber); + expect(snapshot.health.desktop.status).toBe("degraded"); + expect(Option.getOrNull(snapshot.health.desktop.lastError)).toBe( + "desktop telemetry failed", + ); + }), + ), + ); + + it.effect("atomically subscribes while a desktop update is being rebuilt", () => + Effect.scoped( + Effect.gen(function* () { + const startedAt = DateTime.toEpochMillis(yield* DateTime.now); + const nextDesktopSnapshot = desktopSnapshot(startedAt + 1_000); + const desktopChanges = yield* PubSub.unbounded(); + const rebuildStarted = yield* Deferred.make(); + const finishRebuild = yield* Deferred.make(); + const attributionReads = yield* Ref.make(0); + const attributionLayer = Layer.succeed( + ResourceAttribution.ResourceAttribution, + ResourceAttribution.ResourceAttribution.of({ + record: () => Effect.void, + snapshot: Ref.updateAndGet(attributionReads, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.void + : Deferred.succeed(rebuildStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRebuild)), + ), + ), + Effect.andThen( + Effect.succeed({ + readAt: DateTime.makeUnsafe(startedAt), + entries: [], + }), + ), + ), + }), + ); + const nativeLayer = NativeTelemetryClient.layerTest({ + sampleNow: Effect.never, + health: Effect.succeed({ + status: "healthy", + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.none(), + restartCount: 0, + sampleIntervalMs: 1_000, + }), + }); + const desktopLayer = DesktopTelemetryReceiver.layerTest({ + changes: Stream.fromPubSub(desktopChanges), + }); + const telemetryLayer = ResourceTelemetry.layer.pipe( + Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer)), + ); + + yield* Effect.gen(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + yield* Effect.yieldNow; + yield* PubSub.publish(desktopChanges, nextDesktopSnapshot); + yield* Deferred.await(rebuildStarted); + + const subscriptionFiber = yield* telemetry.subscribe.pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(finishRebuild, undefined); + const subscription = yield* Fiber.join(subscriptionFiber); + + expect(DateTime.toEpochMillis(subscription.latest.readAt)).toBe( + nextDesktopSnapshot.sampledAtUnixMs, + ); + expect(subscription.latest.power).toEqual(nextDesktopSnapshot.power); + }).pipe(Effect.provide(telemetryLayer)); + }), + ), + ); + + it.effect("combines native, Electron, attribution, retry, and history data", () => + Effect.gen(function* () { + const startedAt = DateTime.toEpochMillis(yield* DateTime.now); + const samples = [ + nativeSnapshot({ + sequence: 1, + sampledAtUnixMs: startedAt, + childCpuTimeMs: 100, + childWriteBytes: 1_000, + }), + nativeSnapshot({ + sequence: 2, + sampledAtUnixMs: startedAt + 1_000, + childCpuTimeMs: 350, + childWriteBytes: 5_000, + }), + nativeSnapshot({ + sequence: 1, + sampledAtUnixMs: startedAt + 2_000, + childCpuTimeMs: 500, + childWriteBytes: 7_000, + }), + ] as const; + const sampleIndex = yield* Ref.make(0); + const externalProcesses = yield* Ref.make< + ReadonlyArray<{ readonly pid: number; readonly startTimeMs?: number }> + >([]); + const retryCount = yield* Ref.make(0); + const nativeHealth = yield* Ref.make({ + status: "healthy", + hello: Option.some({ + version: 2, + type: "hello", + sidecarVersion: "0.1.0", + sidecarPid: 9_000, + platform: "test", + arch: "test", + capabilities: { + cumulativeCpuTime: true, + currentCpuPercent: true, + residentMemory: true, + virtualMemory: true, + ioBytes: true, + processStartTime: true, + processTree: true, + }, + }), + lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), + lastError: Option.none(), + restartCount: 2, + sampleIntervalMs: 1_000, + }); + const nativeHealthChanges = + yield* PubSub.sliding(4); + const nativeLayer = NativeTelemetryClient.layerTest({ + setExternalProcesses: (processes) => Ref.set(externalProcesses, processes), + readHistory: () => Effect.succeed(samples.slice(0, 2)), + sampleNow: Ref.modify(sampleIndex, (index) => { + const sampleIndex = Math.min(index, samples.length - 1); + return [nativeGeneration(samples[sampleIndex]!, sampleIndex === 2 ? 3 : 2), index + 1]; + }), + retry: Ref.updateAndGet(retryCount, (count) => count + 1).pipe(Effect.as(true)), + health: Ref.get(nativeHealth), + subscribeHealth: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(nativeHealthChanges); + const latest = yield* Ref.get(nativeHealth); + return { + latest, + changes: Stream.fromSubscription(subscription), + }; + }), + }); + const desktopLayer = DesktopTelemetryReceiver.layerTest({ + latest: Effect.succeedSome(desktopSnapshot(startedAt)), + health: Effect.succeed({ + status: "healthy", + lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), + lastError: Option.none(), + }), + }); + const attributionLayer = ResourceAttribution.layer; + const dependencies = Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer); + const telemetryLayer = ResourceTelemetry.layer.pipe(Layer.provide(dependencies)); + const layer = Layer.mergeAll(dependencies, telemetryLayer); + + yield* Effect.gen(function* () { + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + const attribution = yield* ResourceAttribution.ResourceAttribution; + + expect(yield* Ref.get(externalProcesses)).toEqual([{ pid: 5_000, startTimeMs: 300 }]); + + yield* attribution.record({ + component: "provider-event-log", + operation: "append", + logicalWriteBytes: 512, + count: 2, + durationMs: 4, + }); + const first = yield* telemetry.refresh; + expect(first.groups.backend.processCount).toBe(2); + expect(first.groups.electron.processCount).toBe(1); + expect(first.groups.monitor.processCount).toBe(1); + expect(first.power.onBattery).toBe("true"); + expect(Option.getOrNull(first.speedLimitPercent)).toBe(90); + expect(first.attribution.entries).toEqual([ + { + component: "provider-event-log", + operation: "append", + logicalReadBytes: 0, + logicalWriteBytes: 512, + count: 2, + durationMs: 4, + }, + ]); + + yield* TestClock.adjust(Duration.seconds(1)); + const second = yield* telemetry.refresh; + const codex = second.processes.find((entry) => entry.identity.pid === 4_242); + expect(codex?.cpuPercent).toBe(25); + expect(codex?.ioWriteBytesPerSecond).toBe(4_000); + expect(second.groups.backend.ioWriteBytes).toBe(4_000); + expect(second.health.collectionDurationMicros).toBe(300); + expect(second.health.scannedProcessCount).toBe(80); + expect(second.health.inaccessibleProcessCount).toBe(1); + + const history = yield* telemetry.readHistory({ + windowMs: 60_000, + bucketMs: 10_000, + }); + expect(history.retainedSampleCount).toBeGreaterThan(0); + expect( + history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.sampleCount, + ).toBe(2); + expect(history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.cpuTimeMs).toBe( + 250, + ); + expect( + history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.ioWriteBytes, + ).toBe(4_000); + expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe( + 4_000, + ); + + const retry = yield* telemetry.retry; + expect(retry.accepted).toBe(true); + expect(yield* Ref.get(retryCount)).toBe(1); + + yield* Ref.update(nativeHealth, (current) => ({ + ...current, + hello: Option.map(current.hello, (hello) => ({ + ...hello, + sidecarPid: 9_001, + })), + restartCount: 3, + })); + yield* TestClock.adjust(Duration.seconds(1)); + const restarted = yield* telemetry.refresh; + expect(DateTime.toEpochMillis(restarted.readAt)).toBe(startedAt + 2_000); + expect(Option.getOrNull(restarted.health.sidecarPid)).toBe(9_001); + + yield* Ref.update(nativeHealth, (current) => ({ + ...current, + status: "degraded" as const, + lastError: Option.some("collector exited"), + })); + yield* PubSub.publish(nativeHealthChanges, yield* Ref.get(nativeHealth)); + yield* Effect.yieldNow; + const healthUpdate = yield* telemetry.latest; + expect(healthUpdate.health.native.status).toBe("degraded"); + expect(Option.getOrNull(healthUpdate.health.native.lastError)).toBe("collector exited"); + const degradedHistory = yield* telemetry.readHistory({ + windowMs: 60_000, + bucketMs: 10_000, + }); + expect(degradedHistory.health.native.status).toBe("degraded"); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts new file mode 100644 index 000000000000..4dd7e721d474 --- /dev/null +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -0,0 +1,504 @@ +import type { + DesktopHostTelemetrySnapshot, + HostPowerSnapshot, + ResourceMonitorSnapshotEvent, + ResourceTelemetryHealth, + ResourceTelemetryHistoryInput, + ResourceTelemetryProcessIdentity, + ResourceTelemetryRetryResult, + ResourceTelemetrySnapshot, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; +import { + emptyTelemetryCounters, + mergeProcesses, + type ProcessState, + type TelemetryCounters, +} from "./Model.ts"; +import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; +import * as ResourceAttribution from "./ResourceAttribution.ts"; +import { + buildResourceTelemetryHistory, + normalizeResourceTelemetryHistoryInput, + type ResourceTelemetryHistoryWithLegacyBuckets, +} from "./ResourceTelemetryHistory.ts"; +import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts"; + +export class ResourceTelemetryRefreshFailed extends Schema.TaggedErrorClass()( + "ResourceTelemetryRefreshFailed", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Resource telemetry operation '${this.operation}' failed.`; + } +} + +export class ResourceTelemetry extends Context.Service< + ResourceTelemetry, + { + readonly latest: Effect.Effect; + readonly changes: Stream.Stream; + readonly subscribe: Effect.Effect< + { + readonly latest: ResourceTelemetrySnapshot; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; + readonly readHistory: ( + input: ResourceTelemetryHistoryInput, + ) => Effect.Effect; + readonly refresh: Effect.Effect; + readonly validateProcessIdentity: ( + identity: ResourceTelemetryProcessIdentity, + ) => Effect.Effect; + readonly retry: Effect.Effect; + } +>()("t3/resourceTelemetry/ResourceTelemetry") {} + +interface TelemetryState { + readonly nativeSnapshot: Option.Option; + readonly desktopSnapshot: Option.Option; + readonly previous: ReadonlyMap; + readonly counters: TelemetryCounters; + readonly latest: ResourceTelemetrySnapshot; + readonly lastNativeSequence: number; + readonly lastNativeGeneration: number; +} + +interface LiveTelemetryState { + readonly retainCount: number; + readonly scope: Option.Option; +} + +function unknownPower(updatedAt: DateTime.Utc): HostPowerSnapshot { + return { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt, + }; +} + +function buildHealth(input: { + readonly native: NativeTelemetryClient.NativeTelemetryClientHealth; + readonly desktop: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth; + readonly nativeSnapshot: Option.Option; +}): ResourceTelemetryHealth { + return { + native: { + status: input.native.status, + lastSampleAt: input.native.lastSampleAt, + lastError: input.native.lastError, + }, + desktop: { + status: input.desktop.status, + lastSampleAt: input.desktop.lastSampleAt, + lastError: input.desktop.lastError, + }, + sidecarVersion: Option.map(input.native.hello, (hello) => hello.sidecarVersion), + sidecarPid: Option.map(input.native.hello, (hello) => hello.sidecarPid), + restartCount: input.native.restartCount, + collectionDurationMicros: Option.match(input.nativeSnapshot, { + onNone: () => 0, + onSome: (snapshot) => snapshot.collectionDurationMicros, + }), + scannedProcessCount: Option.match(input.nativeSnapshot, { + onNone: () => 0, + onSome: (snapshot) => snapshot.scannedProcessCount, + }), + retainedProcessCount: Option.match(input.nativeSnapshot, { + onNone: () => 0, + onSome: (snapshot) => snapshot.retainedProcessCount, + }), + inaccessibleProcessCount: Option.match(input.nativeSnapshot, { + onNone: () => 0, + onSome: (snapshot) => snapshot.inaccessibleProcessCount, + }), + }; +} + +export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () { + const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient; + const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; + const attribution = yield* ResourceAttribution.ResourceAttribution; + const mutex = yield* Semaphore.make(1); + const changes = yield* PubSub.sliding(8); + const initialReadAt = yield* DateTime.now; + const nativeHealthSubscription = yield* nativeClient.subscribeHealth; + const desktopHealthSubscription = yield* desktopReceiver.subscribeHealth; + const desktopSubscription = yield* desktopReceiver.subscribe; + const initialDesktop = desktopSubscription.latest; + if (Option.isSome(initialDesktop)) { + const electronRoot = initialDesktop.value.electronProcesses.find( + (process) => process.pid === initialDesktop.value.electronPid, + ); + yield* nativeClient + .setExternalProcesses([ + { + pid: initialDesktop.value.electronPid, + ...(electronRoot === undefined ? {} : { startTimeMs: electronRoot.creationTimeMs }), + }, + ]) + .pipe(Effect.ignore); + yield* nativeClient.setHostPowerState(initialDesktop.value.power).pipe(Effect.ignore); + } + const initialNativeHealth = nativeHealthSubscription.latest; + const initialDesktopHealth = desktopHealthSubscription.latest; + const initialAttribution = yield* attribution.snapshot; + const initialMerge = mergeProcesses({ + serverPid: process.pid, + sidecarPid: Option.map(initialNativeHealth.hello, (hello) => hello.sidecarPid), + fallbackSampledAtMs: DateTime.toEpochMillis(initialReadAt), + nativeSnapshot: Option.none(), + desktopSnapshot: initialDesktop, + previous: new Map(), + counters: emptyTelemetryCounters(), + updatePrevious: false, + }); + const initialSnapshot: ResourceTelemetrySnapshot = { + readAt: initialReadAt, + sampleIntervalMs: initialNativeHealth.sampleIntervalMs, + processes: initialMerge.processes, + groups: initialMerge.groups, + power: Option.match(initialDesktop, { + onNone: () => unknownPower(initialReadAt), + onSome: (desktop) => desktop.power, + }), + speedLimitPercent: Option.flatMap(initialDesktop, (desktop) => desktop.speedLimitPercent), + attribution: initialAttribution, + health: buildHealth({ + native: initialNativeHealth, + desktop: initialDesktopHealth, + nativeSnapshot: Option.none(), + }), + }; + const state = yield* Ref.make({ + nativeSnapshot: Option.none(), + desktopSnapshot: initialDesktop, + previous: new Map(), + counters: emptyTelemetryCounters(), + latest: initialSnapshot, + lastNativeSequence: 0, + lastNativeGeneration: initialNativeHealth.restartCount, + }); + const liveState = yield* Ref.make({ + retainCount: 0, + scope: Option.none(), + }); + const liveMutex = yield* Semaphore.make(1); + const refreshHealth = mutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + const [nativeHealth, desktopHealth] = yield* Effect.all([ + nativeClient.health, + desktopReceiver.health, + ]); + const snapshot: ResourceTelemetrySnapshot = { + ...current.latest, + health: buildHealth({ + native: nativeHealth, + desktop: desktopHealth, + nativeSnapshot: current.nativeSnapshot, + }), + }; + yield* Ref.set(state, { + ...current, + latest: snapshot, + }); + if ((yield* Ref.get(liveState)).retainCount > 0) { + yield* PubSub.publish(changes, snapshot); + } + }), + ); + + const rebuild = (input: { + readonly nativeSnapshot?: NativeTelemetryClient.NativeTelemetrySnapshot; + readonly desktopSnapshot?: DesktopHostTelemetrySnapshot; + readonly updatePrevious: boolean; + readonly publishWhenLive?: boolean; + }): Effect.Effect => + mutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + const nativeHealth = yield* nativeClient.health; + const incomingNativeSnapshot = input.nativeSnapshot; + if ( + incomingNativeSnapshot && + (incomingNativeSnapshot.generation < nativeHealth.restartCount || + incomingNativeSnapshot.generation < current.lastNativeGeneration || + (incomingNativeSnapshot.generation === current.lastNativeGeneration && + incomingNativeSnapshot.snapshot.sequence <= current.lastNativeSequence)) + ) { + return current.latest; + } + const nativeSnapshot = incomingNativeSnapshot + ? Option.some(incomingNativeSnapshot.snapshot) + : current.nativeSnapshot; + const desktopSnapshot = input.desktopSnapshot + ? Option.some(input.desktopSnapshot) + : current.desktopSnapshot; + const [desktopHealth, attributionSnapshot] = yield* Effect.all([ + desktopReceiver.health, + attribution.snapshot, + ]); + const recordedElectronRoots = Option.match(nativeSnapshot, { + onNone: () => [], + onSome: (snapshot) => snapshot.externalProcesses ?? [], + }); + const electronRootPids = new Set(recordedElectronRoots.map((process) => process.pid)); + Option.match(desktopSnapshot, { + onNone: () => undefined, + onSome: (desktop) => electronRootPids.add(desktop.electronPid), + }); + const electronRootStartTimes = new Map( + recordedElectronRoots.flatMap((process) => + process.startTimeMs === undefined ? [] : [[process.pid, process.startTimeMs] as const], + ), + ); + const merged = mergeProcesses({ + serverPid: process.pid, + sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), + fallbackSampledAtMs: DateTime.toEpochMillis(current.latest.readAt), + nativeSnapshot, + desktopSnapshot, + electronRootPids, + electronRootStartTimes, + previous: current.previous, + counters: current.counters, + updatePrevious: input.updatePrevious, + }); + const readAt = DateTime.makeUnsafe(merged.sampledAtMs); + const snapshot: ResourceTelemetrySnapshot = { + readAt, + sampleIntervalMs: nativeHealth.sampleIntervalMs, + processes: merged.processes, + groups: merged.groups, + power: Option.match(desktopSnapshot, { + onNone: () => unknownPower(readAt), + onSome: (desktop) => desktop.power, + }), + speedLimitPercent: Option.match(desktopSnapshot, { + onNone: () => Option.none(), + onSome: (desktop) => desktop.speedLimitPercent, + }), + attribution: attributionSnapshot, + health: buildHealth({ + native: nativeHealth, + desktop: desktopHealth, + nativeSnapshot, + }), + }; + yield* Ref.set(state, { + nativeSnapshot, + desktopSnapshot, + previous: merged.previous, + counters: merged.counters, + latest: snapshot, + lastNativeSequence: + incomingNativeSnapshot?.snapshot.sequence ?? current.lastNativeSequence, + lastNativeGeneration: incomingNativeSnapshot?.generation ?? current.lastNativeGeneration, + }); + if (!input.publishWhenLive || (yield* Ref.get(liveState)).retainCount > 0) { + yield* PubSub.publish(changes, snapshot); + } + return snapshot; + }), + ); + + const ingestNative = (snapshot: NativeTelemetryClient.NativeTelemetrySnapshot) => + rebuild({ nativeSnapshot: snapshot, updatePrevious: true }); + const ingestDesktop = (snapshot: DesktopHostTelemetrySnapshot) => + Effect.gen(function* () { + const electronRoot = snapshot.electronProcesses.find( + (process) => process.pid === snapshot.electronPid, + ); + yield* nativeClient + .setExternalProcesses([ + { + pid: snapshot.electronPid, + ...(electronRoot === undefined ? {} : { startTimeMs: electronRoot.creationTimeMs }), + }, + ]) + .pipe(Effect.ignore); + yield* nativeClient.setHostPowerState(snapshot.power).pipe(Effect.ignore); + return yield* rebuild({ + desktopSnapshot: snapshot, + updatePrevious: false, + publishWhenLive: true, + }); + }); + + yield* desktopSubscription.changes.pipe( + Stream.runForEach((snapshot) => ingestDesktop(snapshot)), + Effect.forkScoped, + ); + + const acquireLive = liveMutex.withPermits(1)( + Effect.uninterruptible( + Effect.gen(function* () { + const current = yield* Ref.get(liveState); + if (current.retainCount > 0) { + yield* Ref.set(liveState, { ...current, retainCount: current.retainCount + 1 }); + return; + } + + const scope = yield* Scope.make(); + yield* desktopReceiver.setDiagnosticsDemand(true).pipe(Effect.ignore); + yield* nativeClient.snapshots.pipe( + Stream.runForEach(ingestNative), + Effect.catch((error) => + Effect.logWarning("Native resource telemetry stream stopped", { + cause: error.message, + }), + ), + Effect.forkIn(scope), + ); + yield* nativeClient.sampleNow.pipe( + Effect.flatMap(ingestNative), + Effect.ignore, + Effect.forkIn(scope), + ); + yield* Ref.set(liveState, { retainCount: 1, scope: Option.some(scope) }); + }), + ), + ); + + const releaseLive = liveMutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(liveState); + if (current.retainCount <= 1) { + yield* Ref.set(liveState, { retainCount: 0, scope: Option.none() }); + if (Option.isSome(current.scope)) { + yield* Scope.close(current.scope.value, Exit.void).pipe(Effect.ignore); + } + yield* desktopReceiver.setDiagnosticsDemand(false).pipe(Effect.ignore); + return; + } + yield* Ref.set(liveState, { ...current, retainCount: current.retainCount - 1 }); + }), + ); + + const latest = Ref.get(state).pipe(Effect.map((current) => current.latest)); + const subscribe = subscribeBeforeSnapshot( + changes, + Effect.acquireRelease(acquireLive, () => releaseLive).pipe(Effect.andThen(latest)), + mutex, + ); + const liveChanges = Stream.unwrap(Effect.map(subscribe, ({ changes }) => changes)); + + const readHistory: ResourceTelemetry["Service"]["readHistory"] = (input) => + Effect.gen(function* () { + const readAt = yield* DateTime.now; + const normalizedInput = normalizeResourceTelemetryHistoryInput(input); + const historyResult = yield* Effect.result( + nativeClient.readHistory(normalizedInput.windowMs), + ); + if (Result.isFailure(historyResult)) { + yield* Effect.logWarning("Failed to read native resource telemetry history", { + cause: historyResult.failure.message, + }); + } + const [nativeHealth, desktopHealth] = yield* Effect.all([ + nativeClient.health, + desktopReceiver.health, + ]); + const current = yield* Ref.get(state); + return buildResourceTelemetryHistory({ + readAt, + windowMs: normalizedInput.windowMs, + bucketMs: normalizedInput.bucketMs, + sampleIntervalMs: nativeHealth.sampleIntervalMs, + serverPid: process.pid, + sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), + desktopSnapshot: current.desktopSnapshot, + snapshots: Result.isSuccess(historyResult) ? historyResult.success : [], + health: buildHealth({ + native: nativeHealth, + desktop: desktopHealth, + nativeSnapshot: current.nativeSnapshot, + }), + }); + }); + yield* nativeHealthSubscription.changes.pipe( + Stream.runForEach(() => refreshHealth), + Effect.forkScoped, + ); + yield* desktopHealthSubscription.changes.pipe( + Stream.runForEach(() => refreshHealth), + Effect.forkScoped, + ); + + const refresh: ResourceTelemetry["Service"]["refresh"] = nativeClient.sampleNow.pipe( + Effect.flatMap(ingestNative), + Effect.mapError( + (cause) => + new ResourceTelemetryRefreshFailed({ + operation: "refresh", + cause, + }), + ), + ); + + const validateProcessIdentity: ResourceTelemetry["Service"]["validateProcessIdentity"] = ( + identity, + ) => + nativeClient.sampleNow.pipe( + Effect.map(({ snapshot }) => + snapshot.processes.some( + (process) => process.pid === identity.pid && process.startTimeMs === identity.startTimeMs, + ), + ), + Effect.mapError( + (cause) => + new ResourceTelemetryRefreshFailed({ + operation: "validateProcessIdentity", + cause, + }), + ), + ); + + return ResourceTelemetry.of({ + latest, + changes: liveChanges, + subscribe, + readHistory, + refresh, + validateProcessIdentity, + retry: nativeClient.retry.pipe( + Effect.zip(Ref.get(state)), + Effect.map( + ([accepted, current]): ResourceTelemetryRetryResult => ({ + accepted, + snapshot: current.latest, + }), + ), + ), + }); +}); + +export const layer = Layer.effect(ResourceTelemetry, make()); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts new file mode 100644 index 000000000000..879c83d86dee --- /dev/null +++ b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts @@ -0,0 +1,357 @@ +import type { + DesktopHostTelemetrySnapshot, + ResourceMonitorProcessSample, + ResourceMonitorSnapshotEvent, + ResourceTelemetryHealth, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +import { + buildResourceTelemetryHistory, + normalizeResourceTelemetryHistoryInput, +} from "./ResourceTelemetryHistory.ts"; + +const SERVER_PID = 100; +const ELECTRON_PID = 200; +const CHILD_PID = 300; +const STARTED_AT_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z")); + +function processSample( + input: Partial & + Pick, +): ResourceMonitorProcessSample { + return { + runTimeMs: 1_000, + name: `process-${input.pid}`, + command: `process-${input.pid}`, + status: "Running", + cpuPercent: 0, + cpuTimeMs: 0, + residentBytes: 1_024, + virtualBytes: 2_048, + ioReadBytes: 0, + ioWriteBytes: 0, + ioSemantics: "storage", + ...input, + }; +} + +function snapshot( + sequence: number, + sampledAtUnixMs: number, + childCpuTimeMs: number, + childWriteBytes: number, +): ResourceMonitorSnapshotEvent { + const processes = [ + processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 10 }), + processSample({ + pid: ELECTRON_PID, + ppid: 1, + startTimeMs: 20, + name: "electron", + command: "electron", + }), + processSample({ + pid: CHILD_PID, + ppid: SERVER_PID, + startTimeMs: 30, + name: "codex", + command: "codex app-server", + cpuTimeMs: childCpuTimeMs, + ioWriteBytes: childWriteBytes, + }), + ]; + return { + version: 2, + type: "snapshot", + sequence, + sampledAtUnixMs, + collectionDurationMicros: 100, + scannedProcessCount: processes.length, + retainedProcessCount: processes.length, + inaccessibleProcessCount: 0, + processes, + }; +} + +const health: ResourceTelemetryHealth = { + native: { + status: "healthy", + lastSampleAt: Option.none(), + lastError: Option.none(), + }, + desktop: { + status: "healthy", + lastSampleAt: Option.none(), + lastError: Option.none(), + }, + sidecarVersion: Option.some("0.1.0"), + sidecarPid: Option.some(400), + restartCount: 0, + collectionDurationMicros: 100, + scannedProcessCount: 3, + retainedProcessCount: 3, + inaccessibleProcessCount: 0, +}; + +function desktopSnapshot(): DesktopHostTelemetrySnapshot { + const sampledAt = DateTime.makeUnsafe(STARTED_AT_MS + 1_000); + return { + version: 1, + type: "desktopTelemetry", + sequence: 1, + sampledAtUnixMs: STARTED_AT_MS + 1_000, + electronPid: ELECTRON_PID, + power: { + source: "electron-main", + idle: "false", + idleSeconds: 0, + locked: "false", + suspended: false, + onBattery: "false", + lowPowerMode: "unknown", + thermalState: "nominal", + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: Option.none(), + electronProcesses: [ + { + pid: ELECTRON_PID, + creationTimeMs: 20, + type: "Browser", + cpuPercent: 999, + idleWakeupsPerSecond: 999, + workingSetBytes: 999_999, + peakWorkingSetBytes: 999_999, + }, + ], + }; +} + +describe("buildResourceTelemetryHistory", () => { + it("normalizes query bounds before requesting native history", () => { + expect(normalizeResourceTelemetryHistoryInput({ windowMs: 0, bucketMs: 0 })).toEqual({ + windowMs: 1_000, + bucketMs: 1_000, + }); + }); + + it("replays native snapshots on demand without applying current Electron metrics", () => { + const history = buildResourceTelemetryHistory({ + readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), + windowMs: 10_000, + bucketMs: 10_000, + sampleIntervalMs: 1_000, + serverPid: SERVER_PID, + sidecarPid: Option.some(400), + desktopSnapshot: Option.some(desktopSnapshot()), + snapshots: [ + snapshot(1, STARTED_AT_MS, 100, 1_000), + snapshot(2, STARTED_AT_MS + 1_000, 350, 5_000), + ], + health, + }); + + const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); + const electron = history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID); + expect(child?.sampleCount).toBe(2); + expect(child?.cpuTimeMs).toBe(250); + expect(child?.ioWriteBytes).toBe(4_000); + expect(electron?.category).toBe("electron-main"); + expect(electron?.currentRssBytes).toBe(1_024); + expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(4_000); + }); + + it("uses observed RSS for the history-window peak instead of the lifetime process peak", () => { + const first = snapshot(1, STARTED_AT_MS, 100, 1_000); + const second = snapshot(2, STARTED_AT_MS + 1_000, 200, 2_000); + const history = buildResourceTelemetryHistory({ + readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), + windowMs: 10_000, + bucketMs: 10_000, + sampleIntervalMs: 1_000, + serverPid: SERVER_PID, + sidecarPid: Option.none(), + desktopSnapshot: Option.none(), + snapshots: [ + { + ...first, + processes: first.processes.map((process) => + process.pid === CHILD_PID + ? { ...process, residentBytes: 2_000, peakResidentBytes: 50_000 } + : process, + ), + }, + { + ...second, + processes: second.processes.map((process) => + process.pid === CHILD_PID + ? { ...process, residentBytes: 3_000, peakResidentBytes: 60_000 } + : process, + ), + }, + ], + health, + }); + + expect( + history.topProcesses.find((process) => process.identity.pid === CHILD_PID)?.peakRssBytes, + ).toBe(3_000); + }); + + it("retains cumulative baselines while a process is absent from an intermediate sample", () => { + const first = snapshot(1, STARTED_AT_MS, 100, 1_000); + const absent = snapshot(2, STARTED_AT_MS + 1_000, 0, 0); + const returned = snapshot(3, STARTED_AT_MS + 2_000, 350, 5_000); + const history = buildResourceTelemetryHistory({ + readAt: DateTime.makeUnsafe(STARTED_AT_MS + 3_000), + windowMs: 10_000, + bucketMs: 10_000, + sampleIntervalMs: 1_000, + serverPid: SERVER_PID, + sidecarPid: Option.none(), + desktopSnapshot: Option.none(), + snapshots: [ + first, + { + ...absent, + processes: absent.processes.filter((process) => process.pid !== CHILD_PID), + }, + returned, + ], + health, + }); + + const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); + expect(child?.cpuTimeMs).toBe(250); + expect(child?.ioWriteBytes).toBe(4_000); + }); + + it("uses an exact current Electron identity for slightly older native samples", () => { + const history = buildResourceTelemetryHistory({ + readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), + windowMs: 10_000, + bucketMs: 10_000, + sampleIntervalMs: 1_000, + serverPid: SERVER_PID, + sidecarPid: Option.none(), + desktopSnapshot: Option.some(desktopSnapshot()), + snapshots: [snapshot(1, STARTED_AT_MS, 100, 1_000)], + health, + }); + + expect( + history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID)?.category, + ).toBe("electron-main"); + }); + + it("uses the preceding sample as a baseline without attributing pre-window deltas", () => { + const readAtMs = STARTED_AT_MS + 10_000; + const history = buildResourceTelemetryHistory({ + readAt: DateTime.makeUnsafe(readAtMs), + windowMs: 5_000, + bucketMs: 5_000, + sampleIntervalMs: 1_000, + serverPid: SERVER_PID, + sidecarPid: Option.none(), + desktopSnapshot: Option.none(), + snapshots: [ + snapshot(1, STARTED_AT_MS, 100, 1_000), + snapshot(2, STARTED_AT_MS + 5_000, 600, 6_000), + snapshot(3, readAtMs + 1_000, 10_000, 20_000), + ], + health, + }); + + const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); + expect(child?.sampleCount).toBe(1); + expect(child?.cpuTimeMs).toBe(0); + expect(child?.ioWriteBytes).toBe(0); + expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(0); + expect( + history.buckets.every((bucket) => DateTime.toEpochMillis(bucket.startedAt) <= readAtMs), + ).toBe(true); + }); + + it("prorates a cumulative delta that crosses the history window boundary", () => { + const readAtMs = STARTED_AT_MS + 10_000; + const history = buildResourceTelemetryHistory({ + readAt: DateTime.makeUnsafe(readAtMs), + windowMs: 5_000, + bucketMs: 5_000, + sampleIntervalMs: 1_000, + serverPid: SERVER_PID, + sidecarPid: Option.none(), + desktopSnapshot: Option.none(), + snapshots: [ + snapshot(1, STARTED_AT_MS, 100, 1_000), + snapshot(2, STARTED_AT_MS + 7_500, 850, 8_500), + ], + health, + }); + + const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID); + expect(child?.cpuTimeMs).toBe(250); + expect(child?.ioWriteBytes).toBe(2_500); + expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(2_500); + }); + + it("replays the Electron root identity recorded with each native sample", () => { + const oldElectron = snapshot(1, STARTED_AT_MS, 100, 1_000); + const restartedElectron = snapshot(2, STARTED_AT_MS + 1_000, 200, 2_000); + const history = buildResourceTelemetryHistory({ + readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), + windowMs: 10_000, + bucketMs: 10_000, + sampleIntervalMs: 1_000, + serverPid: SERVER_PID, + sidecarPid: Option.none(), + desktopSnapshot: Option.none(), + snapshots: [ + { + ...oldElectron, + externalProcesses: [{ pid: ELECTRON_PID, startTimeMs: 20 }], + }, + { + ...restartedElectron, + externalProcesses: [{ pid: 201, startTimeMs: 40 }], + processes: [ + ...restartedElectron.processes.filter((process) => process.pid !== ELECTRON_PID), + processSample({ + pid: ELECTRON_PID, + ppid: SERVER_PID, + startTimeMs: 999, + name: "reused", + command: "unrelated process", + }), + processSample({ + pid: 201, + ppid: 1, + startTimeMs: 40, + name: "electron", + command: "electron", + }), + ], + }, + ], + health, + }); + + expect( + history.topProcesses.find( + (process) => process.identity.pid === ELECTRON_PID && process.identity.startTimeMs === 20, + )?.category, + ).toBe("electron-main"); + expect( + history.topProcesses.find( + (process) => process.identity.pid === ELECTRON_PID && process.identity.startTimeMs === 999, + )?.category, + ).toBe("server-child"); + expect(history.topProcesses.find((process) => process.identity.pid === 201)?.category).toBe( + "electron-main", + ); + }); +}); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.ts b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.ts new file mode 100644 index 000000000000..737a934de26d --- /dev/null +++ b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.ts @@ -0,0 +1,290 @@ +import type { + DesktopHostTelemetrySnapshot, + ResourceMonitorSnapshotEvent, + ResourceTelemetryHealth, + ResourceTelemetryHistory, + ResourceTelemetryHistoryBucket, + ResourceTelemetryProcess, + ResourceTelemetryProcessSummary, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +import { + emptyTelemetryCounters, + mergeProcesses, + processIdentityKey, + type ProcessState, + type TelemetryCounters, +} from "./Model.ts"; + +const MAX_HISTORY_WINDOW_MS = 60 * 60_000; + +export function normalizeResourceTelemetryHistoryInput(input: { + readonly windowMs: number; + readonly bucketMs: number; +}): { readonly windowMs: number; readonly bucketMs: number } { + const windowMs = Math.max(1_000, Math.min(MAX_HISTORY_WINDOW_MS, input.windowMs)); + return { + windowMs, + bucketMs: Math.max(1_000, Math.min(windowMs, input.bucketMs)), + }; +} + +interface AggregateSample { + readonly sampledAtMs: number; + readonly cpuPercent: number; + readonly rssBytes: number; + readonly processCount: number; + readonly ioReadBytes: number; + readonly ioWriteBytes: number; +} + +interface ProcessSample { + readonly sampledAtMs: number; + readonly process: ResourceTelemetryProcess; + readonly cpuTimeMs: number; + readonly ioReadBytes: number; + readonly ioWriteBytes: number; +} + +export interface BuildResourceTelemetryHistoryInput { + readonly readAt: DateTime.Utc; + readonly windowMs: number; + readonly bucketMs: number; + readonly sampleIntervalMs: number; + readonly serverPid: number; + readonly sidecarPid: Option.Option; + readonly desktopSnapshot: Option.Option; + readonly snapshots: ReadonlyArray; + readonly health: ResourceTelemetryHealth; +} + +export type ResourceTelemetryHistoryWithLegacyBuckets = ResourceTelemetryHistory & { + readonly legacyBackendBuckets?: ReadonlyArray; +}; + +function summarizeProcesses( + samples: ReadonlyArray, +): ReadonlyArray { + const groups = new Map(); + for (const sample of samples) { + const identityKey = processIdentityKey( + sample.process.identity.pid, + sample.process.identity.startTimeMs, + ); + const current = groups.get(identityKey) ?? []; + current.push(sample); + groups.set(identityKey, current); + } + + return [...groups.values()] + .map((processSamples): ResourceTelemetryProcessSummary => { + const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); + const first = sorted[0]!; + const latest = sorted[sorted.length - 1]!; + const cpuTotal = sorted.reduce((total, sample) => total + sample.process.cpuPercent, 0); + return { + identity: latest.process.identity, + ppid: latest.process.ppid, + depth: latest.process.depth, + name: latest.process.name, + command: latest.process.command, + category: latest.process.category, + firstSeenAt: first.process.firstSeenAt, + lastSeenAt: latest.process.lastSeenAt, + currentCpuPercent: latest.process.cpuPercent, + avgCpuPercent: cpuTotal / sorted.length, + maxCpuPercent: Math.max(...sorted.map((sample) => sample.process.cpuPercent)), + cpuTimeMs: sorted.reduce((total, sample) => total + sample.cpuTimeMs, 0), + currentRssBytes: latest.process.residentBytes, + peakRssBytes: Math.max(...sorted.map((sample) => sample.process.residentBytes)), + ioReadBytes: sorted.reduce((total, sample) => total + sample.ioReadBytes, 0), + ioWriteBytes: sorted.reduce((total, sample) => total + sample.ioWriteBytes, 0), + ioSemantics: latest.process.ioSemantics, + sampleCount: sorted.length, + }; + }) + .toSorted( + (left, right) => right.cpuTimeMs - left.cpuTimeMs || right.peakRssBytes - left.peakRssBytes, + ); +} + +function buildBuckets(input: { + readonly samples: ReadonlyArray; + readonly nowMs: number; + readonly windowMs: number; + readonly bucketMs: number; +}): ReadonlyArray { + const windowStartMs = input.nowMs - input.windowMs; + const buckets: ResourceTelemetryHistoryBucket[] = []; + for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += input.bucketMs) { + const endedAtMs = Math.min(input.nowMs, startedAtMs + input.bucketMs); + const samples = input.samples.filter( + (sample) => + sample.sampledAtMs >= startedAtMs && + (endedAtMs === input.nowMs + ? sample.sampledAtMs <= endedAtMs + : sample.sampledAtMs < endedAtMs), + ); + const cpuTotal = samples.reduce((total, sample) => total + sample.cpuPercent, 0); + buckets.push({ + startedAt: DateTime.makeUnsafe(startedAtMs), + endedAt: DateTime.makeUnsafe(endedAtMs), + avgCpuPercent: samples.length === 0 ? 0 : cpuTotal / samples.length, + maxCpuPercent: + samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.cpuPercent)), + maxRssBytes: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.rssBytes)), + ioReadBytes: samples.reduce((total, sample) => total + sample.ioReadBytes, 0), + ioWriteBytes: samples.reduce((total, sample) => total + sample.ioWriteBytes, 0), + maxProcessCount: + samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.processCount)), + }); + } + return buckets; +} + +export function buildResourceTelemetryHistory( + input: BuildResourceTelemetryHistoryInput, +): ResourceTelemetryHistoryWithLegacyBuckets & { + readonly legacyBackendBuckets: ReadonlyArray; +} { + const readAtMs = DateTime.toEpochMillis(input.readAt); + const { windowMs, bucketMs } = normalizeResourceTelemetryHistoryInput(input); + const windowStartMs = readAtMs - windowMs; + const eligibleSnapshots = input.snapshots + .filter((snapshot) => snapshot.sampledAtUnixMs <= readAtMs) + .toSorted((left, right) => left.sampledAtUnixMs - right.sampledAtUnixMs); + const snapshotsInWindow = eligibleSnapshots.filter( + (snapshot) => snapshot.sampledAtUnixMs >= windowStartMs, + ); + const precedingSnapshot = eligibleSnapshots.findLast( + (snapshot) => snapshot.sampledAtUnixMs < windowStartMs, + ); + const snapshots = precedingSnapshot + ? [precedingSnapshot, ...snapshotsInWindow] + : snapshotsInWindow; + const aggregateSamples: AggregateSample[] = []; + const legacyBackendAggregateSamples: AggregateSample[] = []; + const processSamples: ProcessSample[] = []; + let previous: ReadonlyMap = new Map(); + let counters: TelemetryCounters = emptyTelemetryCounters(); + let previousSnapshotAtMs: number | undefined; + + for (const snapshot of snapshots) { + const deltaWindowFraction = + previousSnapshotAtMs !== undefined && + previousSnapshotAtMs < windowStartMs && + snapshot.sampledAtUnixMs > previousSnapshotAtMs + ? Math.max( + 0, + Math.min( + 1, + (snapshot.sampledAtUnixMs - windowStartMs) / + (snapshot.sampledAtUnixMs - previousSnapshotAtMs), + ), + ) + : 1; + previousSnapshotAtMs = snapshot.sampledAtUnixMs; + const recordedExternalProcesses = + snapshot.externalProcesses ?? + Option.match(input.desktopSnapshot, { + onNone: () => [], + onSome: (desktopSnapshot) => [ + { + pid: desktopSnapshot.electronPid, + startTimeMs: desktopSnapshot.electronProcesses.find( + (metric) => metric.pid === desktopSnapshot.electronPid, + )?.creationTimeMs, + }, + ], + }); + const electronRootPids = new Set(recordedExternalProcesses.map((process) => process.pid)); + const electronRootStartTimes = new Map( + recordedExternalProcesses.flatMap((process) => + process.startTimeMs === undefined ? [] : [[process.pid, process.startTimeMs] as const], + ), + ); + const merged = mergeProcesses({ + serverPid: input.serverPid, + sidecarPid: input.sidecarPid, + fallbackSampledAtMs: snapshot.sampledAtUnixMs, + nativeSnapshot: Option.some(snapshot), + desktopSnapshot: Option.none(), + electronRootPids, + electronRootStartTimes, + previous, + counters, + updatePrevious: true, + }); + previous = new Map([...previous, ...merged.previous]); + counters = merged.counters; + if (snapshot.sampledAtUnixMs < windowStartMs) { + continue; + } + const deltas = + deltaWindowFraction === 1 + ? merged.deltas + : merged.deltas.map((delta) => ({ + ...delta, + cpuTimeMs: Math.round(delta.cpuTimeMs * deltaWindowFraction), + ioReadBytes: Math.round(delta.ioReadBytes * deltaWindowFraction), + ioWriteBytes: Math.round(delta.ioWriteBytes * deltaWindowFraction), + })); + const deltasByIdentity = new Map( + deltas.map((processDelta) => [processDelta.identityKey, processDelta]), + ); + aggregateSamples.push({ + sampledAtMs: snapshot.sampledAtUnixMs, + cpuPercent: merged.groups.allT3.currentCpuPercent, + rssBytes: merged.groups.allT3.currentRssBytes, + processCount: merged.groups.allT3.processCount, + ioReadBytes: deltas.reduce((total, process) => total + process.ioReadBytes, 0), + ioWriteBytes: deltas.reduce((total, process) => total + process.ioWriteBytes, 0), + }); + const backendDeltas = deltas.filter( + (processDelta) => + processDelta.category === "server" || + processDelta.category === "server-child" || + processDelta.category === "provider-root" || + processDelta.category === "terminal-root", + ); + legacyBackendAggregateSamples.push({ + sampledAtMs: snapshot.sampledAtUnixMs, + cpuPercent: merged.groups.backend.currentCpuPercent, + rssBytes: merged.groups.backend.currentRssBytes, + processCount: merged.groups.backend.processCount, + ioReadBytes: backendDeltas.reduce((total, process) => total + process.ioReadBytes, 0), + ioWriteBytes: backendDeltas.reduce((total, process) => total + process.ioWriteBytes, 0), + }); + for (const process of merged.processes) { + const processDelta = deltasByIdentity.get( + processIdentityKey(process.identity.pid, process.identity.startTimeMs), + ); + processSamples.push({ + sampledAtMs: snapshot.sampledAtUnixMs, + process, + cpuTimeMs: processDelta?.cpuTimeMs ?? 0, + ioReadBytes: processDelta?.ioReadBytes ?? 0, + ioWriteBytes: processDelta?.ioWriteBytes ?? 0, + }); + } + } + + return { + readAt: input.readAt, + windowMs, + bucketMs, + sampleIntervalMs: input.sampleIntervalMs, + retainedSampleCount: aggregateSamples.length + processSamples.length, + buckets: buildBuckets({ samples: aggregateSamples, nowMs: readAtMs, windowMs, bucketMs }), + legacyBackendBuckets: buildBuckets({ + samples: legacyBackendAggregateSamples, + nowMs: readAtMs, + windowMs, + bucketMs, + }), + topProcesses: summarizeProcesses(processSamples), + health: input.health, + }; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f47bded1a77b..f12cb8fffdc2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -71,6 +71,7 @@ import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { makeRoutesLayer } from "./server.ts"; @@ -116,6 +117,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; +import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; +import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; +import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -355,6 +360,10 @@ const buildAppUnderTest = (options?: { >; relayClient?: Partial; cloudCliTokenManager?: Partial; + nativeTelemetryClient?: Partial; + desktopTelemetryReceiver?: Partial< + DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"] + >; }; }) => Effect.gen(function* () { @@ -531,6 +540,15 @@ const buildAppUnderTest = (options?: { ...options.layers.vcsStatusBroadcaster, }) : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); + const resourceTelemetryLayer = ResourceTelemetry.layer.pipe( + Layer.provide( + Layer.mergeAll( + NativeTelemetryClient.layerTest(options?.layers?.nativeTelemetryClient), + DesktopTelemetryReceiver.layerTest(options?.layers?.desktopTelemetryReceiver), + ResourceAttribution.layer, + ), + ), + ); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -748,6 +766,7 @@ const buildAppUnderTest = (options?: { ); const appLayer = servedRoutesLayer.pipe( + Layer.provide(resourceTelemetryLayer), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -770,6 +789,58 @@ const buildAppUnderTest = (options?: { ...options?.layers?.serverRuntimeStartup, }), ), + Layer.provide( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: false, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + subscribe: Effect.succeed({ + latest: { + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: false, + updatedAt: TEST_EPOCH, + }, + changes: Stream.empty, + }), + hasDemand: () => Effect.succeed(false), + shouldRunScopeWork: () => Effect.succeed(false), + shouldRunOpportunisticWork: Effect.succeed(false), + }), + ), Layer.provide( Layer.mock(ServerEnvironment.ServerEnvironment)({ getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), @@ -4398,6 +4469,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket resource telemetry through the subscription", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const snapshot = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeResourceTelemetry]({}).pipe(Stream.runHead), + ), + ); + + assertTrue(Option.isSome(snapshot)); + assert.equal(snapshot.value.processes.length, 0); + assert.equal(snapshot.value.groups.backend.processCount, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0b7c8ccd074b..e0d36e99bc96 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,8 @@ import * as Layer from "effect/Layer"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { @@ -90,6 +92,11 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; +import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; +import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; +import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; +import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -106,6 +113,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale" // already closes the websocket gracefully. Do not add an artificial drain before // those finalizers get a chance to run. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; +const ResourceAttributionLayerLive = ResourceAttribution.layer; +const ApplicationObservabilityLive = ObservabilityLive.pipe( + Layer.provideMerge(ResourceAttributionLayerLive), +); const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -119,6 +130,35 @@ const PtyAdapterLive = Layer.unwrap( }), ); +const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); + +const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( + Layer.provide(ResourceMonitorBinary.layer), +); +const DesktopTelemetryReceiverLayerLive = DesktopTelemetryReceiver.layer.pipe( + Layer.provideMerge(ServerSettingsLayerLive), +); + +const ResourceTelemetryLayerLive = ResourceTelemetry.layer.pipe( + Layer.provideMerge(NativeTelemetryLayerLive), + Layer.provideMerge(DesktopTelemetryReceiverLayerLive), +); + +const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe( + Layer.provide(DesktopTelemetryReceiverLayerLive), +); + +const BackgroundLayerLive = BackgroundPolicy.layer.pipe( + Layer.provide(HostPowerMonitorLayerLive), + Layer.provideMerge(ServerSettingsLayerLive), +); + +const ResourceDiagnosticsLayerLive = Layer.mergeAll( + ResourceTelemetryLayerLive, + ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), + ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), +); + const RelayClientLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -184,7 +224,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter // by looking up the default `ProviderInstance` per driver in the instance // registry. Adapter construction itself moved inside each driver's -// `create()`; `ProviderEventLoggersLive` owns the shared native/canonical +// `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical // NDJSON writers and is provided at the outer runtime layer so both // `ProviderService` and the per-instance drivers read the same logger pair. const ProviderLayerLive = ProviderServiceLive.pipe( @@ -298,6 +338,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services + Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), @@ -318,14 +359,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive), + Layer.provideMerge(ProviderEventLoggers.layer), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), - Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), @@ -345,8 +385,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. - Layer.provideMerge(ProcessDiagnostics.layer), - Layer.provideMerge(ProcessResourceMonitor.layer), + Layer.provideMerge(BackgroundLayerLive), + Layer.provideMerge(ResourceDiagnosticsLayerLive), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), @@ -522,7 +562,7 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpResponseCompressionLive), Layer.provideMerge(HttpServerLive), - Layer.provide(ObservabilityLive), + Layer.provide(ApplicationObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 50ca810a95a5..d38a3064910d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -12,8 +12,10 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; @@ -203,6 +205,29 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("buffers changes after a subscription is acquired but before it is consumed", () => + Effect.scoped( + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + + yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/usr/local/bin/codex-next", + }, + }, + }); + + const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); + assert.equal( + Option.getOrUndefined(firstChange)?.providers.codex.binaryPath, + "/usr/local/bin/codex-next", + ); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -590,6 +615,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverPassword: "secret-password", }, }, + backgroundActivity: { + schemaVersion: 1, + profile: "custom", + baseProfile: "balanced", + overrides: { + automaticGitFetchInterval: 10_000, + }, + }, automaticGitFetchInterval: 10_000, }); }).pipe(Effect.provide(makeServerSettingsLayer())), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 82fd2f29f03f..2798faf6f006 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -131,6 +131,13 @@ export class ServerSettingsService extends Context.Service< /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; + + /** + * Acquire a settings change subscription synchronously in the current + * fiber. Use this before reading a snapshot when changes between the + * snapshot and a lazily started stream must not be lost. + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { /** @deprecated Import and use `layerTest` from this module. */ @@ -139,13 +146,17 @@ export class ServerSettingsService extends Context.Service< const makeTest = (overrides: DeepPartial = {}) => Effect.gen(function* () { - const { automaticGitFetchInterval, ...overridesForMerge } = overrides; + const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = + overrides; const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); const initialSettings = yield* normalizeServerSettings({ ...merged, ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } : {}), + ...(providerHealthRefreshInterval !== undefined + ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } + : {}), }); const currentSettingsRef = yield* Ref.make(initialSettings); @@ -161,6 +172,7 @@ const makeTest = (overrides: DeepPartial = {}) => Effect.map(resolveTextGenerationProvider), ), streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), } satisfies ServerSettingsService["Service"]; }); @@ -197,7 +209,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting // Values under these keys are compared as a whole — never stripped field-by-field. const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ + "backgroundActivity", "automaticGitFetchInterval", + "providerHealthRefreshInterval", "sourceControlWriterModelSelection", "textGenerationModelSelection", ]); @@ -344,6 +358,23 @@ const make = Effect.gen(function* () { }; }); + const materializeChanges = (changes: Stream.Stream) => + changes.pipe( + Stream.mapEffect((settings) => + materializeProviderEnvironmentSecrets(settings).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning("failed to materialize provider environment secrets", { + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }).pipe(Effect.as(settings)), + ), + ), + ), + Stream.map(resolveTextGenerationProvider), + ); + const persistProviderEnvironmentSecrets = ( current: ServerSettings, next: ServerSettings, @@ -561,20 +592,11 @@ const make = Effect.gen(function* () { }), ), get streamChanges() { - return Stream.fromPubSub(changesPubSub).pipe( - Stream.mapEffect((settings) => - materializeProviderEnvironmentSecrets(settings).pipe( - Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize provider environment secrets", { - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }).pipe(Effect.as(settings)), - ), - ), - ), - Stream.map(resolveTextGenerationProvider), + return materializeChanges(Stream.fromPubSub(changesPubSub)); + }, + get subscribeChanges() { + return PubSub.subscribe(changesPubSub).pipe( + Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))), ); }, } satisfies ServerSettingsService["Service"]; diff --git a/apps/server/src/utils/subscribeBeforeSnapshot.test.ts b/apps/server/src/utils/subscribeBeforeSnapshot.test.ts new file mode 100644 index 000000000000..70f0beda25ce --- /dev/null +++ b/apps/server/src/utils/subscribeBeforeSnapshot.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { + subscribeBeforeSnapshot, + subscribeBeforeSnapshotWithoutMutex, +} from "./subscribeBeforeSnapshot.ts"; + +describe("subscribeBeforeSnapshot", () => { + it.effect("atomically reads the initial snapshot before receiving later changes", () => + Effect.scoped( + Effect.gen(function* () { + const changes = yield* PubSub.sliding(1); + const latest = yield* Ref.make(1); + const mutex = yield* Semaphore.make(1); + const snapshotStarted = yield* Deferred.make(); + const finishSnapshot = yield* Deferred.make(); + const subscriptionFiber = yield* subscribeBeforeSnapshot( + changes, + Deferred.succeed(snapshotStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishSnapshot)), + Effect.andThen(Ref.get(latest)), + ), + mutex, + ).pipe(Effect.forkChild); + + yield* Deferred.await(snapshotStarted); + const publishFiber = yield* mutex + .withPermits(1)(Ref.set(latest, 2).pipe(Effect.andThen(PubSub.publish(changes, 2)))) + .pipe(Effect.forkChild); + yield* Deferred.succeed(finishSnapshot, undefined); + + const subscription = yield* Fiber.join(subscriptionFiber); + yield* Fiber.join(publishFiber); + const firstChange = yield* subscription.changes.pipe( + Stream.runHead, + Effect.timeout("1 second"), + ); + + expect(subscription.latest).toBe(1); + expect(firstChange).toEqual(Option.some(2)); + }), + ), + ); + + it.effect("subscribes before an uncoordinated snapshot can change", () => + Effect.scoped( + Effect.gen(function* () { + const changes = yield* PubSub.sliding(1); + const latest = yield* Ref.make(1); + const snapshotStarted = yield* Deferred.make(); + const finishSnapshot = yield* Deferred.make(); + const subscriptionFiber = yield* subscribeBeforeSnapshotWithoutMutex( + changes, + Deferred.succeed(snapshotStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishSnapshot)), + Effect.andThen(Ref.get(latest)), + ), + ).pipe(Effect.forkChild); + + yield* Deferred.await(snapshotStarted); + yield* Ref.set(latest, 2); + yield* PubSub.publish(changes, 2); + yield* Deferred.succeed(finishSnapshot, undefined); + + const subscription = yield* Fiber.join(subscriptionFiber); + const firstChange = yield* subscription.changes.pipe( + Stream.runHead, + Effect.timeout("1 second"), + ); + + expect(subscription.latest).toBe(2); + expect(firstChange).toEqual(Option.some(2)); + }), + ), + ); +}); diff --git a/apps/server/src/utils/subscribeBeforeSnapshot.ts b/apps/server/src/utils/subscribeBeforeSnapshot.ts new file mode 100644 index 000000000000..7ca1e1c4feb3 --- /dev/null +++ b/apps/server/src/utils/subscribeBeforeSnapshot.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +export interface SnapshotSubscription { + readonly latest: A; + readonly changes: Stream.Stream; +} + +export const subscribeBeforeSnapshot = Effect.fn("subscribeBeforeSnapshot")(function* ( + changes: PubSub.PubSub, + snapshot: Effect.Effect, + mutex: Semaphore.Semaphore, +) { + return yield* mutex.withPermits(1)( + Effect.gen(function* () { + const latest = yield* snapshot; + const subscription = yield* PubSub.subscribe(changes); + return { + latest, + changes: Stream.fromSubscription(subscription), + } satisfies SnapshotSubscription; + }), + ); +}); + +export const subscribeBeforeSnapshotWithoutMutex = Effect.fn("subscribeBeforeSnapshotWithoutMutex")( + function* (changes: PubSub.PubSub, snapshot: Effect.Effect) { + const subscription = yield* PubSub.subscribe(changes); + const latest = yield* snapshot; + return { + latest, + changes: Stream.fromSubscription(subscription), + } satisfies SnapshotSubscription; + }, +); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index ee595b1f8362..6820a29e2c86 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -2,6 +2,7 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -14,6 +15,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type { + BackgroundScope, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, @@ -22,8 +24,11 @@ import type { import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, sourceControlProvider: { @@ -73,6 +78,7 @@ function makeTestLayer(state: { }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -104,6 +110,37 @@ function makeTestLayer(state: { ); } +function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) => boolean) { + return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: false, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: (scope) => Effect.sync(() => shouldRunScopeWork(scope)), + shouldRunOpportunisticWork: Effect.succeed(true), + }); +} + describe("VcsStatusBroadcaster", () => { it.effect("reuses the cached VCS status across repeated reads", () => { const state = { @@ -183,6 +220,7 @@ describe("VcsStatusBroadcaster", () => { }; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -290,6 +328,7 @@ describe("VcsStatusBroadcaster", () => { }; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: (input) => @@ -453,6 +492,7 @@ describe("VcsStatusBroadcaster", () => { let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -641,6 +681,57 @@ describe("VcsStatusBroadcaster", () => { }); }); + it.effect("does not start automatic remote refreshes without foreground client demand", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => false)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const snapshot = yield* Stream.runHead( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, + ), + ); + + assert.isTrue(Option.isSome(snapshot)); + assert.equal(state.remoteStatusCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -654,6 +745,7 @@ describe("VcsStatusBroadcaster", () => { let remoteStartedDeferred: Deferred.Deferred | null = null; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d1a67053273e..f28069f6d8b2 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,6 +22,7 @@ import type { } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); @@ -131,6 +132,7 @@ interface CachedVcsStatus { interface ActiveRemotePoller { readonly fiber: Fiber.Fiber; readonly subscriberCount: number; + readonly demandCwds: Ref.Ref>; } interface StreamStatusOptions { @@ -180,6 +182,7 @@ const normalizeCwd = (cwd: string) => export const make = Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const fs = yield* FileSystem.FileSystem; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), @@ -378,6 +381,7 @@ export const make = Effect.gen(function* () { const makeRemoteRefreshLoop = ( cwd: string, + demandCwdsRef: Ref.Ref>, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) => { @@ -394,6 +398,22 @@ export const make = Effect.gen(function* () { return activeInterval; } + const demandCwds = yield* Ref.get(demandCwdsRef); + const shouldRun = + needsInitialRefresh || + (yield* Effect.all( + [...demandCwds.keys()].map((demandCwd) => + backgroundPolicy.shouldRunScopeWork({ + type: "vcs-status", + cwd: demandCwd, + }), + ), + { concurrency: "unbounded" }, + )).some(Boolean); + if (!shouldRun) { + return activeInterval; + } + const exit = yield* refreshRemoteStatus(cwd, { refreshUpstream: !Duration.isZero(configuredInterval), }).pipe(Effect.exit); @@ -444,55 +464,88 @@ export const make = Effect.gen(function* () { const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( cwd: string, + demandCwd: string, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) { yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); if (existing) { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount + 1, - }); - return Effect.succeed([undefined, nextPollers] as const); + return Ref.update(existing.demandCwds, (demandCwds) => { + const next = new Map(demandCwds); + next.set(demandCwd, (next.get(demandCwd) ?? 0) + 1); + return next; + }).pipe( + Effect.map(() => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return [undefined, nextPollers] as const; + }), + ); } - return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( - Effect.forkIn(broadcasterScope), - Effect.map((fiber) => { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - fiber, - subscriberCount: 1, - }); - return [undefined, nextPollers] as const; - }), + return Ref.make>(new Map([[demandCwd, 1]])).pipe( + Effect.flatMap((demandCwds) => + makeRemoteRefreshLoop( + cwd, + demandCwds, + automaticRemoteRefreshInterval, + refreshImmediately, + ).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + demandCwds, + }); + return [undefined, nextPollers] as const; + }), + ), + ), ); }); }); const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( cwd: string, + demandCwd: string, ) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); if (!existing) { - return [null, activePollers] as const; + return Effect.succeed([null, activePollers] as const); } if (existing.subscriberCount > 1) { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount - 1, - }); - return [null, nextPollers] as const; + return Ref.update(existing.demandCwds, (demandCwds) => { + const nextDemandCwds = new Map(demandCwds); + const count = nextDemandCwds.get(demandCwd) ?? 0; + if (count <= 1) { + nextDemandCwds.delete(demandCwd); + } else { + nextDemandCwds.set(demandCwd, count - 1); + } + return nextDemandCwds; + }).pipe( + Effect.as([ + null, + new Map(activePollers).set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }), + ] as const), + ); } - const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; + return Effect.succeed([ + existing.fiber, + new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)), + ] as const); }); if (pollerToInterrupt) { @@ -510,12 +563,13 @@ export const make = Effect.gen(function* () { const initialRemote = cachedStatus?.remote?.value ?? null; yield* retainRemotePoller( cwd, + input.cwd, options?.automaticRemoteRefreshInterval ?? Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ac55cc7cff7b..e018d4dc4b5f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -11,12 +11,6 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, - AuthOrchestrationOperateScope, - AuthOrchestrationReadScope, - AuthReviewWriteScope, - AuthRelayWriteScope, - AuthTerminalOperateScope, - AuthAccessReadScope, AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, @@ -50,6 +44,7 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + RpcClientId, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -59,6 +54,7 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -98,9 +94,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; +import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -295,78 +294,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). const SHELL_RESUME_MAX_GAP = 1_000; -const RPC_REQUIRED_SCOPE = new Map([ - [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], - [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], - [WS_METHODS.serverProbe, AuthOrchestrationReadScope], - [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], - [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], - [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], - [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], - [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], - [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], - [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], - [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope], - [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope], - [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], - [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], - [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], - [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], - [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], - [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], - [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], - [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], - [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], - [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], - [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], - [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], - [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], - [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], - [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], - [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], - [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], - [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], - [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], - [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], - [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], - [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], - [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], - [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], - [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], - [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], - [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], - [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], - [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], - [WS_METHODS.terminalOpen, AuthTerminalOperateScope], - [WS_METHODS.terminalAttach, AuthTerminalOperateScope], - [WS_METHODS.terminalWrite, AuthTerminalOperateScope], - [WS_METHODS.terminalResize, AuthTerminalOperateScope], - [WS_METHODS.terminalClear, AuthTerminalOperateScope], - [WS_METHODS.terminalRestart, AuthTerminalOperateScope], - [WS_METHODS.terminalClose, AuthTerminalOperateScope], - [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], - [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], - [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], - [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], - [WS_METHODS.previewResize, AuthOrchestrationOperateScope], - [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], - [WS_METHODS.previewClose, AuthOrchestrationOperateScope], - [WS_METHODS.previewList, AuthOrchestrationReadScope], - [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope], - [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], - [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], - [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], - [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], - [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], -]); - function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, revision: number, @@ -438,10 +365,28 @@ const makeWsRpcLayer = ( const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const rpcClientIds = yield* Ref.make(new Set()); + yield* Effect.addFinalizer(() => + Ref.get(rpcClientIds).pipe( + Effect.flatMap((clientIds) => + Effect.forEach( + clientIds, + (clientId) => backgroundPolicy.removeRpcClient(currentSessionId, clientId), + { + discard: true, + }, + ), + ), + Effect.ignore, + ), + ); const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; const automaticGitFetchInterval = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.automaticGitFetchInterval), + Effect.map( + (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, + ), Effect.catch((cause) => Effect.logWarning("Failed to read automatic Git fetch interval setting", { detail: cause.message, @@ -454,6 +399,7 @@ const makeWsRpcLayer = ( const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; + const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -474,13 +420,6 @@ const makeWsRpcLayer = ( currentSession.scopes.includes(requiredScope) ? stream : Stream.fail(authorizationError(requiredScope)); - const requiredScopeForMethod = (method: string): AuthEnvironmentScope => { - const requiredScope = RPC_REQUIRED_SCOPE.get(method); - if (requiredScope === undefined) { - throw new Error(`RPC method ${method} has no declared authorization scope.`); - } - return requiredScope; - }; const observeRpcEffect = ( method: string, effect: Effect.Effect, @@ -488,7 +427,7 @@ const makeWsRpcLayer = ( ) => instrumentRpcEffect( method, - authorizeEffect(requiredScopeForMethod(method), effect), + authorizeEffect(requiredScopeForRpcMethod(method), effect), traceAttributes, ); const observeRpcStream = ( @@ -498,7 +437,7 @@ const makeWsRpcLayer = ( ) => instrumentRpcStream( method, - authorizeStream(requiredScopeForMethod(method), stream), + authorizeStream(requiredScopeForRpcMethod(method), stream), traceAttributes, ); const observeRpcStreamEffect = ( @@ -512,7 +451,7 @@ const makeWsRpcLayer = ( ) => instrumentRpcStreamEffect( method, - authorizeEffect(requiredScopeForMethod(method), effect), + authorizeEffect(requiredScopeForRpcMethod(method), effect), traceAttributes, ); const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => @@ -1508,10 +1447,50 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetResourceTelemetryHistory]: (input) => + observeRpcEffect( + WS_METHODS.serverGetResourceTelemetryHistory, + resourceTelemetry.readHistory(input), + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverRetryResourceTelemetry]: (_input) => + observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverSignalProcess]: (input) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverReportClientActivity]: (input, metadata) => + Ref.update(rpcClientIds, (clientIds) => { + const next = new Set(clientIds); + next.add(RpcClientId.make(metadata.client.id)); + return next; + }).pipe( + Effect.andThen( + observeRpcEffect( + WS_METHODS.serverReportClientActivity, + backgroundPolicy.reportClientActivity( + currentSessionId, + RpcClientId.make(metadata.client.id), + input, + ), + { "rpc.aggregate": "server" }, + ), + ), + ), + [WS_METHODS.serverReportHostPowerState]: (input) => + observeRpcEffect( + WS_METHODS.serverReportHostPowerState, + backgroundPolicy.reportHostPowerState(input), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverGetBackgroundPolicy]: (_input) => + observeRpcEffect(WS_METHODS.serverGetBackgroundPolicy, backgroundPolicy.snapshot, { + "rpc.aggregate": "server", + }), [WS_METHODS.cloudGetRelayClientStatus]: (_input) => observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { "rpc.aggregate": "cloud", @@ -2035,6 +2014,26 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "auth" }, ), + [WS_METHODS.subscribeBackgroundPolicy]: (_input) => + observeRpcStream( + WS_METHODS.subscribeBackgroundPolicy, + Stream.unwrap( + Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), + ), + ), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.subscribeResourceTelemetry]: (_input) => + observeRpcStream( + WS_METHODS.subscribeResourceTelemetry, + Stream.unwrap( + Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), + ), + ), + { "rpc.aggregate": "server" }, + ), }); }), ); diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index b1a54feb7188..adce508f7765 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -37,6 +37,7 @@ import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; +import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -904,12 +905,16 @@ export function DiagnosticsSettingsPanel() { if (environmentId === null) { return; } + const process = processData?.processes.find((entry) => entry.pid === pid); + if (process === undefined) { + return; + } setSignalingPid(pid); void (async () => { const result = await signalServerProcess({ environmentId, - input: { pid, signal }, + input: { pid, startTimeMs: process.startTimeMs, signal }, }); setSignalingPid(null); if (result._tag === "Failure") { @@ -946,7 +951,7 @@ export function DiagnosticsSettingsPanel() { refreshProcesses(); })(); }, - [environmentId, refreshProcesses, signalServerProcess], + [environmentId, processData?.processes, refreshProcesses, signalServerProcess], ); const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; @@ -957,7 +962,9 @@ export function DiagnosticsSettingsPanel() { : false; return ( - + + + ({ + identity: { pid, startTimeMs: pid * 1_000 }, + ppid, + childPids: [], + depth, + name: `process-${pid}`, + command: `process-${pid}`, + status: "Running", + category: pid === 1 ? "server" : "server-child", + cpuPercent: 0, + cpuTimeMs: 0, + residentBytes: 0, + peakResidentBytes: 0, + virtualBytes: 0, + ioReadBytes: 0, + ioWriteBytes: 0, + ioReadBytesPerSecond: 0, + ioWriteBytesPerSecond: 0, + ioSemantics: "storage", + runTimeMs: 0, + firstSeenAt: DateTime.makeUnsafe(0), + lastSeenAt: DateTime.makeUnsafe(0), +}); + +describe("shouldShowResourceMonitorRetry", () => { + it("allows retry when the initial telemetry request fails before a snapshot", () => { + expect( + shouldShowResourceMonitorRetry({ + nativeStatus: null, + error: "Resource monitor is unavailable.", + }), + ).toBe(true); + }); + + it("does not show retry for an initial load without an error or a healthy snapshot", () => { + expect(shouldShowResourceMonitorRetry({ nativeStatus: null, error: null })).toBe(false); + expect(shouldShowResourceMonitorRetry({ nativeStatus: "healthy", error: null })).toBe(false); + }); +}); + +describe("resourceHistoryBarHeight", () => { + it("renders an exactly zero sample with no visible bar", () => { + expect(resourceHistoryBarHeight({ value: 0, max: 100, minimumVisiblePercent: 2 })).toBe(0); + }); + + it("keeps nonzero samples visible without changing proportional heights", () => { + expect(resourceHistoryBarHeight({ value: 0.5, max: 100, minimumVisiblePercent: 2 })).toBe(2); + expect(resourceHistoryBarHeight({ value: 50, max: 100, minimumVisiblePercent: 2 })).toBe(50); + }); +}); + +describe("resourceHistoryCpuScaleMax", () => { + it("scales average bars independently of brief peak spikes", () => { + const buckets = [ + { avgCpuPercent: 1, maxCpuPercent: 100 }, + { avgCpuPercent: 8, maxCpuPercent: 8 }, + ]; + expect(resourceHistoryCpuScaleMax(buckets)).toBe(8); + }); +}); + +describe("visibleResourceTelemetryProcesses", () => { + it("hides descendants by parentage even when rows are not depth-first", () => { + const processes = [ + process(1, 0, 0), + process(10, 0, 0), + process(2, 1, 1), + process(11, 10, 1), + process(3, 2, 2), + ]; + + expect(visibleResourceTelemetryProcesses(processes, new Set(["1:1000"]))).toEqual([ + processes[0], + processes[1], + processes[3], + ]); + }); +}); diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts new file mode 100644 index 000000000000..6b7ab749c101 --- /dev/null +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts @@ -0,0 +1,60 @@ +import type { ResourceTelemetryProcess, ResourceTelemetrySourceStatus } from "@t3tools/contracts"; + +function processIdentityKey(process: ResourceTelemetryProcess): string { + return `${process.identity.pid}:${process.identity.startTimeMs}`; +} + +export function visibleResourceTelemetryProcesses( + processes: ReadonlyArray, + collapsed: ReadonlySet, +): ReadonlyArray { + const childrenByParent = new Map(); + for (const process of processes) { + const children = childrenByParent.get(process.ppid) ?? []; + children.push(process); + childrenByParent.set(process.ppid, children); + } + + const hidden = new Set(); + const hideDescendants = (pid: number): void => { + for (const child of childrenByParent.get(pid) ?? []) { + const key = processIdentityKey(child); + if (hidden.has(key)) continue; + hidden.add(key); + hideDescendants(child.identity.pid); + } + }; + for (const process of processes) { + if (collapsed.has(processIdentityKey(process))) { + hideDescendants(process.identity.pid); + } + } + return processes.filter((process) => !hidden.has(processIdentityKey(process))); +} + +export function shouldShowResourceMonitorRetry(input: { + readonly nativeStatus: ResourceTelemetrySourceStatus | null; + readonly error: string | null; +}): boolean { + return ( + (input.nativeStatus === null && input.error !== null) || + input.nativeStatus === "degraded" || + input.nativeStatus === "unavailable" || + input.nativeStatus === "stopped" + ); +} + +export function resourceHistoryBarHeight(input: { + readonly value: number; + readonly max: number; + readonly minimumVisiblePercent: number; +}): number { + if (input.value <= 0) return 0; + return Math.max(input.minimumVisiblePercent, (input.value / Math.max(1, input.max)) * 100); +} + +export function resourceHistoryCpuScaleMax( + buckets: ReadonlyArray<{ readonly avgCpuPercent: number }>, +): number { + return Math.max(1, ...buckets.map((bucket) => bucket.avgCpuPercent)); +} diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx new file mode 100644 index 000000000000..d831c763766f --- /dev/null +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -0,0 +1,1268 @@ +import { + ActivityIcon, + AlertTriangleIcon, + BatteryIcon, + ChevronDownIcon, + ChevronRightIcon, + CpuIcon, + DatabaseIcon, + GaugeIcon, + HardDriveIcon, + MemoryStickIcon, + RefreshCwIcon, + RotateCcwIcon, +} from "lucide-react"; +import type { + BackgroundBooleanState, + ResourceAttributionEntry, + ResourceTelemetryAggregate, + ResourceTelemetryHistoryBucket, + ResourceTelemetryIoSemantics, + ResourceTelemetryProcess, + ResourceTelemetryProcessCategory, + ResourceTelemetryProcessSummary, + ResourceTelemetrySourceHealth, + ResourceTelemetrySourceStatus, + ServerProcessSignal, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { + useResourceTelemetry, + useResourceTelemetryHistory, +} from "../../lib/resourceTelemetryState"; +import { cn } from "../../lib/utils"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { formatRelativeTime } from "../../timestampFormat"; +import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; +import { + resourceHistoryBarHeight, + resourceHistoryCpuScaleMax, + shouldShowResourceMonitorRetry, + visibleResourceTelemetryProcesses, +} from "./ResourceTelemetryDiagnostics.logic"; +import { SettingsSection, useRelativeTimeTick } from "./settingsLayout"; + +const HISTORY_WINDOWS = [ + { label: "5m", windowMs: 5 * 60_000, bucketMs: 15_000 }, + { label: "15m", windowMs: 15 * 60_000, bucketMs: 30_000 }, + { label: "30m", windowMs: 30 * 60_000, bucketMs: 60_000 }, + { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 }, +] as const; + +function formatBytes(value: number): string { + if (value < 1_024) return `${Math.round(value)} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let next = value; + let unitIndex = -1; + do { + next /= 1_024; + unitIndex += 1; + } while (next >= 1_024 && unitIndex < units.length - 1); + return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} + +function formatRate(value: number): string { + return `${formatBytes(value)}/s`; +} + +function formatCpuTime(valueMs: number): string { + const seconds = valueMs / 1_000; + if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`; + const minutes = seconds / 60; + if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; + return `${(minutes / 60).toFixed(2)}h`; +} + +function formatDurationMicros(value: number): string { + if (value < 1_000) return `${Math.round(value)} µs`; + if (value < 1_000_000) return `${(value / 1_000).toFixed(2)} ms`; + return `${(value / 1_000_000).toFixed(2)} s`; +} + +function formatSampleInterval(valueMs: number): string { + if (valueMs < 1_000) return `${Math.max(0, Math.round(valueMs))} ms`; + const seconds = valueMs / 1_000; + return `${seconds.toLocaleString(undefined, { maximumFractionDigits: 1 })} ${ + seconds === 1 ? "second" : "seconds" + }`; +} + +function processIdentityKey(process: ResourceTelemetryProcess): string { + return `${process.identity.pid}:${process.identity.startTimeMs}`; +} + +function processSummaryIdentityKey(process: ResourceTelemetryProcessSummary): string { + return `${process.identity.pid}:${process.identity.startTimeMs}`; +} + +function formatProcessName(process: Pick): string { + if (process.name.trim()) return process.name; + const firstToken = process.command.trim().split(/\s+/)[0] ?? process.command; + const normalized = firstToken.replace(/^['"]|['"]$/g, ""); + return normalized.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? normalized; +} + +function categoryLabel(category: ResourceTelemetryProcessCategory): string { + switch (category) { + case "server": + return "Server"; + case "server-child": + return "Backend child"; + case "provider-root": + return "Provider"; + case "terminal-root": + return "Terminal"; + case "electron-main": + return "Electron main"; + case "electron-renderer": + return "Renderer"; + case "electron-gpu": + return "GPU"; + case "electron-utility": + return "Electron utility"; + case "resource-monitor": + return "Monitor"; + case "unknown-t3": + return "T3 process"; + } +} + +function categoryDotClass(category: ResourceTelemetryProcessCategory): string { + if (category === "resource-monitor") return "bg-amber-500"; + if (category.startsWith("electron-")) return "bg-sky-500"; + if (category === "server") return "bg-violet-500"; + return "bg-emerald-500"; +} + +function ioSemanticsLabel(semantics: ResourceTelemetryIoSemantics): string { + switch (semantics) { + case "storage": + return "Storage bytes"; + case "logical": + return "Logical bytes"; + case "all-io": + return "All I/O bytes"; + case "unavailable": + return "Unavailable"; + } +} + +function booleanStateLabel( + value: BackgroundBooleanState, + labels: { readonly true: string; readonly false: string }, +): string { + if (value === "true") return labels.true; + if (value === "false") return labels.false; + return "Unknown"; +} + +function sourceStatusTone(status: ResourceTelemetrySourceStatus): "default" | "warning" | "danger" { + if (status === "healthy") return "default"; + if (status === "starting" || status === "degraded") return "warning"; + return "danger"; +} + +function SourceStatusBadge({ + label, + status, + presentation, +}: { + label: string; + status: ResourceTelemetrySourceStatus; + presentation?: + | { + readonly label: string; + readonly tone: "neutral"; + } + | undefined; +}) { + const tone = presentation?.tone ?? sourceStatusTone(status); + return ( + + + {label} {presentation?.label ?? status} + + ); +} + +function LastSampleLabel({ sampledAt }: { sampledAt: DateTime.Utc | null }) { + useRelativeTimeTick(); + if (!sampledAt) { + return Waiting for sample; + } + const relative = formatRelativeTime(DateTime.formatIso(sampledAt)); + if (!relative) { + return Waiting for sample; + } + return ( + + Updated {relative.value} + {relative.suffix ? ` ${relative.suffix}` : ""} + + ); +} + +function IconStat({ + icon, + label, + value, + detail, + tone = "default", +}: { + icon: ReactNode; + label: string; + value: string; + detail?: string | undefined; + tone?: "default" | "warning" | "danger"; +}) { + return ( +
+
+ + {icon} + + {label} +
+
+ {value} +
+ {detail ? ( +
{detail}
+ ) : null} +
+ ); +} + +function AggregateCard({ + label, + accentClass, + aggregate, +}: { + label: string; + accentClass: string; + aggregate: ResourceTelemetryAggregate; +}) { + return ( +
+ +
+
+ {label} +
+
+ {aggregate.processCount} {aggregate.processCount === 1 ? "process" : "processes"} +
+
+
+ + + + +
+
+ ); +} + +function MetricPair({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function HealthSource({ label, health }: { label: string; health: ResourceTelemetrySourceHealth }) { + const expectedInBrowser = + health.status === "unavailable" && + Option.exists(health.lastError, (error) => error.includes("'web' mode")); + return ( +
+
+
{label}
+
+ {expectedInBrowser + ? "Available when this page runs inside the desktop app." + : Option.match(health.lastError, { + onNone: () => "No reported errors", + onSome: (error) => error, + })} +
+
+ +
+ ); +} + +function DetailRow({ + label, + value, + valueClassName, +}: { + label: string; + value: ReactNode; + valueClassName?: string | undefined; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function HistoryWindowSelector({ + selectedWindowMs, + onSelect, +}: { + selectedWindowMs: number; + onSelect: (windowMs: number) => void; +}) { + return ( +
+ {HISTORY_WINDOWS.map((option) => ( + + ))} +
+ ); +} + +function ResourceHistoryChart({ + buckets, +}: { + buckets: ReadonlyArray; +}) { + const maxCpu = resourceHistoryCpuScaleMax(buckets); + const maxIo = Math.max(1, ...buckets.map((bucket) => bucket.ioReadBytes + bucket.ioWriteBytes)); + + return ( +
+
+ + CPU average + + + I/O reads + + + I/O writes + +
+
+ {buckets.map((bucket) => { + const cpuHeight = resourceHistoryBarHeight({ + value: bucket.avgCpuPercent, + max: maxCpu, + minimumVisiblePercent: 2, + }); + const readHeight = resourceHistoryBarHeight({ + value: bucket.ioReadBytes, + max: maxIo, + minimumVisiblePercent: 1, + }); + const writeHeight = resourceHistoryBarHeight({ + value: bucket.ioWriteBytes, + max: maxIo, + minimumVisiblePercent: 1, + }); + return ( + + + + + +
+ } + /> + +
CPU avg {bucket.avgCpuPercent.toFixed(1)}%
+
CPU peak {bucket.maxCpuPercent.toFixed(1)}%
+
Read {formatBytes(bucket.ioReadBytes)}
+
Write {formatBytes(bucket.ioWriteBytes)}
+
+ + ); + })} +
+ + ); +} + +function ProcessTreeName({ + process, + collapsed, + onToggle, +}: { + process: ResourceTelemetryProcess; + collapsed: boolean; + onToggle: (process: ResourceTelemetryProcess) => void; +}) { + const name = formatProcessName(process); + const hasChildren = process.childPids.length > 0; + const ChevronIcon = collapsed ? ChevronRightIcon : ChevronDownIcon; + return ( +
+ {hasChildren ? ( + + ) : ( + + )} + + + {name}} + /> + + {process.command || process.name} + + +
+ ); +} + +function canSignalProcess(process: ResourceTelemetryProcess): boolean { + return ( + process.category === "server-child" || + process.category === "provider-root" || + process.category === "terminal-root" + ); +} + +function ProcessActions({ + process, + signalingKeys, + onSignal, +}: { + process: ResourceTelemetryProcess; + signalingKeys: ReadonlySet; + onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; +}) { + if (!canSignalProcess(process)) { + return ; + } + const isSignaling = signalingKeys.has(processIdentityKey(process)); + return ( +
+ + +
+ ); +} + +function ProcessTable({ + processes, + signalingKeys, + onSignal, +}: { + processes: ReadonlyArray; + signalingKeys: ReadonlySet; + onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; +}) { + const [collapsed, setCollapsed] = useState>(() => new Set()); + const visible = useMemo( + () => visibleResourceTelemetryProcesses(processes, collapsed), + [collapsed, processes], + ); + const toggle = useCallback((process: ResourceTelemetryProcess) => { + const identityKey = processIdentityKey(process); + setCollapsed((current) => { + const next = new Set(current); + if (next.has(identityKey)) { + next.delete(identityKey); + } else { + next.add(identityKey); + } + return next; + }); + }, []); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {visible.length === 0 ? ( + + + + ) : null} + {visible.map((process) => ( + + + + + + + + + + + + + + ))} + +
ProcessCategoryCPUCPU TimeMemoryRead/sWrite/sRead TotalWrite TotalPIDKill
+ Waiting for the native process monitor. +
+ + + {categoryLabel(process.category)} + + {process.cpuPercent.toFixed(1)}% + + {formatCpuTime(process.cpuTimeMs)} + + {formatBytes(process.residentBytes)} + + {formatRate(process.ioReadBytesPerSecond)} + + {formatRate(process.ioWriteBytesPerSecond)} + + {formatBytes(process.ioReadBytes)} + + + {formatBytes(process.ioWriteBytes)}} /> + {ioSemanticsLabel(process.ioSemantics)} + + + {process.identity.pid} + + +
+
+ ); +} + +function HistoryProcessTable({ + processes, +}: { + processes: ReadonlyArray; +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {processes.length === 0 ? ( + + + + ) : null} + {processes.map((process) => ( + + + + + + + + + + + + ))} + +
ProcessCategoryCPU TimePeak CPUPeak MemReadWriteSamplesPID
+ No retained process samples in this window. +
+ + + {process.name || process.command} + + } + /> + + {process.command || process.name} + + + + {categoryLabel(process.category)} + + {formatCpuTime(process.cpuTimeMs)} + + {process.maxCpuPercent.toFixed(1)}% + + {formatBytes(process.peakRssBytes)} + + {formatBytes(process.ioReadBytes)} + + {formatBytes(process.ioWriteBytes)} + + {process.sampleCount} + + {process.identity.pid} +
+
+ ); +} + +function AttributionTable({ entries }: { entries: ReadonlyArray }) { + return ( +
+ + + + + + + + + + + + + + + + + + + + + {entries.length === 0 ? ( + + + + ) : null} + {entries.map((entry) => ( + + + + + + + + + ))} + +
ComponentOperationLogical ReadLogical WriteCountTime
+ No instrumented application I/O has been recorded yet. +
+ {entry.component} + {entry.operation} + {formatBytes(entry.logicalReadBytes)} + + {formatBytes(entry.logicalWriteBytes)} + {entry.count} + {(entry.durationMs / 1_000).toFixed(2)}s +
+
+ ); +} + +export function ResourceTelemetryDiagnostics() { + const [windowMs, setWindowMs] = useState(15 * 60_000); + const selectedWindow = + HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; + const telemetry = useResourceTelemetry(); + const retryTelemetry = telemetry.retry; + const history = useResourceTelemetryHistory({ + windowMs: selectedWindow.windowMs, + bucketMs: selectedWindow.bucketMs, + }); + const primaryEnvironment = usePrimaryEnvironment(); + const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { + reportFailure: false, + }); + const [signalingKeys, setSignalingKeys] = useState>(() => new Set()); + const [isRetrying, setIsRetrying] = useState(false); + const snapshot = telemetry.data; + const allT3 = snapshot?.groups.allT3; + + const signalProcess = useCallback( + (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { + if ( + signal === "SIGKILL" && + !window.confirm( + `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`, + ) + ) { + return; + } + const identityKey = processIdentityKey(process); + const environmentId = primaryEnvironment?.environmentId; + if (environmentId === undefined) { + return; + } + setSignalingKeys((current) => new Set(current).add(identityKey)); + void signalServerProcess({ + environmentId, + input: { + pid: process.identity.pid, + startTimeMs: process.identity.startTimeMs, + signal, + }, + }) + .then((result) => { + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + if (result.value.signaled) return; + toastManager.add({ + type: "error", + title: `Could not send ${signal}`, + description: Option.getOrElse( + result.value.message, + () => `Failed to send ${signal} to process ${process.identity.pid}.`, + ), + }); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: `Could not send ${signal}`, + description: error instanceof Error ? error.message : `Failed to send ${signal}.`, + }); + }) + .finally(() => { + setSignalingKeys((current) => { + if (!current.has(identityKey)) return current; + const next = new Set(current); + next.delete(identityKey); + return next; + }); + }); + }, + [primaryEnvironment?.environmentId, signalServerProcess], + ); + + const retryCollector = useCallback(() => { + setIsRetrying(true); + void retryTelemetry() + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not restart resource monitor", + description: + error instanceof Error ? error.message : "The resource monitor retry failed.", + }); + }) + .finally(() => { + setIsRetrying(false); + }); + }, [retryTelemetry]); + + const speedLimit = snapshot ? Option.getOrNull(snapshot.speedLimitPercent) : null; + const collectorNeedsRetry = shouldShowResourceMonitorRetry({ + nativeStatus: snapshot?.health.native.status ?? null, + error: telemetry.error, + }); + const hasHostPowerSignal = + snapshot !== null && + (snapshot.power.onBattery !== "unknown" || + snapshot.power.lowPowerMode !== "unknown" || + snapshot.power.idle !== "unknown" || + snapshot.power.locked !== "unknown" || + snapshot.power.thermalState !== "unknown"); + + return ( + <> + } + headerAction={ +
+ {snapshot ? ( + + ) : null} + + + + + + } + /> + Refresh telemetry snapshot + +
+ } + > +
+
+
+
+ T3 system footprint +
+

+ Live native counters for the server, providers, terminals, desktop processes, and + the monitor itself. +

+
+
+ + Sampling every {snapshot ? formatSampleInterval(snapshot.sampleIntervalMs) : "..."} +
+
+
+ } + label="Current CPU" + value={allT3 ? `${allT3.currentCpuPercent.toFixed(1)}%` : "..."} + detail={allT3 ? `${formatCpuTime(allT3.cpuTimeMs)} observed CPU time` : undefined} + /> + } + label="Resident memory" + value={allT3 ? formatBytes(allT3.currentRssBytes) : "..."} + detail={ + allT3 ? `${formatBytes(allT3.peakRssBytes)} combined process peaks` : undefined + } + /> + } + label="Process count" + value={allT3 ? String(allT3.processCount) : "..."} + detail={ + allT3 ? `${allT3.processStarts} starts · ${allT3.processExits} exits` : undefined + } + /> + } + label="Read throughput" + value={allT3 ? formatRate(allT3.ioReadBytesPerSecond) : "..."} + detail={allT3 ? `${formatBytes(allT3.ioReadBytes)} observed` : undefined} + /> + } + label="Write throughput" + value={allT3 ? formatRate(allT3.ioWriteBytesPerSecond) : "..."} + detail={allT3 ? `${formatBytes(allT3.ioWriteBytes)} observed` : undefined} + tone={ + allT3 && allT3.ioWriteBytesPerSecond >= 10 * 1_024 * 1_024 + ? "danger" + : allT3 && allT3.ioWriteBytesPerSecond >= 1_024 * 1_024 + ? "warning" + : "default" + } + /> + } + label="CPU speed limit" + value={ + snapshot ? (speedLimit === null ? "Unknown" : `${speedLimit.toFixed(0)}%`) : "..." + } + detail={snapshot ? `${snapshot.power.thermalState} thermal state` : undefined} + tone={speedLimit !== null && speedLimit < 80 ? "warning" : "default"} + /> +
+ {telemetry.error ? ( +
+ + {telemetry.error} +
+ ) : null} + {snapshot ? ( +
+ + + +
+ ) : null} +
+
+ + } + headerAction={ + collectorNeedsRetry ? ( + + ) : null + } + > +
+
+
+ + + + Host state +
+ {hasHostPowerSignal && snapshot ? ( + <> + + + + + + + ) : ( +
+
+ Desktop host signals not connected +
+

+ Power, idle, lock, and thermal state are supplied by the desktop host. Process + telemetry remains fully active in this browser session. +

+
+ )} +
+
+
+ + + + Collection health +
+ {snapshot ? ( + <> + + + + + 0 + ? "text-amber-600 dark:text-amber-300" + : undefined + } + /> + "Unavailable", + onSome: (version) => + `${version}${Option.match(snapshot.health.sidecarPid, { + onNone: () => "", + onSome: (pid) => ` · PID ${pid}`, + })}`, + })} + /> + + + ) : ( +
+ Waiting for collector health. +
+ )} +
+
+
+ + } + headerAction={ +
+ + +
+ } + > +
+ {history.error ? ( +
+ + {history.error} +
+ ) : null} + + +
+
+ + } + headerAction={ + snapshot ? ( + + Identity: PID + start time + + ) : null + } + > +
+ +
+
+ + } + headerAction={ + Logical bytes by operation + } + > +
+
+ Native counters identify which process is reading or writing. These application-level + counters identify known T3 operations so process spikes can be correlated with specific + persistence and logging paths. +
+ +
+
+ + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 077991f8de07..d0bdb58db2e3 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,17 +1,126 @@ import { DEFAULT_SERVER_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, ProviderDriverKind, ProviderInstanceId, type ProviderInstanceConfig, } from "@t3tools/contracts"; +import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings"; +import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { + backgroundActivitySharedPolicySettings, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, + resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; +describe("background activity settings restore", () => { + it("detects legacy interval values even when the structured setting is at its default", () => { + expect( + hasChangedBackgroundActivitySettings({ + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, + automaticGitFetchInterval: Duration.seconds(45), + providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, + }), + ).toBe(true); + expect( + hasChangedBackgroundActivitySettings({ + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, + automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + providerHealthRefreshInterval: Duration.minutes(7), + }), + ).toBe(true); + expect(hasChangedBackgroundActivitySettings(DEFAULT_UNIFIED_SETTINGS)).toBe(false); + }); + + it("detects a legacy profile override so restoring defaults clears it", () => { + expect( + hasChangedBackgroundActivitySettings({ + ...DEFAULT_UNIFIED_SETTINGS, + backgroundActivityProfile: "performance", + }), + ).toBe(true); + }); + + it("shows the effective legacy preset and marks custom legacy intervals as advanced", () => { + const performance = getBackgroundActivityPresetSettings("performance"); + expect( + resolveBackgroundActivityProfileOption({ + ...DEFAULT_UNIFIED_SETTINGS, + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + backgroundActivityProfile: "performance", + automaticGitFetchInterval: performance.automaticGitFetchInterval, + providerHealthRefreshInterval: performance.providerHealthRefreshInterval, + }), + ).toBe("performance"); + + expect( + resolveBackgroundActivityProfileOption({ + ...DEFAULT_UNIFIED_SETTINGS, + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + backgroundActivityProfile: "performance", + automaticGitFetchInterval: Duration.seconds(45), + providerHealthRefreshInterval: Duration.minutes(7), + }), + ).toBe("advanced"); + }); + + it("preserves advanced overrides when the shared policy changes", () => { + const automaticGitFetchInterval = Duration.seconds(42); + expect( + backgroundActivitySharedPolicySettings( + { + ...DEFAULT_UNIFIED_SETTINGS, + backgroundActivity: { + schemaVersion: 1, + profile: "custom", + baseProfile: "balanced", + overrides: { + automaticGitFetchInterval, + pauseWhenOnBattery: true, + }, + }, + }, + "performance", + ), + ).toEqual({ + schemaVersion: 1, + profile: "custom", + baseProfile: "performance", + overrides: { + automaticGitFetchInterval, + pauseWhenOnBattery: true, + }, + }); + }); + + it("materializes legacy advanced overrides before changing the shared policy", () => { + const automaticGitFetchInterval = Duration.seconds(42); + expect( + backgroundActivitySharedPolicySettings( + { + ...DEFAULT_UNIFIED_SETTINGS, + automaticGitFetchInterval, + }, + "battery-saver", + ), + ).toEqual({ + schemaVersion: 1, + profile: "custom", + baseProfile: "battery-saver", + overrides: { + automaticGitFetchInterval, + }, + }); + }); +}); + describe("project grouping toggle", () => { it("enables repository grouping and disables into separate projects", () => { expect(isProjectGroupingEnabled("repository")).toBe(true); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 51e318225ae6..1d4baefa53a5 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,4 +1,6 @@ import type { + BackgroundActivityProfile, + BackgroundActivitySettings, ProviderDriverKind, ProviderInstanceConfig, ProviderInstanceId, @@ -7,6 +9,12 @@ import type { UnifiedSettings, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + normalizeBackgroundActivitySettings, + normalizeServerBackgroundActivitySettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; +import * as Equal from "effect/Equal"; export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { return mode !== "separate"; @@ -41,6 +49,65 @@ export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingM } } +export function hasChangedBackgroundActivitySettings( + settings: Pick< + UnifiedSettings, + | "backgroundActivity" + | "backgroundActivityProfile" + | "automaticGitFetchInterval" + | "providerHealthRefreshInterval" + >, +): boolean { + return ( + !Equal.equals(settings.backgroundActivity, DEFAULT_UNIFIED_SETTINGS.backgroundActivity) || + settings.backgroundActivityProfile !== DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile || + !Equal.equals( + settings.automaticGitFetchInterval, + DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + ) || + !Equal.equals( + settings.providerHealthRefreshInterval, + DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, + ) + ); +} + +export function resolveBackgroundActivityProfileOption( + settings: ServerSettings, +): BackgroundActivityProfile | "advanced" { + const resolved = resolveServerBackgroundActivitySettings(settings); + const normalized = normalizeBackgroundActivitySettings({ + schemaVersion: 1, + profile: "custom", + baseProfile: resolved.profile, + overrides: { + automaticGitFetchInterval: resolved.automaticGitFetchInterval, + providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, + hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, + idleClientTtl: resolved.idleClientTtl, + pauseWhenHostLocked: resolved.pauseWhenHostLocked, + pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, + pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, + pauseWhenOnBattery: resolved.pauseWhenOnBattery, + }, + }); + return normalized.profile === "custom" ? "advanced" : normalized.profile; +} + +export function backgroundActivitySharedPolicySettings( + settings: ServerSettings, + profile: BackgroundActivityProfile, +): BackgroundActivitySettings { + const normalized = normalizeServerBackgroundActivitySettings(settings); + return { + schemaVersion: 1, + profile: "custom", + baseProfile: profile, + overrides: normalized.profile === "custom" ? normalized.overrides : {}, + }; +} + function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; readonly metricsUrl: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 611cafc14536..5385751924e1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,10 +1,20 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { + ArchiveIcon, + ArchiveX, + InfoIcon, + LoaderIcon, + PlusIcon, + RefreshCwIcon, + SettingsIcon, +} from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, + type BackgroundActivityProfile, + type BackgroundActivitySettings, type DesktopUpdateChannel, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, @@ -27,6 +37,11 @@ import { MAX_GLASS_OPACITY, MIN_GLASS_OPACITY, } from "@t3tools/contracts/settings"; +import { + getBackgroundActivityBaseProfile, + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; @@ -72,7 +87,23 @@ import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -88,12 +119,15 @@ import { import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { + backgroundActivitySharedPolicySettings, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, + resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -132,7 +166,129 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { + balanced: "Balanced", + performance: "Performance", + "battery-saver": "Battery saver", +}; + +type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; +type BackgroundActivityOverridePatch = Partial<{ + [K in keyof BackgroundActivitySettings["overrides"]]: + | BackgroundActivitySettings["overrides"][K] + | undefined; +}>; + +const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { + ...BACKGROUND_ACTIVITY_PROFILE_LABELS, + advanced: "Advanced", +}; + +const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record = { + balanced: + "Pauses background probes when clients are idle, the host is locked, or low power mode is active.", + performance: "Allows scoped background probes while any subscribed client remains connected.", + "battery-saver": "Also pauses background probes when the host or client is on battery.", +}; + +const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = + "Uses custom background intervals with the selected shared power policy."; + +const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); +const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ + readonly key: + | "pauseWhenHostLocked" + | "pauseWhenHostLowPower" + | "pauseWhenClientLowPower" + | "pauseWhenOnBattery"; + readonly label: string; +}> = [ + { key: "pauseWhenHostLocked", label: "Pause when host is locked" }, + { key: "pauseWhenHostLowPower", label: "Pause on host low power" }, + { key: "pauseWhenClientLowPower", label: "Pause on client low power" }, + { key: "pauseWhenOnBattery", label: "Pause on battery" }, +]; + +function durationToSeconds(duration: Duration.Duration): number { + return Math.round(Duration.toMillis(duration) / 1_000); +} + +function normalizeIntervalSeconds(value: number | null, minimum = 0): number { + if (value === null || !Number.isFinite(value)) { + return minimum; + } + return Math.max(minimum, Math.round(value)); +} + +function resetBackgroundActivitySettings() { + return { + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + }; +} + +function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { + return { + backgroundActivity: { + schemaVersion: 1 as const, + profile, + overrides: {}, + }, + }; +} + +function backgroundActivityOverrideSettings( + current: BackgroundActivitySettings, + resolved: ReturnType, + overrides: BackgroundActivityOverridePatch, +) { + const nextOverrides: BackgroundActivityOverridePatch = { + automaticGitFetchInterval: resolved.automaticGitFetchInterval, + providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, + hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, + idleClientTtl: resolved.idleClientTtl, + pauseWhenHostLocked: resolved.pauseWhenHostLocked, + pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, + pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, + pauseWhenOnBattery: resolved.pauseWhenOnBattery, + ...overrides, + }; + for (const [key, value] of Object.entries(nextOverrides)) { + if (value === undefined) { + delete nextOverrides[key as keyof typeof nextOverrides]; + } + } + return { + backgroundActivity: { + schemaVersion: 1 as const, + profile: "custom" as const, + baseProfile: getBackgroundActivityBaseProfile(current), + overrides: nextOverrides as BackgroundActivitySettings["overrides"], + }, + }; +} + +function PolicyTooltip({ children }: { readonly children: string }) { + return ( + + + + + } + /> + + {children} + + + ); +} function withoutProviderInstanceKey( record: Readonly> | undefined, @@ -408,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); const changedSettingLabels = useMemo( () => [ @@ -441,10 +598,7 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] : []), - ...(Duration.toMillis(settings.automaticGitFetchInterval) !== - Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) - ? ["Automatic Git fetch interval"] - : []), + ...(isBackgroundActivityDirty ? ["Background activity"] : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), @@ -465,6 +619,7 @@ export function useSettingsRestore(onRestored?: () => void) { ], [ isTextGenerationModelDirty, + isBackgroundActivityDirty, settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, @@ -474,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.glassOpacity, - settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, settings.sidebarProjectGroupingMode, @@ -507,7 +661,10 @@ export function useSettingsRestore(onRestored?: () => void) { autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, @@ -524,6 +681,272 @@ export function useSettingsRestore(onRestored?: () => void) { }; } +function BackgroundActivityAdvancedDialog({ + open, + onOpenChange, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const activeProfile = resolvedBackgroundActivity.profile; + const automaticGitFetchIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.automaticGitFetchInterval, + ); + const providerHealthRefreshIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.providerHealthRefreshInterval, + ); + const hostPowerMonitorActiveIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.hostPowerMonitorActiveInterval, + ); + const hostPowerMonitorIdleIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.hostPowerMonitorIdleInterval, + ); + + return ( + + + + Background Activity + + Tune the shared power policy and the background intervals that feed it. + + + +
+
+
+
Shared policy
+

+ Controls whether background work may run after a subscribed interval fires. +

+
+ +
+ +
+
+
Git fetch interval
+

+ Refresh remote branch status in the background. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + automaticGitFetchInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+
+
Provider health interval
+

+ Refresh provider availability, versions, auth state, and model metadata. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+
+
Host power monitor
+

+ Poll host power state while clients are active. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + hostPowerMonitorActiveInterval: Duration.seconds( + normalizeIntervalSeconds(value, 5), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+
+
Idle host monitor
+

+ Poll host power state when no foreground client is active. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + hostPowerMonitorIdleInterval: Duration.seconds( + normalizeIntervalSeconds(value, 5), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
+ {BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES.map(({ key, label }) => ( + + ))} +
+
+
+ + + + +
+
+ ); +} + export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); @@ -693,6 +1116,7 @@ export function AppearanceSettingsPanel() { export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); @@ -728,6 +1152,19 @@ export function GeneralSettingsPanel() { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; + const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); + const backgroundActivityDescription = + backgroundActivityProfileOption === "advanced" + ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Current shared policy: ${ + BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] + }.` + : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; + const canResetBackgroundActivity = !Equal.equals( + settings.backgroundActivity, + DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + ); return ( @@ -890,6 +1327,88 @@ export function GeneralSettingsPanel() { } /> + + Background activity + + This shared policy gates background work such as Git refreshes and provider health + probes after their individual intervals elapse. + +
+ } + description={backgroundActivityDescription} + resetAction={ + canResetBackgroundActivity ? ( + updateSettings(resetBackgroundActivitySettings())} + /> + ) : null + } + control={ + <> + + {backgroundActivityProfileOption === "advanced" ? ( + + setBackgroundActivityDialogOpen(true)} + > + + + } + /> + Configure background activity + + ) : null} + + + } + /> + 0 ? serverProviders.reduce( @@ -1508,6 +2035,69 @@ export function ProviderSettingsPanel() { } > + + Health check interval + + This interval is configured here, then the shared Background activity policy decides + whether provider probes may run when the timer fires. Custom intervals appear as + Advanced in General settings. + + + } + description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." + resetAction={ + providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: undefined, + }, + ), + ) + } + /> + ) : null + } + control={ +
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+ } + /> + {rows.map((row) => { const driverOption = getDriverOption(row.driver); const liveProvider = serverProviders.find( diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 764f682fbc5d..cf6da231cdf2 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,8 +1,9 @@ -import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { ChevronDownIcon, GitPullRequestIcon, InfoIcon, RefreshCwIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useState, type ReactNode } from "react"; import type { + BackgroundActivitySettings, SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, @@ -10,7 +11,11 @@ import type { VcsDriverKind, VcsDiscoveryItem, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + getBackgroundActivityBaseProfile, + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -70,6 +75,11 @@ const VCS_ICONS: Partial> = { const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; +type BackgroundActivityOverridePatch = Partial<{ + [K in keyof BackgroundActivitySettings["overrides"]]: + | BackgroundActivitySettings["overrides"][K] + | undefined; +}>; function durationToSeconds(duration: Duration.Duration): number { return Math.round(Duration.toMillis(duration) / 1_000); @@ -82,6 +92,50 @@ function normalizeFetchIntervalSeconds(value: number | null): number { return Math.max(0, Math.round(value)); } +function backgroundActivityOverrideSettings( + current: BackgroundActivitySettings, + overrides: BackgroundActivityOverridePatch, +) { + const nextOverrides: BackgroundActivityOverridePatch = { + ...current.overrides, + ...overrides, + }; + for (const [key, value] of Object.entries(nextOverrides)) { + if (value === undefined) { + delete nextOverrides[key as keyof typeof nextOverrides]; + } + } + return { + backgroundActivity: { + schemaVersion: 1 as const, + profile: "custom" as const, + baseProfile: getBackgroundActivityBaseProfile(current), + overrides: nextOverrides as BackgroundActivitySettings["overrides"], + }, + }; +} + +function BackgroundPolicyTooltip({ children }: { readonly children: string }) { + return ( + + + + + } + /> + + {children} + + + ); +} + function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); } @@ -292,13 +346,16 @@ function DiscoveryItemRow({ } function GitFetchIntervalSettings() { - const automaticGitFetchInterval = usePrimarySettings( - (settings) => settings.automaticGitFetchInterval, - ); + const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); - const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const automaticGitFetchIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.automaticGitFetchInterval, + ); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( - DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + getBackgroundActivityPresetSettings( + getBackgroundActivityBaseProfile(settings.backgroundActivity), + ).automaticGitFetchInterval, ); const canResetFetchInterval = automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; @@ -309,6 +366,11 @@ function GitFetchIntervalSettings() {
Fetch interval + + This interval is configured for Git only. The shared Background activity policy still + decides whether Git refreshes may run when the timer fires. Custom intervals appear as + Advanced in General settings. + - updateSettings({ - automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, - }) + updateSettings( + backgroundActivityOverrideSettings(settings.backgroundActivity, { + automaticGitFetchInterval: undefined, + }), + ) } /> ) : null} @@ -341,9 +405,11 @@ function GitFetchIntervalSettings() { size="sm" className="w-32" onValueChange={(value) => - updateSettings({ - automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), - }) + updateSettings( + backgroundActivityOverrideSettings(settings.backgroundActivity, { + automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), + }), + ) } > diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index 3d4bf4944f14..0b5e2c9df278 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -5,6 +5,10 @@ import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; import { runtimeContextLayer } from "../lib/runtime"; +import { + backgroundActivityObserverLayer, + backgroundActivityReporterLayer, +} from "../lib/backgroundActivityReporter"; import { connectionPlatformLayer } from "./platform"; const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( @@ -17,10 +21,22 @@ type ConnectionLayerSource = | typeof Connection.layer | typeof snapshotLoaderLayer | typeof runtimeContextLayer - | typeof connectionPlatformLayer; + | typeof connectionPlatformLayer + | typeof backgroundActivityObserverLayer + | typeof backgroundActivityReporterLayer; -const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( - Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( + Layer.provideMerge( + Layer.mergeAll( + runtimeContextLayer, + providedConnectionPlatformLayer, + backgroundActivityObserverLayer, + ), + ), +); + +const connectionLayer = backgroundActivityReporterLayer.pipe( + Layer.provideMerge(providedClientConnectionLayer), ); export const connectionAtomRuntime: Atom.AtomRuntime< diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index fb2e493cadaa..2e08dd336984 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -1,8 +1,6 @@ /** * True when running inside the Electron preload bridge, false in a regular browser. - * The preload script sets window.nativeApi via contextBridge before any web-app + * The preload script sets window.desktopBridge via contextBridge before any web-app * code executes, so this is reliable at module load time. */ -export const isElectron = - typeof window !== "undefined" && - (window.desktopBridge !== undefined || window.nativeApi !== undefined); +export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined; diff --git a/apps/web/src/environments/primary/httpLayer.ts b/apps/web/src/environments/primary/httpLayer.ts index bedb4954d54c..306b5de63d75 100644 --- a/apps/web/src/environments/primary/httpLayer.ts +++ b/apps/web/src/environments/primary/httpLayer.ts @@ -10,7 +10,6 @@ function isSameOriginBrowserPrimary(): boolean { if ( typeof window === "undefined" || window.desktopBridge !== undefined || - window.nativeApi !== undefined || !window.location.origin.startsWith("http") ) { return false; diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3f22c605e465..af904e9bb0c9 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -352,7 +352,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); } } - if (Object.keys(clientPatch).length > 0) { persistClientSettings({ ...getClientSettingsSnapshot(), diff --git a/apps/web/src/lib/backgroundActivityReporter.test.ts b/apps/web/src/lib/backgroundActivityReporter.test.ts new file mode 100644 index 000000000000..e32d991f9f53 --- /dev/null +++ b/apps/web/src/lib/backgroundActivityReporter.test.ts @@ -0,0 +1,63 @@ +import { EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + observeBackgroundActivitySubscription, + retainedBackgroundScopes, + wasRecentlyInteracted, +} from "./backgroundActivityReporter.ts"; + +describe("wasRecentlyInteracted", () => { + it("expires interaction independently of window focus", () => { + expect(wasRecentlyInteracted(10_000, 55_000)).toBe(true); + expect(wasRecentlyInteracted(10_000, 55_001)).toBe(false); + }); + + it("rejects future timestamps", () => { + expect(wasRecentlyInteracted(10_001, 10_000)).toBe(false); + }); + + it.effect("retains an observed subscription until its returned finalizer runs", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("environment-observation-test"); + const scope = { type: "vcs-status" as const, cwd: "/repo" }; + const release = yield* observeBackgroundActivitySubscription({ + environmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: scope.cwd }, + }); + + expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); + + yield* release; + expect(retainedBackgroundScopes(environmentId)).toEqual([]); + }), + ); + + it.effect("keeps delimiter-containing environment and scope values distinct", () => + Effect.gen(function* () { + const firstEnvironmentId = EnvironmentId.make("a"); + const secondEnvironmentId = EnvironmentId.make("a:vcs-status:b"); + const releaseFirst = yield* observeBackgroundActivitySubscription({ + environmentId: firstEnvironmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "b:vcs-status:c" }, + }); + const releaseSecond = yield* observeBackgroundActivitySubscription({ + environmentId: secondEnvironmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "c" }, + }); + + expect(retainedBackgroundScopes(firstEnvironmentId)).toEqual([ + { type: "vcs-status", cwd: "b:vcs-status:c" }, + ]); + expect(retainedBackgroundScopes(secondEnvironmentId)).toEqual([ + { type: "vcs-status", cwd: "c" }, + ]); + + yield* Effect.all([releaseFirst, releaseSecond]); + }), + ); +}); diff --git a/apps/web/src/lib/backgroundActivityReporter.ts b/apps/web/src/lib/backgroundActivityReporter.ts new file mode 100644 index 000000000000..8af048b47f36 --- /dev/null +++ b/apps/web/src/lib/backgroundActivityReporter.ts @@ -0,0 +1,254 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { + EnvironmentRpcSubscriptionObserver, + request, + type EnvironmentRpcSubscriptionObservation, +} from "@t3tools/client-runtime/rpc"; +import { + type BackgroundScope, + type ClientActivityReportInput, + type EnvironmentId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { randomUUID } from "./utils"; + +const CLIENT_ID_STORAGE_KEY = "t3.backgroundActivity.clientId"; +const REPORT_INTERVAL_MS = 25_000; +const LEASE_TTL_MS = 45_000; +const RECENT_INTERACTION_WINDOW_MS = LEASE_TTL_MS; +const BASELINE_SCOPES: ReadonlyArray = [{ type: "provider-status" }]; + +interface RetainedScope { + readonly environmentId: EnvironmentId; + readonly scope: BackgroundScope; + refCount: number; +} + +const retainedScopes = new Map(); +const retainedScopeListeners = new Set<() => void>(); + +function notifyRetainedScopesChanged(): void { + for (const listener of retainedScopeListeners) { + try { + listener(); + } catch { + // A failing observer must not corrupt retained-scope lifetime. + } + } +} + +function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string { + switch (scope.type) { + case "server-config": + case "diagnostics": + return JSON.stringify([environmentId, scope.type]); + case "provider-status": + return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]); + case "vcs-status": + case "git-refs": + return JSON.stringify([environmentId, scope.type, scope.cwd]); + case "thread": + return JSON.stringify([environmentId, scope.type, scope.threadId]); + } +} + +function getClientId(): string { + try { + const existing = window.localStorage.getItem(CLIENT_ID_STORAGE_KEY); + if (existing) return existing; + const next = randomUUID(); + window.localStorage.setItem(CLIENT_ID_STORAGE_KEY, next); + return next; + } catch { + return "ephemeral-browser-client"; + } +} + +function resolveClientKind(): ClientActivityReportInput["clientKind"] { + return window.desktopBridge ? "desktop-renderer" : "web"; +} + +export function wasRecentlyInteracted(lastInteractionAtMs: number, observedAtMs: number): boolean { + return ( + lastInteractionAtMs <= observedAtMs && + observedAtMs - lastInteractionAtMs <= RECENT_INTERACTION_WINDOW_MS + ); +} + +function createActivityReport( + environmentId: EnvironmentId, + lastInteractionAtMs: number, + observedAtMs: number, +): ClientActivityReportInput { + const scopes = [...BASELINE_SCOPES]; + for (const entry of retainedScopes.values()) { + if (entry.environmentId === environmentId) { + scopes.push(entry.scope); + } + } + return { + environmentId, + clientId: getClientId(), + clientKind: resolveClientKind(), + visible: document.visibilityState === "visible", + focused: document.hasFocus(), + recentlyInteracted: wasRecentlyInteracted(lastInteractionAtMs, observedAtMs), + appState: document.visibilityState === "visible" ? "active" : "background", + scopes, + ttlMs: LEASE_TTL_MS, + observedAt: DateTime.makeUnsafe(observedAtMs), + }; +} + +function scopeForSubscription( + observation: EnvironmentRpcSubscriptionObservation, +): BackgroundScope | null { + if (observation.method === WS_METHODS.subscribeResourceTelemetry) { + return { type: "diagnostics" }; + } + if (observation.method !== WS_METHODS.subscribeVcsStatus) { + return null; + } + const input = observation.input as { readonly cwd?: unknown }; + return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; +} + +function retainBackgroundScope(environmentId: EnvironmentId, scope: BackgroundScope): () => void { + const key = stableScopeKey(environmentId, scope); + const existing = retainedScopes.get(key); + if (existing) { + existing.refCount += 1; + } else { + retainedScopes.set(key, { environmentId, scope, refCount: 1 }); + notifyRetainedScopesChanged(); + } + + return () => { + const current = retainedScopes.get(key); + if (!current) return; + current.refCount -= 1; + if (current.refCount <= 0) { + retainedScopes.delete(key); + notifyRetainedScopesChanged(); + } + }; +} + +export function observeBackgroundActivitySubscription( + observation: EnvironmentRpcSubscriptionObservation, +): Effect.Effect> { + const scope = scopeForSubscription(observation); + if (scope === null) { + return Effect.succeed(Effect.void); + } + return Effect.sync(() => { + const release = retainBackgroundScope(observation.environmentId as EnvironmentId, scope); + return Effect.sync(release); + }); +} + +export function retainedBackgroundScopes( + environmentId: EnvironmentId, +): ReadonlyArray { + return Array.from(retainedScopes.values(), (entry) => + entry.environmentId === environmentId ? entry.scope : null, + ).filter((scope): scope is BackgroundScope => scope !== null); +} + +export const backgroundActivityObserverLayer = Layer.succeed( + EnvironmentRpcSubscriptionObserver, + EnvironmentRpcSubscriptionObserver.of({ + observe: observeBackgroundActivitySubscription, + }), +); + +export const backgroundActivityReporterLayer = Layer.effectDiscard( + Effect.gen(function* () { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + const registry = yield* EnvironmentRegistry; + const clock = yield* Clock.Clock; + const reportRequests = yield* Queue.sliding(1); + const requestReport = () => Queue.offerUnsafe(reportRequests, undefined); + let lastInteractionAtMs = clock.currentTimeMillisUnsafe(); + const recordInteraction = () => { + const observedAtMs = clock.currentTimeMillisUnsafe(); + const wasRecent = wasRecentlyInteracted(lastInteractionAtMs, observedAtMs); + lastInteractionAtMs = observedAtMs; + if (!wasRecent) { + requestReport(); + } + }; + const passiveListenerOptions = { passive: true } as const; + + const report = Effect.gen(function* () { + const observedAtMs = yield* Clock.currentTimeMillis; + const entries = yield* SubscriptionRef.get(registry.entries); + yield* Effect.forEach( + entries.keys(), + (environmentId) => + registry + .run( + environmentId, + request( + WS_METHODS.serverReportClientActivity, + createActivityReport(environmentId, lastInteractionAtMs, observedAtMs), + ), + ) + .pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + }).pipe(Effect.withSpan("web.backgroundActivity.report")); + + yield* Effect.acquireRelease( + Effect.sync(() => { + retainedScopeListeners.add(requestReport); + document.addEventListener("visibilitychange", requestReport); + window.addEventListener("focus", requestReport); + window.addEventListener("blur", requestReport); + window.addEventListener("online", requestReport); + window.addEventListener("pointermove", recordInteraction); + window.addEventListener("keydown", recordInteraction); + window.addEventListener("wheel", recordInteraction, passiveListenerOptions); + window.addEventListener("touchstart", recordInteraction, passiveListenerOptions); + }), + () => + Effect.sync(() => { + retainedScopeListeners.delete(requestReport); + document.removeEventListener("visibilitychange", requestReport); + window.removeEventListener("focus", requestReport); + window.removeEventListener("blur", requestReport); + window.removeEventListener("online", requestReport); + window.removeEventListener("pointermove", recordInteraction); + window.removeEventListener("keydown", recordInteraction); + window.removeEventListener("wheel", recordInteraction); + window.removeEventListener("touchstart", recordInteraction); + }), + ); + + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.runForEach(() => Effect.sync(requestReport)), + Effect.forkScoped, + ); + yield* Stream.fromQueue(reportRequests).pipe( + Stream.debounce("250 millis"), + Stream.runForEach(() => report), + Effect.forkScoped, + ); + yield* Effect.sync(requestReport).pipe( + Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)), + Effect.forkScoped, + ); + }), +); diff --git a/apps/web/src/lib/resourceTelemetryState.ts b/apps/web/src/lib/resourceTelemetryState.ts new file mode 100644 index 000000000000..47ca79898dfb --- /dev/null +++ b/apps/web/src/lib/resourceTelemetryState.ts @@ -0,0 +1,51 @@ +import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { useCallback } from "react"; + +import { usePrimaryEnvironment } from "../state/environments"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { useAtomCommand } from "../state/use-atom-command"; + +export interface ResourceTelemetryState { + readonly data: ResourceTelemetrySnapshot | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; + readonly retry: () => Promise; +} + +export function useResourceTelemetry(): ResourceTelemetryState { + const primaryEnvironment = usePrimaryEnvironment(); + const environmentId = primaryEnvironment?.environmentId ?? null; + const query = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.resourceTelemetry({ environmentId, input: {} }), + ); + const retryCommand = useAtomCommand(serverEnvironment.retryResourceTelemetry, { + reportFailure: false, + }); + const retry = useCallback(async () => { + if (environmentId === null) { + throw new Error("No environment is selected."); + } + const result = await retryCommand({ environmentId, input: {} }); + if (result._tag === "Failure") { + throw Cause.squash(result.cause); + } + return result.value.snapshot; + }, [environmentId, retryCommand]); + + return { ...query, retry }; +} + +export function useResourceTelemetryHistory(input: ResourceTelemetryHistoryInput) { + const primaryEnvironment = usePrimaryEnvironment(); + const environmentId = primaryEnvironment?.environmentId ?? null; + return useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.resourceTelemetryHistory({ environmentId, input }), + ); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 3379f5ed9893..260256c12503 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -49,7 +49,6 @@ beforeEach(() => { }); } Reflect.deleteProperty(testWindow(), "desktopBridge"); - Reflect.deleteProperty(testWindow(), "nativeApi"); Object.defineProperty(testWindow(), "localStorage", { configurable: true, value: createLocalStorageStub(), @@ -61,16 +60,12 @@ afterEach(() => { }); describe("LocalApi", () => { - it("keeps backend operations unavailable in the browser facade", async () => { + it("keeps backend operations out of the local host facade", async () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(); - await expect(api.server.getConfig()).rejects.toThrow( - "Local backend API is unavailable before a backend is paired.", - ); - await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow( - "Local backend API is unavailable before a backend is paired.", - ); + expect(api).not.toHaveProperty("server"); + expect(api.shell).not.toHaveProperty("openInEditor"); }); it("uses the browser context-menu fallback without a desktop bridge", async () => { @@ -120,12 +115,4 @@ describe("LocalApi", () => { await api.persistence.setClientSettings(settings); await expect(api.persistence.getClientSettings()).resolves.toEqual(settings); }); - - it("prefers the native LocalApi when one is injected", async () => { - const nativeApi = { dialogs: {} }; - testWindow().nativeApi = nativeApi as never; - const { readLocalApi } = await import("./localApi"); - - expect(readLocalApi()).toBe(nativeApi); - }); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2fbf183f91b0..b42702c7a4a7 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -6,10 +6,6 @@ import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientP let cachedApi: LocalApi | undefined; -function unavailableLocalBackendError(): Error { - return new Error("Local backend API is unavailable before a backend is paired."); -} - function createBrowserLocalApi(): LocalApi { return { dialogs: { @@ -25,7 +21,6 @@ function createBrowserLocalApi(): LocalApi { }, }, shell: { - openInEditor: () => Promise.reject(unavailableLocalBackendError()), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -63,20 +58,6 @@ function createBrowserLocalApi(): LocalApi { writeBrowserClientSettings(settings); }, }, - server: { - getConfig: () => Promise.reject(unavailableLocalBackendError()), - refreshProviders: () => Promise.reject(unavailableLocalBackendError()), - updateProvider: () => Promise.reject(unavailableLocalBackendError()), - upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), - removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), - getSettings: () => Promise.reject(unavailableLocalBackendError()), - updateSettings: () => Promise.reject(unavailableLocalBackendError()), - discoverSourceControl: () => Promise.reject(unavailableLocalBackendError()), - getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()), - getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), - getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), - signalProcess: () => Promise.reject(unavailableLocalBackendError()), - }, }; } @@ -88,12 +69,7 @@ export function readLocalApi(): LocalApi | undefined { if (typeof window === "undefined") return undefined; if (cachedApi) return cachedApi; - if (window.nativeApi) { - cachedApi = window.nativeApi; - return cachedApi; - } - - cachedApi = createBrowserLocalApi(); + cachedApi = createLocalApi(); return cachedApi; } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index ac1da93f5c85..31260c8ae666 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,6 +1,6 @@ /// -import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; +import type { DesktopBridge } from "@t3tools/contracts"; interface ImportMetaEnv { readonly VITE_HTTP_URL: string; @@ -22,7 +22,6 @@ interface ImportMeta { declare global { interface Window { - nativeApi?: LocalApi; desktopBridge?: DesktopBridge; } } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7eb2f4dfd604..fa8833d9db2b 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -40,6 +40,10 @@ T3 Code runs as a **Node.js WebSocket server** that serves a React web app and c - **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). +Related design: + +- [Resource telemetry architecture](./resource-telemetry.md) + ## Event Lifecycle ### Startup and client connect diff --git a/docs/architecture/resource-telemetry.md b/docs/architecture/resource-telemetry.md new file mode 100644 index 000000000000..504aa3f8b646 --- /dev/null +++ b/docs/architecture/resource-telemetry.md @@ -0,0 +1,391 @@ +# Resource telemetry architecture + +Status: implemented + +## Purpose + +Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset` +subprocess probes with two persistent, direct data sources: + +1. a standalone Rust resource-monitor executable that reads process counters + through operating-system APIs via `sysinfo`; +2. Electron main-process APIs for Electron process metrics and host power state. + +The native monitor owns bounded in-memory history. The server only merges and +summarizes that history when diagnostics requests it. Telemetry history is not +persisted to disk or continuously copied into Node. + +## Why a standalone executable + +The monitor is intentionally not a Node native addon. + +- No N-API, `ffi-rs`, or dynamic-library ABI is loaded into the server process. +- A monitor crash cannot corrupt the Node runtime. +- The server can supervise, restart, version-check, and measure the monitor as a + normal child process. +- The same protocol works for the desktop app and the published CLI. +- Packaging is a single platform executable instead of an addon toolchain plus + Node/Electron ABI matrix. + +The cost is one persistent child process and NDJSON serialization. That is a +better failure boundary than repeatedly spawning shell utilities or loading +native code into Node. + +## Runtime topology + +### Desktop + +```text +Electron main + ├─ powerMonitor + ├─ app.getAppMetrics() while diagnostics is open + ├─ inherited fd 4, telemetry NDJSON ─────────────┐ + └─ inherited fd 5, demand-control NDJSON ◀──────┤ + ▼ +Node server ── stdin/stdout NDJSON ── Rust resource monitor + │ + ├─ ResourceTelemetry Effect service + ├─ background power policy projection + └─ WebSocket RPC/subscription ── diagnostics UI +``` + +### Web, headless, and remote server + +Electron telemetry is unavailable. The native monitor still runs beside the +server and tracks the server process tree. Power fields degrade to `unknown` +instead of invoking platform shell commands. + +### WSL backend limitation + +Windows desktop packages currently ship the Windows resource-monitor executable. +That executable cannot run inside the Linux WSL backend, so a WSL-only backend +does not receive `resourceMonitorPath` and reports native process telemetry as +unavailable. Electron host-power telemetry remains available over the inherited +desktop pipe. Supporting native WSL process telemetry requires publishing a +Linux sidecar for each supported architecture in the Windows artifact and +converting its packaged path into the selected distro; the configuration +deliberately does not pass the Windows `.exe` into WSL as a false fallback. + +## Native monitor + +The executable lives in `native/resource-monitor`. + +It receives schema-compatible commands on stdin and emits one JSON object per +line on stdout: + +- `configure` +- `setExternalProcesses` +- `setSampleInterval` +- `setStreaming` +- `sampleNow` +- `readHistory` +- `shutdown` +- `hello` +- `snapshot` +- `historyChunk` +- `error` + +The protocol version is defined by +`RESOURCE_MONITOR_PROTOCOL_VERSION` in +`packages/contracts/src/resourceTelemetry.ts`. + +### Collection + +The monitor keeps one `sysinfo::System` instance and refreshes it at the +power-adaptive interval selected by the server. It collects: + +- PID and parent PID; +- process start time and run time; +- process name and command line; +- current and cumulative CPU usage; +- resident and virtual memory; +- cumulative process I/O counters. + +On Linux, task/thread enumeration is disabled. Command lines are loaded only +when first needed. This avoids the expensive default behavior of walking every +`/proc//task/` directory on each refresh. + +### Process-tree selection + +Each sample scans the accessible process table, builds the PID/PPID graph, and +retains: + +- the server process; +- every descendant of the server, including provider-spawned grandchildren such + as shells, `node`, `tsgo`, language servers, and other tools; +- Electron processes supplied as explicit external roots; +- descendants of those Electron roots; +- the resource monitor itself, because it is a server child. + +Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native +start times are matched with a two-second tolerance because native start times +can have coarser platform resolution. + +The process list is emitted in depth-first tree order so renderer collapse and +expansion preserves complete subtrees. + +### Native history and streaming + +Every native sample is appended to a one-hour in-memory ring bounded to 3,600 +snapshots, 20,000 retained process rows, and 64 MiB of retained history bytes. +History stays in the sidecar until a `readHistory` request and is returned in +bounded chunks. The first bound reached wins, so high process counts or large +process names and command lines shorten the effective history window. + +Periodic snapshot streaming is disabled by default. The server enables it only +while at least one diagnostics subscription is retained. `sampleNow` remains +available for explicit refreshes and identity validation. + +The server adjusts native sampling without restarting the sidecar: + +- suspended, locked, low-power, or serious/critical thermal state: 15 seconds; +- battery: 5 seconds; +- normal AC: 1 second; +- unknown or stale power: 5 seconds in the background and 1 second while live + diagnostics is open. + +### Sampling limits + +This is counter sampling, not syscall tracing. + +- A process that starts and exits entirely between samples may not be observed. +- Cumulative CPU and I/O counters still provide accurate deltas for processes + that survive across samples. +- Exact file paths, individual write syscalls, ETW events, eBPF events, and + Endpoint Security events are outside this implementation. + +Those deeper tracing systems can be added later as opt-in diagnostic modes +without changing the public `ResourceTelemetry` model. + +## I/O semantics + +The monitor preserves platform semantics instead of presenting all counters as +equivalent: + +- Unix-like platforms report storage I/O counters exposed by `sysinfo`. +- Windows reports all process I/O bytes, not only disk bytes. +- Operating-system caches can prevent logical application reads or writes from + appearing as physical storage bytes. + +The UI therefore labels these values as I/O reads and writes and exposes the +per-process `ioSemantics` value. + +Group totals are observed deltas since telemetry startup. Per-process total +columns are the operating system's cumulative counters for that process. + +## Electron telemetry + +Electron main owns `DesktopTelemetryPublisher`. + +Power events trigger an immediate snapshot. While diagnostics is closed, the +server sends the configured active and idle host-power intervals to Electron +(30 seconds and 2 minutes in the balanced profile). During those heartbeats +Electron reads: + +- `powerMonitor.isOnBatteryPower()`; +- `powerMonitor.getSystemIdleTime()`; +- `powerMonitor.getSystemIdleState()`; +- `powerMonitor.getCurrentThermalState()`. + +`app.getAppMetrics()` is only called while diagnostics demand is active. Its +live cadence is 1 second on AC, 5 seconds on battery, and 15 seconds while +locked, suspended, or thermally constrained. + +It also listens for: + +- lock and unlock; +- suspend and resume; +- AC and battery transitions; +- thermal-state changes; +- CPU speed-limit changes. + +Suspension stays latched across event-driven snapshots. An explicit resume +clears it immediately; a periodic heartbeat also clears a stale latch so a +missed resume event cannot leave telemetry permanently constrained. + +Electron does not expose a cross-platform low-power-mode getter, so that field +remains `unknown`. + +The desktop backend is spawned with: + +- fd 3 for the existing bootstrap payload; +- fd 4 for Electron-to-server telemetry NDJSON; +- fd 5 for server-to-Electron diagnostics-demand NDJSON. + +These are private Electron-main/server pipes. They do not use the renderer +WebSocket and are recreated for every backend restart. + +## Server Effect services + +The implementation is under `apps/server/src/resourceTelemetry`. + +### `ResourceMonitorBinary` + +Resolves an executable from: + +1. `T3CODE_RESOURCE_MONITOR_PATH`; +2. desktop bootstrap configuration; +3. bundled CLI resources; +4. local Cargo build outputs. + +Unsupported platforms, missing binaries, and non-executable binaries use +schema-backed tagged errors with descriptive messages. + +### `NativeTelemetryClient` + +Owns the resource-monitor process and protocol. + +- validates the hello/version handshake; +- sends configuration and external process roots; +- adapts the native interval from host power state; +- enables streaming only for scoped live subscribers; +- reads chunked native history on demand; +- exposes `sampleNow`; +- serializes commands; +- supervises process exit and protocol failure; +- restarts with bounded exponential backoff; +- opens a circuit after repeated failures; +- supports explicit retry; +- publishes health changes immediately. + +Snapshot sequence numbers are scoped to a monitor generation. Server ingestion +uses the monitor restart count as the generation key, so sequence reset after a +restart cannot freeze telemetry. + +### `DesktopTelemetryReceiver` + +Reads fd 4, decodes schema-validated messages, stores the latest Electron +snapshot, and publishes desktop health. It writes diagnostics demand to fd 5 +and gives the first sample a 90-second startup deadline. Once samples arrive, +the stale deadline stays beyond the slower configured host-power interval with +30 seconds of scheduling grace, so intentional 2–10 minute idle polling does +not oscillate the policy between constrained and unconstrained states. Decode +errors, protocol mismatch, control-write failure, stream failure, stale input, +and normal stream closure are represented explicitly. + +### `ResourceTelemetry` + +Merges native and Electron data and owns public telemetry semantics. + +- calculates CPU and I/O rates from cumulative native counters; +- preserves the last native rates during desktop-only updates; +- classifies backend, Electron, and monitor processes; +- computes process depth and child relationships; +- tracks starts, exits, CPU time, and observed I/O; +- projects power data; +- acquires native streaming and Electron process metrics only for scoped live + subscribers; +- queries and replays native history only when requested; +- validates `(pid, startTimeMs)` before process signaling; +- updates history health even when no further native sample arrives. + +Electron and monitor processes are visible but are not valid targets for the +existing process-signal RPC. + +### History projection + +`ResourceTelemetryHistory` is a pure on-demand projection. It replays raw native +snapshots to derive rates, lifecycle counters, buckets, and process summaries. +Current Electron process metrics are intentionally excluded from historical +replay so they cannot overwrite older native CPU or memory samples. + +### `ResourceAttribution` + +Tracks known logical application I/O separately from OS counters. Current +integration points record successful writes for: + +- provider native and canonical event logs; +- the local server trace sink. + +Entries contain component, operation, logical bytes, count, and elapsed time. +Future persistence paths should call `ResourceAttribution.record` rather than +adding diagnostics-specific counters. + +## Background policy integration + +`HostPowerMonitor` consumes `DesktopTelemetryReceiver` directly; observing host +power does not retain live resource diagnostics or invoke shell probes. + +The monitor updates its latest timestamp on every Electron sample but only +publishes semantic state changes. Increasing idle seconds alone does not cause a +background-policy broadcast every second. + +## Public API and UI + +The WebSocket RPC surface provides: + +- current snapshot; +- bounded history; +- explicit monitor retry; +- a live snapshot subscription. + +The diagnostics page displays: + +- aggregate CPU, memory, I/O, and process counts; +- backend, Electron, and monitor overhead groups; +- power and thermal state; +- collector health and restart information; +- CPU and I/O history; +- a collapsible live process tree; +- safe process signaling for backend descendants; +- instrumented logical application I/O. + +Legacy process diagnostics RPCs are projected from the same service so they no +longer start recurring process-table commands. + +## Packaging + +Desktop artifact builds compile the Rust target, stage it as +`resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the +backend bootstrap. + +CLI release jobs upload each active platform monitor artifact and copy it into: + +```text +apps/server/dist/resource-monitor/-/ +``` + +The published server package already includes `dist`, so those executables ship +with the CLI. Missing platform artifacts degrade native telemetry to +`unavailable`; the server continues running. + +## Resource and failure behavior + +Steady state uses: + +- one native process; +- power-adaptive native counter sampling with no periodic Node snapshot stream; +- event-driven Electron power updates plus profile-driven heartbeats (30–60 + seconds while active and 2–10 minutes while idle); +- no `app.getAppMetrics()` calls while diagnostics is closed; +- no telemetry database; +- no recurring shell probes; +- bounded PubSub queues and native ring history. + +The diagnostics page exposes the monitor's own process resource usage and +collection duration so the observer's cost is measurable. + +Failures are isolated: + +- native failure does not stop the server; +- Electron telemetry loss does not stop native telemetry; +- schema/version errors are visible in health; +- repeated native failures stop automatic restart churn until explicit retry; +- server and desktop shutdown close their respective streams and child process + scopes. + +## Future integration points + +High-value follow-up work can use the existing service boundaries: + +- opt-in file-path attribution through platform-specific tracing; +- process lifecycle events to reduce the chance of missing very short-lived + children; +- additional `ResourceAttribution` instrumentation for databases, checkpoints, + caches, and file synchronization; +- exported diagnostic bundles; +- adaptive sample intervals based on diagnostics visibility and active work. + +These additions should preserve the current rules: direct platform APIs, +schema-validated boundaries, explicit metric semantics, bounded retention, and +no mandatory telemetry persistence. diff --git a/native/resource-monitor/Cargo.lock b/native/resource-monitor/Cargo.lock new file mode 100644 index 000000000000..cdc5f9522880 --- /dev/null +++ b/native/resource-monitor/Cargo.lock @@ -0,0 +1,343 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + +[[package]] +name = "t3-resource-monitor" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sysinfo", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/native/resource-monitor/Cargo.toml b/native/resource-monitor/Cargo.toml new file mode 100644 index 000000000000..30cf2ad7892c --- /dev/null +++ b/native/resource-monitor/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "t3-resource-monitor" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sysinfo = "0.39.3" + +[profile.release] +codegen-units = 1 +lto = "thin" +panic = "abort" +strip = true diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs new file mode 100644 index 000000000000..0e5dd66307b9 --- /dev/null +++ b/native/resource-monitor/src/main.rs @@ -0,0 +1,1160 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io::{self, BufRead, BufWriter, Write}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use sysinfo::{ + MINIMUM_CPU_UPDATE_INTERVAL, Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind, +}; + +const PROTOCOL_VERSION: u32 = 2; +const MIN_SAMPLE_INTERVAL_MS: u64 = 250; +const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000; +const PROCESS_START_TIME_PRECISION_MS: u64 = 1_000; +const HISTORY_RETENTION_MS: u64 = 60 * 60_000; +const MAX_HISTORY_SNAPSHOTS: usize = 3_600; +const INPUT_QUEUE_CAPACITY: usize = 64; +const MAX_HISTORY_RETAINED_ENTRIES: usize = 20_000; +const MAX_HISTORY_RETAINED_BYTES: usize = 64 * 1024 * 1024; +const MAX_PROCESS_NAME_BYTES: usize = 1_024; +const MAX_PROCESS_COMMAND_BYTES: usize = 16 * 1_024; +const MAX_PROCESS_STATUS_BYTES: usize = 256; +const HISTORY_CHUNK_SNAPSHOTS: usize = 32; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExternalProcess { + pid: u32, + #[serde(default)] + start_time_ms: Option, +} + +impl ExternalProcess { + fn estimated_history_bytes(&self) -> usize { + std::mem::size_of::() + } +} + +#[derive(Debug, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +enum Command { + Configure { + version: u32, + root_pid: u32, + sample_interval_ms: u64, + #[serde(default)] + external_processes: Vec, + }, + SetExternalProcesses { + version: u32, + processes: Vec, + }, + SetSampleInterval { + version: u32, + sample_interval_ms: u64, + }, + SetStreaming { + version: u32, + enabled: bool, + }, + SampleNow { + version: u32, + request_id: String, + }, + ReadHistory { + version: u32, + request_id: String, + window_ms: u64, + }, + Shutdown { + version: u32, + }, +} + +impl Command { + fn version(&self) -> u32 { + match self { + Self::Configure { version, .. } + | Self::SetExternalProcesses { version, .. } + | Self::SetSampleInterval { version, .. } + | Self::SetStreaming { version, .. } + | Self::SampleNow { version, .. } + | Self::ReadHistory { version, .. } + | Self::Shutdown { version } => *version, + } + } +} + +enum Input { + Command(Command), + Invalid(String), +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Capabilities { + cumulative_cpu_time: bool, + current_cpu_percent: bool, + resident_memory: bool, + virtual_memory: bool, + io_bytes: bool, + process_start_time: bool, + process_tree: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HelloEvent { + version: u32, + #[serde(rename = "type")] + event_type: &'static str, + sidecar_version: &'static str, + sidecar_pid: u32, + platform: &'static str, + arch: &'static str, + capabilities: Capabilities, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +enum IoSemantics { + Storage, + AllIo, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProcessSample { + pid: u32, + ppid: u32, + start_time_ms: u64, + run_time_ms: u64, + name: String, + command: String, + status: String, + cpu_percent: f32, + cpu_time_ms: u64, + resident_bytes: u64, + virtual_bytes: u64, + io_read_bytes: u64, + io_write_bytes: u64, + io_semantics: IoSemantics, +} + +impl ProcessSample { + fn estimated_history_bytes(&self) -> usize { + std::mem::size_of::() + .saturating_add(self.name.len()) + .saturating_add(self.command.len()) + .saturating_add(self.status.len()) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SnapshotEvent { + version: u32, + #[serde(rename = "type")] + event_type: &'static str, + sequence: u64, + sampled_at_unix_ms: u64, + collection_duration_micros: u64, + scanned_process_count: usize, + retained_process_count: usize, + inaccessible_process_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + external_processes: Vec, + processes: Vec, +} + +impl SnapshotEvent { + fn retained_entry_count(&self) -> usize { + self.processes + .len() + .saturating_add(self.external_processes.len()) + } + + fn estimated_history_bytes(&self) -> usize { + std::mem::size_of::() + .saturating_add( + self.processes + .iter() + .map(ProcessSample::estimated_history_bytes) + .sum::(), + ) + .saturating_add( + self.external_processes + .iter() + .map(ExternalProcess::estimated_history_bytes) + .sum::(), + ) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HistoryChunkEvent<'a> { + version: u32, + #[serde(rename = "type")] + event_type: &'static str, + request_id: &'a str, + done: bool, + snapshots: &'a [SnapshotEvent], +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorEvent { + version: u32, + #[serde(rename = "type")] + event_type: &'static str, + code: &'static str, + message: String, + recoverable: bool, +} + +#[derive(Debug, Clone)] +struct CollectorConfig { + root_pid: u32, + sample_interval: Option, + external_processes: HashMap>, +} + +#[derive(Default)] +struct HistoryRecorder { + snapshots: VecDeque, + retained_entry_count: usize, + retained_bytes: usize, +} + +impl HistoryRecorder { + fn record(&mut self, snapshot: &SnapshotEvent) { + self.record_with_limits( + snapshot, + MAX_HISTORY_SNAPSHOTS, + MAX_HISTORY_RETAINED_ENTRIES, + MAX_HISTORY_RETAINED_BYTES, + ); + } + + fn record_with_limits( + &mut self, + snapshot: &SnapshotEvent, + max_snapshots: usize, + max_retained_entries: usize, + max_retained_bytes: usize, + ) { + let mut retained = snapshot.clone(); + retained.request_id = None; + self.retained_entry_count = self + .retained_entry_count + .saturating_add(retained.retained_entry_count()); + self.retained_bytes = self + .retained_bytes + .saturating_add(retained.estimated_history_bytes()); + self.snapshots.push_back(retained); + self.trim_to_limits( + snapshot.sampled_at_unix_ms, + max_snapshots, + max_retained_entries, + max_retained_bytes, + ); + } + + fn trim_to_limits( + &mut self, + now_ms: u64, + max_snapshots: usize, + max_retained_entries: usize, + max_retained_bytes: usize, + ) { + let mut future_entry_count = 0usize; + let mut future_bytes = 0usize; + self.snapshots.retain(|snapshot| { + let keep = snapshot.sampled_at_unix_ms <= now_ms; + if !keep { + future_entry_count = + future_entry_count.saturating_add(snapshot.retained_entry_count()); + future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); + } + keep + }); + self.retained_entry_count = self.retained_entry_count.saturating_sub(future_entry_count); + self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); + + while self.snapshots.front().is_some_and(|snapshot| { + snapshot.sampled_at_unix_ms < now_ms.saturating_sub(HISTORY_RETENTION_MS) + || self.snapshots.len() > max_snapshots + || self.retained_entry_count > max_retained_entries + || self.retained_bytes > max_retained_bytes + }) { + if let Some(removed) = self.snapshots.pop_front() { + self.retained_entry_count = self + .retained_entry_count + .saturating_sub(removed.retained_entry_count()); + self.retained_bytes = self + .retained_bytes + .saturating_sub(removed.estimated_history_bytes()); + } + } + } + + fn read(&self, window_ms: u64, now_ms: u64) -> Vec { + let started_at_ms = now_ms.saturating_sub(window_ms.min(HISTORY_RETENTION_MS)); + self.snapshots + .iter() + .filter(|snapshot| { + snapshot.sampled_at_unix_ms >= started_at_ms + && snapshot.sampled_at_unix_ms <= now_ms + }) + .cloned() + .collect() + } +} + +struct Collector { + system: System, + sequence: u64, + cpu_baseline_refreshed_at: Option, +} + +impl Collector { + fn new() -> Self { + Self { + system: System::new(), + sequence: 0, + cpu_baseline_refreshed_at: None, + } + } + + fn prime_cpu_usage(&mut self) { + self.system.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + process_refresh_kind(), + ); + self.cpu_baseline_refreshed_at = Some(Instant::now()); + } + + fn sample(&mut self, config: &CollectorConfig, request_id: Option) -> SnapshotEvent { + if let Some(delay) = + remaining_cpu_measurement_delay(self.cpu_baseline_refreshed_at.take(), Instant::now()) + { + thread::sleep(delay); + } + let collection_started = Instant::now(); + self.system.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + process_refresh_kind(), + ); + self.cpu_baseline_refreshed_at = Some(Instant::now()); + + let rows = self + .system + .processes() + .iter() + .map(|(pid, process)| { + let pid = pid.as_u32(); + let ppid = process.parent().map(Pid::as_u32).unwrap_or(0); + (pid, ppid, process.start_time().saturating_mul(1_000)) + }) + .collect::>(); + let external_processes = config + .external_processes + .iter() + .filter_map(|(pid, expected_start_time_ms)| { + let (_, _, actual_start_time_ms) = rows + .iter() + .find(|(candidate_pid, _, _)| candidate_pid == pid)?; + matches_external_identity(*actual_start_time_ms, *expected_start_time_ms).then_some( + ExternalProcess { + pid: *pid, + start_time_ms: Some(*actual_start_time_ms), + }, + ) + }) + .collect::>(); + let mut roots = external_processes + .iter() + .map(|process| process.pid) + .collect::>(); + roots.insert(config.root_pid); + let tracked = select_tracked_pids(&rows, &roots); + let tracked_process_count = tracked.len(); + let mut processes = tracked + .into_iter() + .filter_map(|pid| { + let process = self.system.process(Pid::from_u32(pid))?; + let disk_usage = process.disk_usage(); + let command = if process.cmd().is_empty() { + process.name().to_string_lossy().into_owned() + } else { + process + .cmd() + .iter() + .map(|part| part.to_string_lossy()) + .collect::>() + .join(" ") + }; + + Some(ProcessSample { + pid, + ppid: process.parent().map(Pid::as_u32).unwrap_or(0), + start_time_ms: process.start_time().saturating_mul(1_000), + run_time_ms: process.run_time().saturating_mul(1_000), + name: truncate_utf8( + process.name().to_string_lossy().into_owned(), + MAX_PROCESS_NAME_BYTES, + ), + command: truncate_utf8(command, MAX_PROCESS_COMMAND_BYTES), + status: truncate_utf8( + format!("{:?}", process.status()), + MAX_PROCESS_STATUS_BYTES, + ), + cpu_percent: process.cpu_usage(), + cpu_time_ms: process.accumulated_cpu_time(), + resident_bytes: process.memory(), + virtual_bytes: process.virtual_memory(), + io_read_bytes: disk_usage.total_read_bytes, + io_write_bytes: disk_usage.total_written_bytes, + io_semantics: io_semantics(), + }) + }) + .collect::>(); + processes.sort_by_key(|process| process.pid); + self.sequence = self.sequence.saturating_add(1); + + SnapshotEvent { + version: PROTOCOL_VERSION, + event_type: "snapshot", + sequence: self.sequence, + sampled_at_unix_ms: unix_time_ms(), + collection_duration_micros: collection_started.elapsed().as_micros() as u64, + scanned_process_count: self.system.processes().len(), + retained_process_count: processes.len(), + inaccessible_process_count: inaccessible_process_count( + tracked_process_count, + processes.len(), + ), + request_id, + external_processes, + processes, + } + } +} + +fn process_refresh_kind() -> ProcessRefreshKind { + ProcessRefreshKind::nothing() + .with_memory() + .with_cpu() + .with_disk_usage() + .with_cmd(UpdateKind::Always) + .without_tasks() +} + +fn inaccessible_process_count(selected: usize, materialized: usize) -> usize { + selected.saturating_sub(materialized) +} + +fn remaining_cpu_measurement_delay( + baseline_refreshed_at: Option, + now: Instant, +) -> Option { + baseline_refreshed_at + .and_then(|baseline| MINIMUM_CPU_UPDATE_INTERVAL.checked_sub(now.duration_since(baseline))) + .filter(|delay| !delay.is_zero()) +} + +fn matches_external_identity( + actual_start_time_ms: u64, + expected_start_time_ms: Option, +) -> bool { + // sysinfo reports process starts at whole-second precision. Normalize the + // higher-resolution Electron timestamp to that same bucket instead of + // accepting adjacent seconds, which could attach a quickly reused PID. + expected_start_time_ms.is_none_or(|expected| { + actual_start_time_ms == expected - (expected % PROCESS_START_TIME_PRECISION_MS) + }) +} + +fn select_tracked_pids(rows: &[(u32, u32, u64)], roots: &HashSet) -> HashSet { + let mut children_by_parent = HashMap::>::new(); + let mut start_time_by_pid = HashMap::::new(); + for (pid, ppid, start_time_ms) in rows { + children_by_parent + .entry(*ppid) + .or_default() + .push((*pid, *start_time_ms)); + start_time_by_pid.insert(*pid, *start_time_ms); + } + + let mut tracked = HashSet::new(); + let mut visited_identities = HashSet::new(); + let mut queue = roots + .iter() + .filter_map(|pid| { + start_time_by_pid + .get(pid) + .map(|start_time_ms| (*pid, *start_time_ms)) + }) + .collect::>(); + + while let Some((pid, start_time_ms)) = queue.pop_front() { + if !visited_identities.insert((pid, start_time_ms)) { + continue; + } + tracked.insert(pid); + if let Some(children) = children_by_parent.get(&pid) { + queue.extend( + children + .iter() + .copied() + .filter(|(_, child_start_time_ms)| *child_start_time_ms >= start_time_ms), + ); + } + } + + tracked +} + +fn truncate_utf8(mut value: String, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value; + } + let mut boundary = max_bytes; + while !value.is_char_boundary(boundary) { + boundary = boundary.saturating_sub(1); + } + value.truncate(boundary); + value +} + +fn io_semantics() -> IoSemantics { + if cfg!(target_os = "windows") { + IoSemantics::AllIo + } else { + IoSemantics::Storage + } +} + +fn unix_time_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn clamp_sample_interval(sample_interval_ms: u64) -> Option { + (sample_interval_ms > 0).then(|| { + Duration::from_millis( + sample_interval_ms.clamp(MIN_SAMPLE_INTERVAL_MS, MAX_SAMPLE_INTERVAL_MS), + ) + }) +} + +fn spawn_input_reader() -> Receiver { + let (sender, receiver) = mpsc::sync_channel(INPUT_QUEUE_CAPACITY); + thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(error) => { + let _ = sender.send(Input::Invalid(format!( + "failed reading command stream: {error}" + ))); + return; + } + }; + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(command) => { + if sender.send(Input::Command(command)).is_err() { + return; + } + } + Err(error) => { + if sender + .send(Input::Invalid(format!("invalid command: {error}"))) + .is_err() + { + return; + } + } + } + } + }); + receiver +} + +fn sample_now_deadline( + current: Option, + interval: Option, + now: Instant, +) -> Option { + current.or_else(|| interval.map(|duration| now + duration)) +} + +fn write_event(writer: &mut impl Write, event: &T) -> io::Result<()> { + serde_json::to_writer(&mut *writer, event)?; + writer.write_all(b"\n")?; + writer.flush() +} + +fn write_error( + writer: &mut impl Write, + code: &'static str, + message: impl Into, + recoverable: bool, +) -> io::Result<()> { + write_event( + writer, + &ErrorEvent { + version: PROTOCOL_VERSION, + event_type: "error", + code, + message: message.into(), + recoverable, + }, + ) +} + +fn write_history( + writer: &mut impl Write, + request_id: &str, + snapshots: &[SnapshotEvent], +) -> io::Result<()> { + if snapshots.is_empty() { + return write_event( + writer, + &HistoryChunkEvent { + version: PROTOCOL_VERSION, + event_type: "historyChunk", + request_id, + done: true, + snapshots, + }, + ); + } + + let chunk_count = snapshots.len().div_ceil(HISTORY_CHUNK_SNAPSHOTS); + for (index, chunk) in snapshots.chunks(HISTORY_CHUNK_SNAPSHOTS).enumerate() { + write_event( + writer, + &HistoryChunkEvent { + version: PROTOCOL_VERSION, + event_type: "historyChunk", + request_id, + done: index + 1 == chunk_count, + snapshots: chunk, + }, + )?; + } + Ok(()) +} + +fn main() -> io::Result<()> { + let mut writer = BufWriter::new(io::stdout().lock()); + write_event( + &mut writer, + &HelloEvent { + version: PROTOCOL_VERSION, + event_type: "hello", + sidecar_version: env!("CARGO_PKG_VERSION"), + sidecar_pid: std::process::id(), + platform: std::env::consts::OS, + arch: std::env::consts::ARCH, + capabilities: Capabilities { + cumulative_cpu_time: true, + current_cpu_percent: true, + resident_memory: true, + virtual_memory: true, + io_bytes: true, + process_start_time: true, + process_tree: true, + }, + }, + )?; + + let receiver = spawn_input_reader(); + let mut collector = Collector::new(); + let mut history = HistoryRecorder::default(); + let mut config: Option = None; + let mut next_sample_at: Option = None; + let mut streaming_enabled = false; + + loop { + if next_sample_at.is_some_and(|deadline| deadline <= Instant::now()) { + if let Some(current) = config.as_ref() { + if let Some(interval) = current.sample_interval { + let event = collector.sample(current, None); + history.record(&event); + if streaming_enabled { + write_event(&mut writer, &event)?; + } + next_sample_at = Some(Instant::now() + interval); + } else { + next_sample_at = None; + } + } else { + next_sample_at = None; + } + continue; + } + + let timeout = next_sample_at + .map(|deadline| deadline.saturating_duration_since(Instant::now())) + .unwrap_or(Duration::from_secs(60)); + + match receiver.recv_timeout(timeout) { + Ok(Input::Invalid(message)) => { + write_error(&mut writer, "invalid-command", message, true)?; + } + Ok(Input::Command(command)) => { + if command.version() != PROTOCOL_VERSION { + write_error( + &mut writer, + "protocol-mismatch", + format!( + "unsupported protocol version {}; expected {PROTOCOL_VERSION}", + command.version() + ), + false, + )?; + continue; + } + + match command { + Command::Configure { + root_pid, + sample_interval_ms, + external_processes, + .. + } => { + let sample_interval = clamp_sample_interval(sample_interval_ms); + config = Some(CollectorConfig { + root_pid, + sample_interval, + external_processes: external_processes + .into_iter() + .map(|process| (process.pid, process.start_time_ms)) + .collect(), + }); + collector.prime_cpu_usage(); + next_sample_at = sample_interval.map(|_| Instant::now()); + } + Command::SetExternalProcesses { processes, .. } => { + if let Some(current) = config.as_mut() { + current.external_processes = processes + .into_iter() + .map(|process| (process.pid, process.start_time_ms)) + .collect(); + } else { + write_error( + &mut writer, + "not-configured", + "configure must be sent before external processes", + true, + )?; + } + } + Command::SetSampleInterval { + sample_interval_ms, .. + } => { + if let Some(current) = config.as_mut() { + current.sample_interval = clamp_sample_interval(sample_interval_ms); + next_sample_at = current + .sample_interval + .map(|interval| Instant::now() + interval); + } else { + write_error( + &mut writer, + "not-configured", + "configure must be sent before changing the sample interval", + true, + )?; + } + } + Command::SetStreaming { enabled, .. } => { + streaming_enabled = enabled; + } + Command::SampleNow { request_id, .. } => { + if let Some(current) = config.as_ref() { + let event = collector.sample(current, Some(request_id)); + history.record(&event); + write_event(&mut writer, &event)?; + next_sample_at = sample_now_deadline( + next_sample_at, + current.sample_interval, + Instant::now(), + ); + } else { + write_error( + &mut writer, + "not-configured", + "configure must be sent before sampling", + true, + )?; + } + } + Command::ReadHistory { + request_id, + window_ms, + .. + } => { + if config.is_some() { + let snapshots = history.read(window_ms, unix_time_ms()); + write_history(&mut writer, &request_id, &snapshots)?; + } else { + write_error( + &mut writer, + "not-configured", + "configure must be sent before reading history", + true, + )?; + } + } + Command::Shutdown { .. } => return Ok(()), + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_roots_and_all_descendants() { + let rows = vec![ + (10, 1, 1_000), + (11, 10, 1_100), + (12, 11, 1_200), + (20, 1, 2_000), + (21, 20, 2_100), + (30, 99, 3_000), + ]; + let tracked = select_tracked_pids(&rows, &HashSet::from([10, 20])); + + assert_eq!(tracked, HashSet::from([10, 11, 12, 20, 21])); + } + + #[test] + fn rejects_descendants_older_than_a_reused_parent_pid() { + let rows = vec![ + (20, 1, 5_000), + (21, 20, 4_000), + (22, 20, 5_100), + (23, 21, 5_200), + ]; + let tracked = select_tracked_pids(&rows, &HashSet::from([20])); + + assert_eq!(tracked, HashSet::from([20, 22])); + } + + #[test] + fn ignores_missing_roots() { + let rows = vec![(10, 1, 1_000), (11, 10, 1_100)]; + let tracked = select_tracked_pids(&rows, &HashSet::from([99])); + + assert!(tracked.is_empty()); + } + + #[test] + fn validates_external_process_start_identity() { + assert!(matches_external_identity(10_000, None)); + assert!(matches_external_identity(10_000, Some(10_999))); + assert!(!matches_external_identity(10_000, Some(11_000))); + assert!(!matches_external_identity(10_000, Some(9_999))); + } + + #[test] + fn decodes_protocol_commands() { + let configure = serde_json::from_str::( + r#"{"version":2,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#, + ) + .expect("configure command"); + + match configure { + Command::Configure { + root_pid, + sample_interval_ms, + external_processes, + .. + } => { + assert_eq!(root_pid, 42); + assert_eq!(sample_interval_ms, 1_000); + assert_eq!(external_processes[0].pid, 7); + assert_eq!(external_processes[0].start_time_ms, None); + } + _ => panic!("unexpected command"), + } + + let read_history = serde_json::from_str::( + r#"{"version":2,"type":"readHistory","requestId":"history-1","windowMs":60000}"#, + ) + .expect("read history command"); + assert!(matches!( + read_history, + Command::ReadHistory { + request_id, + window_ms: 60_000, + .. + } if request_id == "history-1" + )); + } + + #[test] + fn clamps_sample_interval() { + assert_eq!(clamp_sample_interval(0), None); + assert_eq!(clamp_sample_interval(1), Some(Duration::from_millis(250))); + assert_eq!( + clamp_sample_interval(100_000), + Some(Duration::from_millis(60_000)) + ); + } + + #[test] + fn counts_selected_processes_that_could_not_be_materialized() { + assert_eq!(inaccessible_process_count(5, 3), 2); + assert_eq!(inaccessible_process_count(3, 5), 0); + } + + #[test] + fn waits_for_a_cpu_measurement_window_after_priming() { + let baseline = Instant::now(); + + assert_eq!( + remaining_cpu_measurement_delay(Some(baseline), baseline), + Some(MINIMUM_CPU_UPDATE_INTERVAL) + ); + assert_eq!( + remaining_cpu_measurement_delay(Some(baseline), baseline + MINIMUM_CPU_UPDATE_INTERVAL), + None + ); + assert_eq!(remaining_cpu_measurement_delay(None, baseline), None); + } + + #[test] + fn retains_bounded_history_without_request_ids() { + let mut history = HistoryRecorder::default(); + for sequence in 0..=MAX_HISTORY_SNAPSHOTS { + history.record(&SnapshotEvent { + version: PROTOCOL_VERSION, + event_type: "snapshot", + sequence: sequence as u64, + sampled_at_unix_ms: sequence as u64 * 1_000, + collection_duration_micros: 1, + scanned_process_count: 0, + retained_process_count: 0, + inaccessible_process_count: 0, + request_id: Some("request".to_owned()), + external_processes: vec![ExternalProcess { + pid: 7, + start_time_ms: Some(1_000), + }], + processes: Vec::new(), + }); + } + + assert_eq!(history.snapshots.len(), MAX_HISTORY_SNAPSHOTS); + assert!( + history + .snapshots + .iter() + .all(|snapshot| snapshot.request_id.is_none()) + ); + assert!(history.snapshots.iter().all(|snapshot| { + snapshot.external_processes.len() == 1 + && snapshot.external_processes[0].pid == 7 + && snapshot.external_processes[0].start_time_ms == Some(1_000) + })); + assert_eq!( + history + .read(10_000, MAX_HISTORY_SNAPSHOTS as u64 * 1_000) + .len(), + 11 + ); + } + + #[test] + fn excludes_and_trims_future_history_after_the_clock_moves_backward() { + let mut history = HistoryRecorder::default(); + let snapshot = SnapshotEvent { + version: PROTOCOL_VERSION, + event_type: "snapshot", + sequence: 1, + sampled_at_unix_ms: 2_000, + collection_duration_micros: 1, + scanned_process_count: 0, + retained_process_count: 0, + inaccessible_process_count: 0, + request_id: None, + external_processes: Vec::new(), + processes: Vec::new(), + }; + history.record(&snapshot); + + assert!(history.read(0, 1_000).is_empty()); + + history.record(&SnapshotEvent { + sequence: 2, + sampled_at_unix_ms: 1_000, + ..snapshot + }); + assert_eq!(history.snapshots.len(), 1); + assert_eq!( + history.snapshots.front().map(|entry| entry.sequence), + Some(2) + ); + } + + #[test] + fn bounds_history_by_estimated_process_bytes() { + let mut history = HistoryRecorder::default(); + let command = "x".repeat(128); + let process = ProcessSample { + pid: 1, + ppid: 0, + start_time_ms: 0, + run_time_ms: 0, + name: "process".to_owned(), + command, + status: "Run".to_owned(), + cpu_percent: 0.0, + cpu_time_ms: 0, + resident_bytes: 0, + virtual_bytes: 0, + io_read_bytes: 0, + io_write_bytes: 0, + io_semantics: IoSemantics::Storage, + }; + let snapshot_bytes = + std::mem::size_of::() + process.estimated_history_bytes(); + for sequence in 0..3 { + history.record_with_limits( + &SnapshotEvent { + version: PROTOCOL_VERSION, + event_type: "snapshot", + sequence, + sampled_at_unix_ms: sequence * 1_000, + collection_duration_micros: 1, + scanned_process_count: 1, + retained_process_count: 1, + inaccessible_process_count: 0, + request_id: None, + external_processes: Vec::new(), + processes: vec![ProcessSample { + pid: sequence as u32 + 1, + start_time_ms: sequence * 1_000, + ..process.clone() + }], + }, + 3, + 3, + snapshot_bytes * 2, + ); + } + + assert!(history.retained_bytes <= snapshot_bytes * 2); + assert_eq!(history.snapshots.len(), 2); + assert_eq!( + history.snapshots.front().map(|snapshot| snapshot.sequence), + Some(1) + ); + } + + #[test] + fn counts_external_processes_toward_history_limits() { + let mut history = HistoryRecorder::default(); + let external_processes = (1..=128) + .map(|pid| ExternalProcess { + pid, + start_time_ms: Some(u64::from(pid) * 1_000), + }) + .collect::>(); + let snapshot = SnapshotEvent { + version: PROTOCOL_VERSION, + event_type: "snapshot", + sequence: 0, + sampled_at_unix_ms: 0, + collection_duration_micros: 1, + scanned_process_count: 0, + retained_process_count: 0, + inaccessible_process_count: 0, + request_id: None, + external_processes, + processes: Vec::new(), + }; + let snapshot_bytes = snapshot.estimated_history_bytes(); + let snapshot_entries = snapshot.retained_entry_count(); + + for sequence in 0..3 { + history.record_with_limits( + &SnapshotEvent { + sequence, + sampled_at_unix_ms: sequence * 1_000, + ..snapshot.clone() + }, + 3, + snapshot_entries * 2, + snapshot_bytes * 2, + ); + } + + assert_eq!(history.retained_entry_count, snapshot_entries * 2); + assert!(history.retained_bytes <= snapshot_bytes * 2); + assert_eq!(history.snapshots.len(), 2); + assert_eq!( + history.snapshots.front().map(|snapshot| snapshot.sequence), + Some(1) + ); + } + + #[test] + fn truncates_process_strings_at_utf8_boundaries() { + let value = "é".repeat(MAX_PROCESS_NAME_BYTES); + let truncated = truncate_utf8(value, MAX_PROCESS_NAME_BYTES - 1); + + assert!(truncated.len() < MAX_PROCESS_NAME_BYTES); + assert!(truncated.is_char_boundary(truncated.len())); + } + + #[test] + fn refreshes_commands_without_enumerating_linux_tasks() { + let refresh_kind = process_refresh_kind(); + + assert_eq!(refresh_kind.cmd(), UpdateKind::Always); + assert!(!refresh_kind.tasks()); + assert!(refresh_kind.cpu()); + assert!(refresh_kind.memory()); + assert!(refresh_kind.disk_usage()); + } + + #[test] + fn sample_now_does_not_postpone_an_existing_periodic_deadline() { + let now = Instant::now(); + let deadline = now + Duration::from_secs(1); + + assert_eq!( + sample_now_deadline( + Some(deadline), + Some(Duration::from_secs(5)), + now + Duration::from_millis(100) + ), + Some(deadline) + ); + } +} diff --git a/package.json b/package.json index 960f2ed6000b..2146f35b8034 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "sync:upstream-prs": "node scripts/sync-upstream-pr-tracks.mjs", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", + "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", "typecheck": "vp run -r --concurrency-limit 2 typecheck", "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", + "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", "fmt": "vp fmt", "fmt:check": "vp fmt --check", diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c7c928b3c954..8013a1358967 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers + | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.terminalAttach; @@ -62,6 +63,23 @@ export type EnvironmentStreamRpcTag = | EnvironmentStreamCommandRpcTag; export type EnvironmentUnaryRpcTag = Exclude; + +export interface EnvironmentRpcSubscriptionObservation { + readonly environmentId: string; + readonly method: EnvironmentSubscriptionRpcTag; + readonly input: unknown; +} + +export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ + readonly observe: ( + subscription: EnvironmentRpcSubscriptionObservation, + ) => Effect.Effect>; +}>("@t3tools/client-runtime/rpc/EnvironmentRpcSubscriptionObserver", { + defaultValue: () => ({ + observe: () => Effect.succeed(Effect.void), + }), +}) {} + const isRpcClientError = Schema.is(RpcClientError.RpcClientError); export type EnvironmentRpcInput = Parameters>[0]; @@ -166,93 +184,94 @@ export function subscribeDynamic( EnvironmentSupervisor > { return Stream.unwrap( - EnvironmentSupervisor.pipe( - Effect.map((supervisor) => { - const sessionChanges = SubscriptionRef.changes(supervisor.session); - const sessions = - options?.resubscribe === undefined - ? sessionChanges - : Stream.merge( - sessionChanges, - options.resubscribe.pipe( - Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), - ), - ); - return sessions.pipe( - Stream.switchMap( - Option.match({ - onNone: () => Stream.empty, - onSome: (session) => { - const method = session.client[tag] as ( - input: EnvironmentRpcInput, - ) => Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure - >; - const subscribeToSession = (): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure - > => - Stream.suspend(() => - Stream.unwrap( - makeInput(session).pipe( - Effect.map((input) => - method(input).pipe( - Stream.catchCause((cause) => { - const hasOnlyExpectedFailures = - cause.reasons.length > 0 && - cause.reasons.every((reason) => reason._tag === "Fail"); - const isTransportFailure = - hasOnlyExpectedFailures && - cause.reasons.every( - (reason) => - reason._tag === "Fail" && isRpcClientError(reason.error), - ); - if (isTransportFailure) { - return Stream.fromEffect( - Effect.logWarning( - "Durable RPC subscription lost its transport; waiting for the next session.", - { - cause: Cause.pretty(cause), - method: tag, - environmentId: supervisor.target.environmentId, - }, - ), - ).pipe(Stream.drain); - } - if ( - hasOnlyExpectedFailures && - options?.onExpectedFailure !== undefined - ) { - const handled = Stream.fromEffect( - options.onExpectedFailure(cause), - ).pipe(Stream.drain); - if (options.retryExpectedFailureAfter === undefined) { - return handled; - } - return handled.pipe( - Stream.concat( - Stream.fromEffect( - Effect.sleep(options.retryExpectedFailureAfter), - ).pipe(Stream.drain), - ), - Stream.concat(subscribeToSession()), - ); - } - return Stream.failCause(cause); - }), - ), - ), - ), - ), - ); - return subscribeToSession(); - }, - }), - ), - ); - }), - ), + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor; + const observer = yield* EnvironmentRpcSubscriptionObserver; + const sessionChanges = SubscriptionRef.changes(supervisor.session); + const sessions = + options?.resubscribe === undefined + ? sessionChanges + : Stream.merge( + sessionChanges, + options.resubscribe.pipe( + Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), + ), + ); + return sessions.pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (session) => { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure + >; + const subscribeToSession = (): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure + > => + Stream.suspend(() => + Stream.unwrap( + Effect.gen(function* () { + const input = yield* makeInput(session); + const completeObservation = yield* observer.observe({ + environmentId: supervisor.target.environmentId, + method: tag, + input, + }); + return method(input).pipe( + Stream.ensuring(completeObservation), + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { + const handled = Stream.fromEffect( + options.onExpectedFailure(cause), + ).pipe(Stream.drain); + if (options.retryExpectedFailureAfter === undefined) { + return handled; + } + return handled.pipe( + Stream.concat( + Stream.fromEffect( + Effect.sleep(options.retryExpectedFailureAfter), + ).pipe(Stream.drain), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), + ); + }), + ), + ); + return subscribeToSession(); + }, + }), + ), + ); + }), ).pipe( Stream.withSpan("EnvironmentRpc.subscribe", { attributes: { "rpc.method": tag }, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ea7f5fb6d755..5f93a3edb6e7 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -298,6 +298,16 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:resource-telemetry", + tag: WS_METHODS.subscribeResourceTelemetry, + idleTtlMs: 0, + }), + resourceTelemetryHistory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:resource-telemetry-history", + tag: WS_METHODS.serverGetResourceTelemetryHistory, + staleTimeMs: 5_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", @@ -349,5 +359,13 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, }), + retryResourceTelemetry: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:retry-resource-telemetry", + tag: WS_METHODS.serverRetryResourceTelemetry, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), }; } diff --git a/packages/contracts/src/background.test.ts b/packages/contracts/src/background.test.ts new file mode 100644 index 000000000000..fcb772098f86 --- /dev/null +++ b/packages/contracts/src/background.test.ts @@ -0,0 +1,18 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { ClientActivityClientId } from "./background.ts"; + +const decodeClientActivityClientId = Schema.decodeUnknownSync(ClientActivityClientId); + +describe("ClientActivityClientId", () => { + it("trims and accepts bounded client identifiers", () => { + expect(decodeClientActivityClientId(" client-1 ")).toBe("client-1"); + expect(decodeClientActivityClientId("x".repeat(128))).toBe("x".repeat(128)); + }); + + it("rejects empty and oversized client identifiers", () => { + expect(() => decodeClientActivityClientId(" ")).toThrow(); + expect(() => decodeClientActivityClientId("x".repeat(129))).toThrow(); + }); +}); diff --git a/packages/contracts/src/background.ts b/packages/contracts/src/background.ts new file mode 100644 index 000000000000..3042b4b19d9f --- /dev/null +++ b/packages/contracts/src/background.ts @@ -0,0 +1,110 @@ +import * as Schema from "effect/Schema"; + +import { + AuthSessionId, + EnvironmentId, + RpcClientId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +export const BackgroundBooleanState = Schema.Literals(["true", "false", "unknown"]); +export type BackgroundBooleanState = typeof BackgroundBooleanState.Type; + +export const HostPowerThermalState = Schema.Literals([ + "unknown", + "nominal", + "fair", + "serious", + "critical", +]); +export type HostPowerThermalState = typeof HostPowerThermalState.Type; + +export const HostPowerSource = Schema.Literals([ + "unknown", + "node-macos-shell", + "node-macos-native", + "node-linux", + "node-windows", + "electron-main", +]); +export type HostPowerSource = typeof HostPowerSource.Type; + +export const HostPowerSnapshot = Schema.Struct({ + source: HostPowerSource, + idle: BackgroundBooleanState, + idleSeconds: Schema.NullOr(Schema.Number), + locked: BackgroundBooleanState, + suspended: Schema.Boolean, + onBattery: BackgroundBooleanState, + lowPowerMode: BackgroundBooleanState, + thermalState: HostPowerThermalState, + stale: Schema.Boolean, + updatedAt: Schema.DateTimeUtc, +}); +export type HostPowerSnapshot = typeof HostPowerSnapshot.Type; + +export const BackgroundScope = Schema.Union([ + Schema.Struct({ type: Schema.Literal("server-config") }), + Schema.Struct({ + type: Schema.Literal("provider-status"), + instanceId: Schema.optionalKey(ProviderInstanceId), + }), + Schema.Struct({ type: Schema.Literal("vcs-status"), cwd: Schema.String }), + Schema.Struct({ type: Schema.Literal("git-refs"), cwd: Schema.String }), + Schema.Struct({ type: Schema.Literal("diagnostics") }), + Schema.Struct({ type: Schema.Literal("thread"), threadId: ThreadId }), +]); +export type BackgroundScope = typeof BackgroundScope.Type; + +export const ClientKind = Schema.Literals(["web", "desktop-renderer", "mobile", "unknown"]); +export type ClientKind = typeof ClientKind.Type; + +export const ClientActivityClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type ClientActivityClientId = typeof ClientActivityClientId.Type; + +export const ClientActivityReportInput = Schema.Struct({ + environmentId: Schema.optionalKey(EnvironmentId), + clientId: ClientActivityClientId, + clientKind: ClientKind, + visible: Schema.Boolean, + focused: Schema.Boolean, + recentlyInteracted: Schema.Boolean, + appState: Schema.optionalKey(Schema.Literals(["active", "inactive", "background", "unknown"])), + lowPowerMode: Schema.optionalKey(BackgroundBooleanState), + batteryState: Schema.optionalKey(Schema.Literals(["unknown", "unplugged", "charging", "full"])), + networkType: Schema.optionalKey(Schema.String), + scopes: Schema.Array(BackgroundScope), + ttlMs: Schema.optionalKey(Schema.Number), + observedAt: Schema.DateTimeUtc, +}); +export type ClientActivityReportInput = typeof ClientActivityReportInput.Type; + +export const ClientActivityLease = Schema.Struct({ + sessionId: AuthSessionId, + rpcClientId: RpcClientId, + clientId: ClientActivityClientId, + clientKind: ClientKind, + visible: Schema.Boolean, + focused: Schema.Boolean, + recentlyInteracted: Schema.Boolean, + appState: Schema.optionalKey(Schema.Literals(["active", "inactive", "background", "unknown"])), + lowPowerMode: Schema.optionalKey(BackgroundBooleanState), + batteryState: Schema.optionalKey(Schema.Literals(["unknown", "unplugged", "charging", "full"])), + networkType: Schema.optionalKey(Schema.String), + scopes: Schema.Array(BackgroundScope), + updatedAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtc, +}); +export type ClientActivityLease = typeof ClientActivityLease.Type; + +export const BackgroundPolicySnapshot = Schema.Struct({ + hostPower: HostPowerSnapshot, + leases: Schema.Array(ClientActivityLease), + activeForegroundLeaseCount: Schema.Number, + activeScopeKeys: Schema.Array(Schema.String), + shouldRunOpportunisticWork: Schema.Boolean, + updatedAt: Schema.DateTimeUtc, +}); +export type BackgroundPolicySnapshot = typeof BackgroundPolicySnapshot.Type; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 614ea5131fbc..a8fa565cef43 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -43,6 +43,8 @@ export const TurnId = makeEntityId("TurnId"); export type TurnId = typeof TurnId.Type; export const AuthSessionId = makeEntityId("AuthSessionId"); export type AuthSessionId = typeof AuthSessionId.Type; +export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); +export type RpcClientId = typeof RpcClientId.Type; export const ProviderItemId = makeEntityId("ProviderItemId"); export type ProviderItemId = typeof ProviderItemId.Type; diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index dc492636c44f..80db97048d34 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { PortSchema } from "./baseSchemas.ts"; +import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DesktopBackendBootstrap = Schema.Struct({ mode: Schema.Literal("desktop"), @@ -17,6 +17,9 @@ export const DesktopBackendBootstrap = Schema.Struct({ otlpTracesUrl: Schema.optional(Schema.String), otlpMetricsUrl: Schema.optional(Schema.String), processEnv: Schema.optional(Schema.Record(Schema.String, Schema.String)), + desktopTelemetryFd: Schema.optionalKey(PositiveInt), + desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), + resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), }); export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5936cb0fc17c..f0ee1889177f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas.ts"; +export * from "./background.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; @@ -26,4 +27,5 @@ export * from "./assets.ts"; export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; +export * from "./resourceTelemetry.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d8c490e154b4..3eba4bba8e5d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -31,20 +31,6 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project.ts"; -import type { ProviderInstanceId } from "./providerInstance.ts"; -import type { - ServerConfig, - ServerProcessDiagnosticsResult, - ServerProcessResourceHistoryInput, - ServerProcessResourceHistoryResult, - ServerProviderUpdateInput, - ServerProviderUpdatedPayload, - ServerRemoveKeybindingResult, - ServerSignalProcessInput, - ServerSignalProcessResult, - ServerTraceDiagnosticsResult, - ServerUpsertKeybindingResult, -} from "./server.ts"; import type { TerminalAttachInput, TerminalAttachStreamEvent, @@ -57,7 +43,6 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal.ts"; -import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; import * as Schema from "effect/Schema"; import type { DiscoveredLocalServerList, @@ -100,13 +85,11 @@ import type { import { EnvironmentId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; -import { EditorId } from "./editor.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; -import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import type { ClientSettings } from "./settings.ts"; import type { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, - SourceControlDiscoveryResult, SourceControlPublishRepositoryInput, SourceControlPublishRepositoryResult, SourceControlRepositoryInfo, @@ -1119,7 +1102,7 @@ export interface DesktopPreviewBridge { * APIs bound to the local app shell, not to any particular backend environment. * * These capabilities describe the desktop/browser host that the user is - * currently running: dialogs, editor/external-link opening, context menus, and + * currently running: dialogs, external-link opening, context menus, and * app-level settings/config access. They must not be used as a proxy for * "whatever environment the user is targeting", because in a multi-environment * world the local shell and a selected backend environment are distinct @@ -1131,7 +1114,6 @@ export interface LocalApi { confirm: (message: string) => Promise; }; shell: { - openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; }; contextMenu: { @@ -1144,29 +1126,6 @@ export interface LocalApi { getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; }; - server: { - getConfig: () => Promise; - /** - * Refresh provider snapshots. When `input.instanceId` is supplied only that - * configured instance is probed; otherwise every configured instance is - * refreshed (legacy untargeted refresh). - */ - refreshProviders: (input?: { - readonly instanceId?: ProviderInstanceId; - }) => Promise; - updateProvider: (input: ServerProviderUpdateInput) => Promise; - upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; - removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise; - getSettings: () => Promise; - updateSettings: (patch: ServerSettingsPatch) => Promise; - discoverSourceControl: () => Promise; - getTraceDiagnostics: () => Promise; - getProcessDiagnostics: () => Promise; - getProcessResourceHistory: ( - input: ServerProcessResourceHistoryInput, - ) => Promise; - signalProcess: (input: ServerSignalProcessInput) => Promise; - }; } /** diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts new file mode 100644 index 000000000000..2c1b2ffec6ef --- /dev/null +++ b/packages/contracts/src/resourceTelemetry.ts @@ -0,0 +1,430 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { HostPowerSnapshot } from "./background.ts"; + +export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; + +export const ResourceTelemetryIoSemantics = Schema.Literals([ + "storage", + "logical", + "all-io", + "unavailable", +]); +export type ResourceTelemetryIoSemantics = typeof ResourceTelemetryIoSemantics.Type; + +export const ResourceTelemetryProcessCategory = Schema.Literals([ + "server", + "server-child", + "provider-root", + "terminal-root", + "electron-main", + "electron-renderer", + "electron-gpu", + "electron-utility", + "resource-monitor", + "unknown-t3", +]); +export type ResourceTelemetryProcessCategory = typeof ResourceTelemetryProcessCategory.Type; + +export const ResourceTelemetrySourceStatus = Schema.Literals([ + "starting", + "healthy", + "degraded", + "unavailable", + "stopped", +]); +export type ResourceTelemetrySourceStatus = typeof ResourceTelemetrySourceStatus.Type; + +export const ResourceTelemetryProcessIdentity = Schema.Struct({ + pid: PositiveInt, + startTimeMs: NonNegativeInt, +}); +export type ResourceTelemetryProcessIdentity = typeof ResourceTelemetryProcessIdentity.Type; + +export const ResourceMonitorExternalProcess = Schema.Struct({ + pid: PositiveInt, + startTimeMs: Schema.optionalKey(NonNegativeInt), +}); +export type ResourceMonitorExternalProcess = typeof ResourceMonitorExternalProcess.Type; + +export const ResourceMonitorCapabilities = Schema.Struct({ + cumulativeCpuTime: Schema.Boolean, + currentCpuPercent: Schema.Boolean, + residentMemory: Schema.Boolean, + virtualMemory: Schema.Boolean, + ioBytes: Schema.Boolean, + processStartTime: Schema.Boolean, + processTree: Schema.Boolean, +}); +export type ResourceMonitorCapabilities = typeof ResourceMonitorCapabilities.Type; + +export const ResourceMonitorProcessSample = Schema.Struct({ + pid: PositiveInt, + ppid: NonNegativeInt, + startTimeMs: NonNegativeInt, + runTimeMs: NonNegativeInt, + name: Schema.String, + command: Schema.String, + status: Schema.String, + cpuPercent: Schema.Number, + cpuTimeMs: NonNegativeInt, + residentBytes: NonNegativeInt, + virtualBytes: NonNegativeInt, + ioReadBytes: NonNegativeInt, + ioWriteBytes: NonNegativeInt, + ioSemantics: Schema.Literals(["storage", "all-io"]), +}); +export type ResourceMonitorProcessSample = typeof ResourceMonitorProcessSample.Type; + +export const ResourceMonitorConfigureCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("configure"), + rootPid: PositiveInt, + sampleIntervalMs: NonNegativeInt, + externalProcesses: Schema.Array(ResourceMonitorExternalProcess), +}); +export type ResourceMonitorConfigureCommand = typeof ResourceMonitorConfigureCommand.Type; + +export const ResourceMonitorSetExternalProcessesCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("setExternalProcesses"), + processes: Schema.Array(ResourceMonitorExternalProcess), +}); +export type ResourceMonitorSetExternalProcessesCommand = + typeof ResourceMonitorSetExternalProcessesCommand.Type; + +export const ResourceMonitorSampleNowCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("sampleNow"), + requestId: TrimmedNonEmptyString, +}); +export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type; + +export const ResourceMonitorSetSampleIntervalCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("setSampleInterval"), + sampleIntervalMs: NonNegativeInt, +}); +export type ResourceMonitorSetSampleIntervalCommand = + typeof ResourceMonitorSetSampleIntervalCommand.Type; + +export const ResourceMonitorSetStreamingCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("setStreaming"), + enabled: Schema.Boolean, +}); +export type ResourceMonitorSetStreamingCommand = typeof ResourceMonitorSetStreamingCommand.Type; + +export const ResourceMonitorReadHistoryCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("readHistory"), + requestId: TrimmedNonEmptyString, + windowMs: NonNegativeInt, +}); +export type ResourceMonitorReadHistoryCommand = typeof ResourceMonitorReadHistoryCommand.Type; + +export const ResourceMonitorShutdownCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("shutdown"), +}); +export type ResourceMonitorShutdownCommand = typeof ResourceMonitorShutdownCommand.Type; + +export const ResourceMonitorCommand = Schema.Union([ + ResourceMonitorConfigureCommand, + ResourceMonitorSetExternalProcessesCommand, + ResourceMonitorSetSampleIntervalCommand, + ResourceMonitorSetStreamingCommand, + ResourceMonitorSampleNowCommand, + ResourceMonitorReadHistoryCommand, + ResourceMonitorShutdownCommand, +]); +export type ResourceMonitorCommand = typeof ResourceMonitorCommand.Type; + +export const ResourceMonitorHelloEvent = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("hello"), + sidecarVersion: TrimmedNonEmptyString, + sidecarPid: PositiveInt, + platform: TrimmedNonEmptyString, + arch: TrimmedNonEmptyString, + capabilities: ResourceMonitorCapabilities, +}); +export type ResourceMonitorHelloEvent = typeof ResourceMonitorHelloEvent.Type; + +export const ResourceMonitorSnapshotEvent = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("snapshot"), + sequence: NonNegativeInt, + sampledAtUnixMs: NonNegativeInt, + collectionDurationMicros: NonNegativeInt, + scannedProcessCount: NonNegativeInt, + retainedProcessCount: NonNegativeInt, + inaccessibleProcessCount: NonNegativeInt, + requestId: Schema.optionalKey(TrimmedNonEmptyString), + externalProcesses: Schema.optionalKey(Schema.Array(ResourceMonitorExternalProcess)), + processes: Schema.Array(ResourceMonitorProcessSample), +}); +export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type; + +export const ResourceMonitorHistoryChunkEvent = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("historyChunk"), + requestId: TrimmedNonEmptyString, + done: Schema.Boolean, + snapshots: Schema.Array(ResourceMonitorSnapshotEvent), +}); +export type ResourceMonitorHistoryChunkEvent = typeof ResourceMonitorHistoryChunkEvent.Type; + +export const ResourceMonitorErrorEvent = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("error"), + code: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, + recoverable: Schema.Boolean, +}); +export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type; + +export const ResourceMonitorEvent = Schema.Union([ + ResourceMonitorHelloEvent, + ResourceMonitorSnapshotEvent, + ResourceMonitorHistoryChunkEvent, + ResourceMonitorErrorEvent, +]); +export type ResourceMonitorEvent = typeof ResourceMonitorEvent.Type; + +export const DesktopElectronProcessType = Schema.Literals([ + "Browser", + "Tab", + "Utility", + "Zygote", + "Sandbox helper", + "GPU", + "Pepper Plugin", + "Pepper Plugin Broker", + "Unknown", +]); +export type DesktopElectronProcessType = typeof DesktopElectronProcessType.Type; + +export const DesktopElectronProcessMetric = Schema.Struct({ + pid: PositiveInt, + creationTimeMs: NonNegativeInt, + type: DesktopElectronProcessType, + name: Schema.optionalKey(Schema.String), + serviceName: Schema.optionalKey(Schema.String), + cpuPercent: Schema.Number, + cumulativeCpuSeconds: Schema.optionalKey(Schema.Number), + idleWakeupsPerSecond: Schema.Number, + workingSetBytes: NonNegativeInt, + peakWorkingSetBytes: NonNegativeInt, +}); +export type DesktopElectronProcessMetric = typeof DesktopElectronProcessMetric.Type; + +const DesktopHostPowerSnapshot = Schema.Struct({ + ...HostPowerSnapshot.fields, + updatedAt: Schema.DateTimeUtcFromString, +}); + +export const DesktopHostTelemetrySnapshot = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("desktopTelemetry"), + sequence: NonNegativeInt, + sampledAtUnixMs: NonNegativeInt, + electronPid: PositiveInt, + power: DesktopHostPowerSnapshot, + speedLimitPercent: Schema.OptionFromNullOr(Schema.Number), + electronProcesses: Schema.Array(DesktopElectronProcessMetric), +}); +export type DesktopHostTelemetrySnapshot = typeof DesktopHostTelemetrySnapshot.Type; + +export const DesktopHostTelemetryHello = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("desktopTelemetryHello"), + electronPid: PositiveInt, +}); +export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type; + +export const DesktopHostTelemetryMessage = Schema.Union([ + DesktopHostTelemetryHello, + DesktopHostTelemetrySnapshot, +]); +export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type; + +export const DesktopTelemetrySetDiagnosticsDemand = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("setDiagnosticsDemand"), + enabled: Schema.Boolean, +}); +export type DesktopTelemetrySetDiagnosticsDemand = typeof DesktopTelemetrySetDiagnosticsDemand.Type; + +export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("setHostPowerIntervals"), + activeIntervalMs: PositiveInt, + idleIntervalMs: PositiveInt, +}); +export type DesktopTelemetrySetHostPowerIntervals = + typeof DesktopTelemetrySetHostPowerIntervals.Type; + +export const DesktopTelemetryControlMessage = Schema.Union([ + DesktopTelemetrySetDiagnosticsDemand, + DesktopTelemetrySetHostPowerIntervals, +]); +export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type; + +export const ResourceTelemetryProcess = Schema.Struct({ + identity: ResourceTelemetryProcessIdentity, + ppid: NonNegativeInt, + childPids: Schema.Array(PositiveInt), + depth: NonNegativeInt, + name: Schema.String, + command: Schema.String, + status: Schema.String, + category: ResourceTelemetryProcessCategory, + electronType: Schema.optionalKey(DesktopElectronProcessType), + electronServiceName: Schema.optionalKey(Schema.String), + cpuPercent: Schema.Number, + cpuTimeMs: NonNegativeInt, + residentBytes: NonNegativeInt, + peakResidentBytes: NonNegativeInt, + virtualBytes: NonNegativeInt, + ioReadBytes: NonNegativeInt, + ioWriteBytes: NonNegativeInt, + ioReadBytesPerSecond: Schema.Number, + ioWriteBytesPerSecond: Schema.Number, + ioSemantics: ResourceTelemetryIoSemantics, + idleWakeupsPerSecond: Schema.optionalKey(Schema.Number), + runTimeMs: NonNegativeInt, + firstSeenAt: Schema.DateTimeUtc, + lastSeenAt: Schema.DateTimeUtc, +}); +export type ResourceTelemetryProcess = typeof ResourceTelemetryProcess.Type; + +export const ResourceTelemetryAggregate = Schema.Struct({ + processCount: NonNegativeInt, + currentCpuPercent: Schema.Number, + cpuTimeMs: NonNegativeInt, + currentRssBytes: NonNegativeInt, + peakRssBytes: NonNegativeInt, + ioReadBytes: NonNegativeInt, + ioWriteBytes: NonNegativeInt, + ioReadBytesPerSecond: Schema.Number, + ioWriteBytesPerSecond: Schema.Number, + processStarts: NonNegativeInt, + processExits: NonNegativeInt, +}); +export type ResourceTelemetryAggregate = typeof ResourceTelemetryAggregate.Type; + +export const ResourceTelemetryGroups = Schema.Struct({ + backend: ResourceTelemetryAggregate, + electron: ResourceTelemetryAggregate, + monitor: ResourceTelemetryAggregate, + allT3: ResourceTelemetryAggregate, +}); +export type ResourceTelemetryGroups = typeof ResourceTelemetryGroups.Type; + +export const ResourceTelemetrySourceHealth = Schema.Struct({ + status: ResourceTelemetrySourceStatus, + lastSampleAt: Schema.Option(Schema.DateTimeUtc), + lastError: Schema.Option(TrimmedNonEmptyString), +}); +export type ResourceTelemetrySourceHealth = typeof ResourceTelemetrySourceHealth.Type; + +export const ResourceTelemetryHealth = Schema.Struct({ + native: ResourceTelemetrySourceHealth, + desktop: ResourceTelemetrySourceHealth, + sidecarVersion: Schema.Option(TrimmedNonEmptyString), + sidecarPid: Schema.Option(PositiveInt), + restartCount: NonNegativeInt, + collectionDurationMicros: NonNegativeInt, + scannedProcessCount: NonNegativeInt, + retainedProcessCount: NonNegativeInt, + inaccessibleProcessCount: NonNegativeInt, +}); +export type ResourceTelemetryHealth = typeof ResourceTelemetryHealth.Type; + +export const ResourceAttributionEntry = Schema.Struct({ + component: TrimmedNonEmptyString, + operation: TrimmedNonEmptyString, + logicalReadBytes: NonNegativeInt, + logicalWriteBytes: NonNegativeInt, + count: NonNegativeInt, + durationMs: NonNegativeInt, +}); +export type ResourceAttributionEntry = typeof ResourceAttributionEntry.Type; + +export const ResourceAttributionSnapshot = Schema.Struct({ + readAt: Schema.DateTimeUtc, + entries: Schema.Array(ResourceAttributionEntry), +}); +export type ResourceAttributionSnapshot = typeof ResourceAttributionSnapshot.Type; + +export const ResourceTelemetrySnapshot = Schema.Struct({ + readAt: Schema.DateTimeUtc, + sampleIntervalMs: NonNegativeInt, + processes: Schema.Array(ResourceTelemetryProcess), + groups: ResourceTelemetryGroups, + power: HostPowerSnapshot, + speedLimitPercent: Schema.Option(Schema.Number), + attribution: ResourceAttributionSnapshot, + health: ResourceTelemetryHealth, +}); +export type ResourceTelemetrySnapshot = typeof ResourceTelemetrySnapshot.Type; + +export const ResourceTelemetryHistoryInput = Schema.Struct({ + windowMs: NonNegativeInt, + bucketMs: NonNegativeInt, +}); +export type ResourceTelemetryHistoryInput = typeof ResourceTelemetryHistoryInput.Type; + +export const ResourceTelemetryHistoryBucket = Schema.Struct({ + startedAt: Schema.DateTimeUtc, + endedAt: Schema.DateTimeUtc, + avgCpuPercent: Schema.Number, + maxCpuPercent: Schema.Number, + maxRssBytes: NonNegativeInt, + ioReadBytes: NonNegativeInt, + ioWriteBytes: NonNegativeInt, + maxProcessCount: NonNegativeInt, +}); +export type ResourceTelemetryHistoryBucket = typeof ResourceTelemetryHistoryBucket.Type; + +export const ResourceTelemetryProcessSummary = Schema.Struct({ + identity: ResourceTelemetryProcessIdentity, + ppid: NonNegativeInt, + depth: NonNegativeInt, + name: Schema.String, + command: Schema.String, + category: ResourceTelemetryProcessCategory, + firstSeenAt: Schema.DateTimeUtc, + lastSeenAt: Schema.DateTimeUtc, + currentCpuPercent: Schema.Number, + avgCpuPercent: Schema.Number, + maxCpuPercent: Schema.Number, + cpuTimeMs: NonNegativeInt, + currentRssBytes: NonNegativeInt, + peakRssBytes: NonNegativeInt, + ioReadBytes: NonNegativeInt, + ioWriteBytes: NonNegativeInt, + ioSemantics: ResourceTelemetryIoSemantics, + sampleCount: NonNegativeInt, +}); +export type ResourceTelemetryProcessSummary = typeof ResourceTelemetryProcessSummary.Type; + +export const ResourceTelemetryHistory = Schema.Struct({ + readAt: Schema.DateTimeUtc, + windowMs: NonNegativeInt, + bucketMs: NonNegativeInt, + sampleIntervalMs: NonNegativeInt, + retainedSampleCount: NonNegativeInt, + buckets: Schema.Array(ResourceTelemetryHistoryBucket), + topProcesses: Schema.Array(ResourceTelemetryProcessSummary), + health: ResourceTelemetryHealth, +}); +export type ResourceTelemetryHistory = typeof ResourceTelemetryHistory.Type; + +export const ResourceTelemetryRetryResult = Schema.Struct({ + accepted: Schema.Boolean, + snapshot: ResourceTelemetrySnapshot, +}); +export type ResourceTelemetryRetryResult = typeof ResourceTelemetryRetryResult.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 43fc2d1acb06..0701e15a6689 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -8,6 +8,11 @@ import { AuthAccessStreamEvent, EnvironmentAuthorizationError, } from "./auth.ts"; +import { + BackgroundPolicySnapshot, + ClientActivityReportInput, + HostPowerSnapshot, +} from "./background.ts"; import { FilesystemBrowseInput, FilesystemBrowseResult, @@ -132,6 +137,12 @@ import { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, } from "./server.ts"; +import { + ResourceTelemetryHistory, + ResourceTelemetryHistoryInput, + ResourceTelemetryRetryResult, + ResourceTelemetrySnapshot, +} from "./resourceTelemetry.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { SourceControlCloneRepositoryInput, @@ -215,7 +226,12 @@ export const WS_METHODS = { serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetProcessResourceHistory: "server.getProcessResourceHistory", + serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", + serverRetryResourceTelemetry: "server.retryResourceTelemetry", serverSignalProcess: "server.signalProcess", + serverReportClientActivity: "server.reportClientActivity", + serverReportHostPowerState: "server.reportHostPowerState", + serverGetBackgroundPolicy: "server.getBackgroundPolicy", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -235,6 +251,8 @@ export const WS_METHODS = { subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", + subscribeBackgroundPolicy: "subscribeBackgroundPolicy", + subscribeResourceTelemetry: "subscribeResourceTelemetry", } as const; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -326,6 +344,21 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( }, ); +export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( + WS_METHODS.serverGetResourceTelemetryHistory, + { + payload: ResourceTelemetryHistoryInput, + success: ResourceTelemetryHistory, + error: EnvironmentAuthorizationError, + }, +); + +export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { + payload: Schema.Struct({}), + success: ResourceTelemetryRetryResult, + error: EnvironmentAuthorizationError, +}); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -345,6 +378,22 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela stream: true, }); +export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { + payload: ClientActivityReportInput, + error: EnvironmentAuthorizationError, +}); + +export const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { + payload: HostPowerSnapshot, + error: EnvironmentAuthorizationError, +}); + +export const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { + payload: Schema.Struct({}), + success: BackgroundPolicySnapshot, + error: EnvironmentAuthorizationError, +}); + export const WsSourceControlLookupRepositoryRpc = Rpc.make( WS_METHODS.sourceControlLookupRepository, { @@ -690,6 +739,20 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, stream: true, }); +export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { + payload: Schema.Struct({}), + success: BackgroundPolicySnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + +export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { + payload: Schema.Struct({}), + success: ResourceTelemetrySnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, @@ -704,7 +767,12 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, + WsServerGetResourceTelemetryHistoryRpc, + WsServerRetryResourceTelemetryRpc, WsServerSignalProcessRpc, + WsServerReportClientActivityRpc, + WsServerReportHostPowerStateRpc, + WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc, @@ -754,6 +822,8 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, + WsSubscribeBackgroundPolicyRpc, + WsSubscribeResourceTelemetryRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a8394..8e42c938ca48 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -303,6 +303,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type; export const ServerProcessDiagnosticsEntry = Schema.Struct({ pid: PositiveInt, + startTimeMs: NonNegativeInt, ppid: NonNegativeInt, pgid: Schema.Option(Schema.Int), status: TrimmedNonEmptyString, @@ -395,6 +396,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis export const ServerSignalProcessInput = Schema.Struct({ pid: PositiveInt, + startTimeMs: NonNegativeInt, signal: ServerProcessSignal, }); export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0c42faa1fcc1..45a520345e19 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -600,15 +600,66 @@ export const SourceControlWritingStyleSettings = Schema.Struct({ export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type; export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); +export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5); + +export const BackgroundActivityProfile = Schema.Literals([ + "balanced", + "performance", + "battery-saver", +]); +export type BackgroundActivityProfile = typeof BackgroundActivityProfile.Type; +export const DEFAULT_BACKGROUND_ACTIVITY_PROFILE: BackgroundActivityProfile = "balanced"; + +export const BackgroundActivityProfileSelection = Schema.Literals([ + "balanced", + "performance", + "battery-saver", + "custom", +]); +export type BackgroundActivityProfileSelection = typeof BackgroundActivityProfileSelection.Type; + +export const BackgroundActivityOverrides = Schema.Struct({ + automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), + providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), + hostPowerMonitorActiveInterval: Schema.optionalKey(Schema.DurationFromMillis), + hostPowerMonitorIdleInterval: Schema.optionalKey(Schema.DurationFromMillis), + idleClientTtl: Schema.optionalKey(Schema.DurationFromMillis), + pauseWhenHostLocked: Schema.optionalKey(Schema.Boolean), + pauseWhenHostLowPower: Schema.optionalKey(Schema.Boolean), + pauseWhenClientLowPower: Schema.optionalKey(Schema.Boolean), + pauseWhenOnBattery: Schema.optionalKey(Schema.Boolean), +}); +export type BackgroundActivityOverrides = typeof BackgroundActivityOverrides.Type; + +export const BackgroundActivitySettings = Schema.Struct({ + schemaVersion: Schema.Literal(1).pipe(Schema.withDecodingDefault(Effect.succeed(1 as const))), + profile: BackgroundActivityProfileSelection.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), + ), + baseProfile: Schema.optionalKey(BackgroundActivityProfile), + overrides: BackgroundActivityOverrides.pipe(Schema.withDecodingDefault(Effect.succeed({}))), +}).pipe(Schema.withDecodingDefault(Effect.succeed({}))); +export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + backgroundActivity: BackgroundActivitySettings, + // Legacy flat fields retained for old settings files and old clients. New + // consumers should resolve `backgroundActivity` instead. automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), ), ), + providerHealthRefreshInterval: Schema.DurationFromMillis.pipe( + Schema.withDecodingDefault( + Effect.succeed(Duration.toMillis(DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL)), + ), + ), + backgroundActivityProfile: BackgroundActivityProfile.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), + ), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), @@ -768,7 +819,17 @@ export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), + backgroundActivity: Schema.optionalKey( + Schema.Struct({ + schemaVersion: Schema.optionalKey(Schema.Literal(1)), + profile: Schema.optionalKey(BackgroundActivityProfileSelection), + baseProfile: Schema.optionalKey(BackgroundActivityProfile), + overrides: Schema.optionalKey(BackgroundActivityOverrides), + }), + ), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), + providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), + backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), diff --git a/packages/shared/package.json b/packages/shared/package.json index 8a45591fd369..8cdae3e51605 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -79,6 +79,10 @@ "types": "./src/serverSettings.ts", "import": "./src/serverSettings.ts" }, + "./backgroundActivitySettings": { + "types": "./src/backgroundActivitySettings.ts", + "import": "./src/backgroundActivitySettings.ts" + }, "./String": { "types": "./src/String.ts", "import": "./src/String.ts" diff --git a/packages/shared/src/backgroundActivitySettings.ts b/packages/shared/src/backgroundActivitySettings.ts new file mode 100644 index 000000000000..fbba3359c460 --- /dev/null +++ b/packages/shared/src/backgroundActivitySettings.ts @@ -0,0 +1,270 @@ +import { + type BackgroundActivityProfile, + type BackgroundActivitySettings, + DEFAULT_BACKGROUND_ACTIVITY_PROFILE, + DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, + DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; + +export interface ResolvedBackgroundActivitySettings { + readonly profile: BackgroundActivityProfile; + readonly automaticGitFetchInterval: Duration.Duration; + readonly providerHealthRefreshInterval: Duration.Duration; + readonly hostPowerMonitorActiveInterval: Duration.Duration; + readonly hostPowerMonitorIdleInterval: Duration.Duration; + readonly idleClientTtl: Duration.Duration; + readonly pauseWhenHostLocked: boolean; + readonly pauseWhenHostLowPower: boolean; + readonly pauseWhenClientLowPower: boolean; + readonly pauseWhenOnBattery: boolean; +} + +const PRESET_SETTINGS: Record = { + performance: { + profile: "performance", + automaticGitFetchInterval: Duration.seconds(15), + providerHealthRefreshInterval: Duration.minutes(1), + hostPowerMonitorActiveInterval: Duration.seconds(30), + hostPowerMonitorIdleInterval: Duration.minutes(2), + idleClientTtl: Duration.seconds(45), + pauseWhenHostLocked: true, + pauseWhenHostLowPower: false, + pauseWhenClientLowPower: false, + pauseWhenOnBattery: false, + }, + balanced: { + profile: "balanced", + automaticGitFetchInterval: DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, + providerHealthRefreshInterval: DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, + hostPowerMonitorActiveInterval: Duration.seconds(30), + hostPowerMonitorIdleInterval: Duration.minutes(5), + idleClientTtl: Duration.seconds(45), + pauseWhenHostLocked: true, + pauseWhenHostLowPower: true, + pauseWhenClientLowPower: true, + pauseWhenOnBattery: false, + }, + "battery-saver": { + profile: "battery-saver", + automaticGitFetchInterval: Duration.seconds(0), + providerHealthRefreshInterval: Duration.minutes(15), + hostPowerMonitorActiveInterval: Duration.minutes(1), + hostPowerMonitorIdleInterval: Duration.minutes(10), + idleClientTtl: Duration.seconds(45), + pauseWhenHostLocked: true, + pauseWhenHostLowPower: true, + pauseWhenClientLowPower: true, + pauseWhenOnBattery: true, + }, +}; + +export function getBackgroundActivityPresetSettings( + profile: BackgroundActivityProfile, +): ResolvedBackgroundActivitySettings { + return PRESET_SETTINGS[profile]; +} + +export function getBackgroundActivityBaseProfile( + backgroundActivity: BackgroundActivitySettings, +): BackgroundActivityProfile { + if (backgroundActivity.profile === "custom") { + return backgroundActivity.baseProfile ?? DEFAULT_BACKGROUND_ACTIVITY_PROFILE; + } + return backgroundActivity.profile; +} + +export function resolveBackgroundActivitySettings( + backgroundActivity: BackgroundActivitySettings, +): ResolvedBackgroundActivitySettings { + const baseProfile = getBackgroundActivityBaseProfile(backgroundActivity); + const preset = PRESET_SETTINGS[baseProfile]; + const overrides = backgroundActivity.profile === "custom" ? backgroundActivity.overrides : {}; + return { + profile: baseProfile, + automaticGitFetchInterval: + overrides.automaticGitFetchInterval ?? preset.automaticGitFetchInterval, + providerHealthRefreshInterval: + overrides.providerHealthRefreshInterval ?? preset.providerHealthRefreshInterval, + hostPowerMonitorActiveInterval: + overrides.hostPowerMonitorActiveInterval ?? preset.hostPowerMonitorActiveInterval, + hostPowerMonitorIdleInterval: + overrides.hostPowerMonitorIdleInterval ?? preset.hostPowerMonitorIdleInterval, + idleClientTtl: overrides.idleClientTtl ?? preset.idleClientTtl, + pauseWhenHostLocked: overrides.pauseWhenHostLocked ?? preset.pauseWhenHostLocked, + pauseWhenHostLowPower: overrides.pauseWhenHostLowPower ?? preset.pauseWhenHostLowPower, + pauseWhenClientLowPower: overrides.pauseWhenClientLowPower ?? preset.pauseWhenClientLowPower, + pauseWhenOnBattery: overrides.pauseWhenOnBattery ?? preset.pauseWhenOnBattery, + }; +} + +function durationsEqual(a: Duration.Duration, b: Duration.Duration): boolean { + return Duration.toMillis(a) === Duration.toMillis(b); +} + +function resolvedSettingsEqual( + a: ResolvedBackgroundActivitySettings, + b: ResolvedBackgroundActivitySettings, +): boolean { + return ( + durationsEqual(a.automaticGitFetchInterval, b.automaticGitFetchInterval) && + durationsEqual(a.providerHealthRefreshInterval, b.providerHealthRefreshInterval) && + durationsEqual(a.hostPowerMonitorActiveInterval, b.hostPowerMonitorActiveInterval) && + durationsEqual(a.hostPowerMonitorIdleInterval, b.hostPowerMonitorIdleInterval) && + durationsEqual(a.idleClientTtl, b.idleClientTtl) && + a.pauseWhenHostLocked === b.pauseWhenHostLocked && + a.pauseWhenHostLowPower === b.pauseWhenHostLowPower && + a.pauseWhenClientLowPower === b.pauseWhenClientLowPower && + a.pauseWhenOnBattery === b.pauseWhenOnBattery + ); +} + +export function normalizeBackgroundActivitySettings( + backgroundActivity: BackgroundActivitySettings, +): BackgroundActivitySettings { + if (backgroundActivity.profile !== "custom") { + return { + schemaVersion: 1, + profile: backgroundActivity.profile, + overrides: {}, + }; + } + + const resolved = resolveBackgroundActivitySettings(backgroundActivity); + const profiles: ReadonlyArray = [ + getBackgroundActivityBaseProfile(backgroundActivity), + "balanced", + "performance", + "battery-saver", + ]; + for (const profile of profiles) { + if (resolvedSettingsEqual(resolved, PRESET_SETTINGS[profile])) { + return { + schemaVersion: 1, + profile, + overrides: {}, + }; + } + } + + const baseProfile = getBackgroundActivityBaseProfile(backgroundActivity); + const preset = PRESET_SETTINGS[baseProfile]; + const overrides: BackgroundActivitySettings["overrides"] = { + ...(!durationsEqual(resolved.automaticGitFetchInterval, preset.automaticGitFetchInterval) + ? { automaticGitFetchInterval: resolved.automaticGitFetchInterval } + : {}), + ...(!durationsEqual( + resolved.providerHealthRefreshInterval, + preset.providerHealthRefreshInterval, + ) + ? { providerHealthRefreshInterval: resolved.providerHealthRefreshInterval } + : {}), + ...(!durationsEqual( + resolved.hostPowerMonitorActiveInterval, + preset.hostPowerMonitorActiveInterval, + ) + ? { hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval } + : {}), + ...(!durationsEqual(resolved.hostPowerMonitorIdleInterval, preset.hostPowerMonitorIdleInterval) + ? { hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval } + : {}), + ...(!durationsEqual(resolved.idleClientTtl, preset.idleClientTtl) + ? { idleClientTtl: resolved.idleClientTtl } + : {}), + ...(resolved.pauseWhenHostLocked !== preset.pauseWhenHostLocked + ? { pauseWhenHostLocked: resolved.pauseWhenHostLocked } + : {}), + ...(resolved.pauseWhenHostLowPower !== preset.pauseWhenHostLowPower + ? { pauseWhenHostLowPower: resolved.pauseWhenHostLowPower } + : {}), + ...(resolved.pauseWhenClientLowPower !== preset.pauseWhenClientLowPower + ? { pauseWhenClientLowPower: resolved.pauseWhenClientLowPower } + : {}), + ...(resolved.pauseWhenOnBattery !== preset.pauseWhenOnBattery + ? { pauseWhenOnBattery: resolved.pauseWhenOnBattery } + : {}), + }; + + return { + schemaVersion: 1, + profile: "custom", + baseProfile, + overrides, + }; +} + +export function resolveServerBackgroundActivitySettings( + settings: ServerSettings, +): ResolvedBackgroundActivitySettings { + const defaultBackgroundActivity: BackgroundActivitySettings = { + schemaVersion: 1, + profile: DEFAULT_BACKGROUND_ACTIVITY_PROFILE, + overrides: {}, + }; + const backgroundActivityIsDefault = + settings.backgroundActivity.profile === defaultBackgroundActivity.profile && + settings.backgroundActivity.baseProfile === undefined && + Object.keys(settings.backgroundActivity.overrides).length === 0; + const legacyProfile = settings.backgroundActivityProfile; + const hasLegacyOverrides = + legacyProfile !== DEFAULT_BACKGROUND_ACTIVITY_PROFILE || + Duration.toMillis(settings.automaticGitFetchInterval) !== + Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL) || + Duration.toMillis(settings.providerHealthRefreshInterval) !== + Duration.toMillis(DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL); + if (backgroundActivityIsDefault && hasLegacyOverrides) { + return resolveBackgroundActivitySettings({ + schemaVersion: 1, + profile: + Duration.toMillis(settings.automaticGitFetchInterval) === + Duration.toMillis( + getBackgroundActivityPresetSettings(legacyProfile).automaticGitFetchInterval, + ) && + Duration.toMillis(settings.providerHealthRefreshInterval) === + Duration.toMillis( + getBackgroundActivityPresetSettings(legacyProfile).providerHealthRefreshInterval, + ) + ? legacyProfile + : "custom", + baseProfile: legacyProfile, + overrides: { + ...(Duration.toMillis(settings.automaticGitFetchInterval) !== + Duration.toMillis( + getBackgroundActivityPresetSettings(legacyProfile).automaticGitFetchInterval, + ) + ? { automaticGitFetchInterval: settings.automaticGitFetchInterval } + : {}), + ...(Duration.toMillis(settings.providerHealthRefreshInterval) !== + Duration.toMillis( + getBackgroundActivityPresetSettings(legacyProfile).providerHealthRefreshInterval, + ) + ? { providerHealthRefreshInterval: settings.providerHealthRefreshInterval } + : {}), + }, + }); + } + return resolveBackgroundActivitySettings(settings.backgroundActivity); +} + +export function normalizeServerBackgroundActivitySettings( + settings: ServerSettings, +): BackgroundActivitySettings { + const resolved = resolveServerBackgroundActivitySettings(settings); + return normalizeBackgroundActivitySettings({ + schemaVersion: 1, + profile: "custom", + baseProfile: resolved.profile, + overrides: { + automaticGitFetchInterval: resolved.automaticGitFetchInterval, + providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, + hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, + idleClientTtl: resolved.idleClientTtl, + pauseWhenHostLocked: resolved.pauseWhenHostLocked, + pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, + pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, + pauseWhenOnBattery: resolved.pauseWhenOnBattery, + }, + }); +} diff --git a/packages/shared/src/logging.test.ts b/packages/shared/src/logging.test.ts index 0e1ea2738bcf..4b19bd291dae 100644 --- a/packages/shared/src/logging.test.ts +++ b/packages/shared/src/logging.test.ts @@ -127,6 +127,25 @@ describe("RotatingFileSink", () => { expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); }); + it("never reports a rotation failure after successfully appending a chunk", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + NodeFS.mkdirSync(`${filePath}.1`); + const sink = new RotatingFileSink({ + filePath, + maxBytes: 1, + maxFiles: 1, + throwOnError: true, + }); + + sink.write("oversized"); + + expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized"); + const thrown = captureError(() => sink.write("next")); + expect(thrown).toMatchObject({ operation: "rotate", filePath }); + expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized"); + }); + it("preserves backup pruning failures", () => { const directory = makeTempDirectory(); const filePath = NodePath.join(directory, "log.ndjson"); diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts index 4aa9c7843a61..ac3d56e28e89 100644 --- a/packages/shared/src/logging.ts +++ b/packages/shared/src/logging.ts @@ -93,10 +93,6 @@ export class RotatingFileSink { NodeFS.appendFileSync(this.filePath, buffer); this.currentSize += buffer.length; - - if (this.currentSize > this.maxBytes) { - this.rotate(); - } } catch (cause) { if (isRotatingFileSinkError(cause)) { throw cause; diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index f9cf1b1cbcf5..4bd1070bf1f1 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Order from "effect/Order"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; @@ -19,6 +20,7 @@ import { makeLocalFileTracer, makeTraceSink, type TraceRecord, + type TraceSinkFlushStats, } from "./observability.ts"; describe("errorTag", () => { @@ -176,6 +178,34 @@ describe("observability", () => { ), ); + it.effect("reports successful logical trace writes", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" }); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + const reported = yield* Ref.make>([]); + + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 1024, + maxFiles: 2, + batchWindowMs: 10_000, + onFlush: (stats) => Ref.update(reported, (current) => [...current, stats]), + }); + + sink.push(makeRecord("attributed")); + yield* sink.flush; + + const stats = yield* Ref.get(reported); + assert.equal(stats.length, 1); + assert.equal(stats[0]?.count, 1); + assert.isAbove(stats[0]?.logicalWriteBytes ?? 0, 0); + }), + ), + ); + it.effect("rotates the trace file when the configured max size is exceeded", () => Effect.scoped( Effect.gen(function* () { @@ -186,7 +216,7 @@ describe("observability", () => { const sink = yield* makeTraceSink({ filePath: tracePath, - maxBytes: 180, + maxBytes: 500, maxFiles: 2, batchWindowMs: 10_000, }); @@ -217,6 +247,70 @@ describe("observability", () => { ), ); + it.effect("keeps every trace file within the configured limit for threshold flushes", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" }); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + const maxBytes = 1_024; + + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + for (let index = 0; index < 256; index += 1) { + sink.push(makeRecord("threshold", `${index}-${"x".repeat(48)}`)); + } + yield* sink.close(); + + const matchingFiles = (yield* fileSystem.readDirectory(tempDir)).filter( + (entry) => entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."), + ); + assert.include(matchingFiles, "shared.trace.ndjson.1"); + for (const entry of matchingFiles) { + const stat = yield* fileSystem.stat(path.join(tempDir, entry)); + assert.isAtMost(Number(stat.size), maxBytes, entry); + } + }), + ), + ); + + it.effect("drops a single trace record that cannot fit within the configured limit", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" }); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + const maxBytes = 1_024; + + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + sink.push(makeRecord("oversized", "x".repeat(maxBytes * 2))); + sink.push(makeRecord("retained")); + yield* sink.close(); + + const records = yield* readTraceRecords(tracePath); + const stat = yield* fileSystem.stat(tracePath); + assert.deepEqual( + records.map((record) => record.name), + ["retained"], + ); + assert.isAtMost(Number(stat.size), maxBytes); + }), + ), + ); + it.effect("drops only the invalid trace record when serialization fails", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 1b92b98739d2..e0a7595865d9 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -8,7 +8,8 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; import { RotatingFileSink } from "./logging.ts"; -const FLUSH_BUFFER_THRESHOLD = 32; +const FLUSH_BUFFER_THRESHOLD = 256; +const textEncoder = new TextEncoder(); export type TraceAttributes = Readonly>; @@ -109,6 +110,13 @@ export interface TraceSinkOptions { readonly maxBytes: number; readonly maxFiles: number; readonly batchWindowMs: number; + readonly onFlush?: (stats: TraceSinkFlushStats) => Effect.Effect; +} + +export interface TraceSinkFlushStats { + readonly logicalWriteBytes: number; + readonly count: number; + readonly durationMs: number; } export interface TraceSink { @@ -275,26 +283,73 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac filePath: options.filePath, maxBytes: options.maxBytes, maxFiles: options.maxFiles, + throwOnError: true, }); let buffer: Array = []; + let pendingFlushStats: TraceSinkFlushStats = { + logicalWriteBytes: 0, + count: 0, + durationMs: 0, + }; const flushUnsafe = () => { if (buffer.length === 0) { return; } - const chunk = buffer.join(""); + const records = buffer; buffer = []; + let persistedCount = 0; + + while (persistedCount < records.length) { + const firstRecordBytes = textEncoder.encode(records[persistedCount]).byteLength; + if (firstRecordBytes > options.maxBytes) { + persistedCount += 1; + continue; + } + + let nextIndex = persistedCount + 1; + let chunkBytes = firstRecordBytes; + while (nextIndex < records.length) { + const nextRecordBytes = textEncoder.encode(records[nextIndex]).byteLength; + if (chunkBytes + nextRecordBytes > options.maxBytes) break; + chunkBytes += nextRecordBytes; + nextIndex += 1; + } - try { - sink.write(chunk); - } catch { - buffer.unshift(chunk); + const chunk = records.slice(persistedCount, nextIndex).join(""); + const startedAt = performance.now(); + try { + sink.write(chunk); + } catch { + buffer.unshift(...records.slice(persistedCount)); + return; + } + pendingFlushStats = { + logicalWriteBytes: pendingFlushStats.logicalWriteBytes + chunkBytes, + count: pendingFlushStats.count + nextIndex - persistedCount, + durationMs: pendingFlushStats.durationMs + Math.max(0, performance.now() - startedAt), + }; + persistedCount = nextIndex; } }; - const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); + const flush = Effect.sync(() => { + flushUnsafe(); + const stats = pendingFlushStats; + pendingFlushStats = { + logicalWriteBytes: 0, + count: 0, + durationMs: 0, + }; + return stats; + }).pipe( + Effect.flatMap((stats) => + stats.count > 0 && options.onFlush ? options.onFlush(stats).pipe(Effect.ignore) : Effect.void, + ), + Effect.withTracerEnabled(false), + ); yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); yield* Effect.forkScoped( @@ -399,6 +454,7 @@ export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( maxBytes: options.maxBytes, maxFiles: options.maxFiles, batchWindowMs: options.batchWindowMs, + ...(options.onFlush ? { onFlush: options.onFlush } : {}), })); const delegate = diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 2352c6e29390..baa84a4e1aa8 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -4,7 +4,9 @@ import { ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; +import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; import { applyServerSettingsPatch, @@ -295,4 +297,219 @@ describe("serverSettings helpers", () => { config: { homePath: "~/.codex" }, }); }); + + it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + backgroundActivity: { + schemaVersion: 1, + profile: "battery-saver", + overrides: {}, + }, + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "battery-saver", + overrides: {}, + }); + expect(next.backgroundActivityProfile).toBe("battery-saver"); + expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0); + expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe( + Duration.toMillis(Duration.minutes(15)), + ); + }); + + it("turns legacy interval patches into custom background activity overrides", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + automaticGitFetchInterval: Duration.seconds(15), + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "custom", + baseProfile: "balanced", + overrides: { + automaticGitFetchInterval: Duration.seconds(15), + }, + }); + expect(resolveServerBackgroundActivitySettings(next).profile).toBe("balanced"); + expect( + Duration.toMillis(resolveServerBackgroundActivitySettings(next).automaticGitFetchInterval), + ).toBe(15_000); + }); + + it("preserves legacy background activity settings when applying an unrelated patch", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + backgroundActivityProfile: "performance" as const, + automaticGitFetchInterval: Duration.seconds(7), + providerHealthRefreshInterval: Duration.minutes(4), + }; + + const next = applyServerSettingsPatch(current, { + sourceControlWriterModelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.4-mini", + ), + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "custom", + baseProfile: "performance", + overrides: { + automaticGitFetchInterval: Duration.seconds(7), + providerHealthRefreshInterval: Duration.minutes(4), + }, + }); + expect(next.backgroundActivityProfile).toBe("performance"); + expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(7_000); + expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe(240_000); + }); + + it("does not reactivate dormant overrides from a concrete profile", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + backgroundActivity: { + schemaVersion: 1 as const, + profile: "battery-saver" as const, + overrides: { + providerHealthRefreshInterval: Duration.seconds(5), + }, + }, + }; + + const next = applyServerSettingsPatch(current, { + automaticGitFetchInterval: Duration.seconds(15), + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "custom", + baseProfile: "battery-saver", + overrides: { + automaticGitFetchInterval: Duration.seconds(15), + }, + }); + }); + + it("prefers structured background activity settings over legacy aliases", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + backgroundActivity: { + schemaVersion: 1, + profile: "battery-saver", + overrides: {}, + }, + automaticGitFetchInterval: Duration.seconds(5), + backgroundActivityProfile: "performance", + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "battery-saver", + overrides: {}, + }); + expect(next.backgroundActivityProfile).toBe("battery-saver"); + expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0); + }); + + it("reconciles custom background activity back to a preset when overrides match the preset", () => { + const custom = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + automaticGitFetchInterval: Duration.seconds(15), + }); + const next = applyServerSettingsPatch(custom, { + automaticGitFetchInterval: Duration.seconds(30), + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "balanced", + overrides: {}, + }); + expect(next.backgroundActivityProfile).toBe("balanced"); + expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(30_000); + }); + + it("drops custom overrides that duplicate the base profile", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + backgroundActivity: { + schemaVersion: 1, + profile: "custom", + baseProfile: "balanced", + overrides: { + automaticGitFetchInterval: Duration.seconds(30), + }, + }, + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "balanced", + overrides: {}, + }); + }); + + it("replaces the complete background override record", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + backgroundActivity: { + schemaVersion: 1, + profile: "custom", + baseProfile: "balanced", + overrides: { + automaticGitFetchInterval: Duration.seconds(15), + providerHealthRefreshInterval: Duration.minutes(3), + }, + }, + }); + + const next = applyServerSettingsPatch(current, { + backgroundActivity: { + overrides: { + automaticGitFetchInterval: Duration.seconds(10), + }, + }, + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "custom", + baseProfile: "balanced", + overrides: { + automaticGitFetchInterval: Duration.seconds(10), + }, + }); + }); + + it("keeps interval overrides supplied with a profile patch", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + backgroundActivityProfile: "performance", + automaticGitFetchInterval: Duration.seconds(0), + providerHealthRefreshInterval: Duration.minutes(4), + }); + + expect(next.backgroundActivity).toEqual({ + schemaVersion: 1, + profile: "custom", + baseProfile: "performance", + overrides: { + automaticGitFetchInterval: Duration.seconds(0), + providerHealthRefreshInterval: Duration.minutes(4), + }, + }); + }); + + it("ignores overrides attached to a concrete background profile", () => { + const resolved = resolveServerBackgroundActivitySettings({ + ...DEFAULT_SERVER_SETTINGS, + backgroundActivity: { + schemaVersion: 1, + profile: "balanced", + overrides: { + pauseWhenOnBattery: true, + }, + }, + }); + + expect(resolved.pauseWhenOnBattery).toBe(false); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e0e53648acc0..21d819a1c9ea 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -12,6 +12,12 @@ import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; import { createModelSelection } from "./model.ts"; +import { + getBackgroundActivityBaseProfile, + normalizeBackgroundActivitySettings, + normalizeServerBackgroundActivitySettings, + resolveBackgroundActivitySettings, +} from "./backgroundActivitySettings.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); @@ -120,10 +126,64 @@ export function applyServerSettingsPatch( patch: ServerSettingsPatch, ): ServerSettings { const selectionPatch = patch.textGenerationModelSelection; - const { automaticGitFetchInterval, ...patchForMerge } = patch; + const { + automaticGitFetchInterval, + providerHealthRefreshInterval, + backgroundActivityProfile, + backgroundActivity, + ...patchForMerge + } = patch; + const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); + const backgroundActivityPatch = + backgroundActivityProfile !== undefined + ? { + schemaVersion: 1 as const, + profile: + automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined + ? ("custom" as const) + : backgroundActivityProfile, + ...(automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined + ? { baseProfile: backgroundActivityProfile } + : {}), + overrides: { + ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), + ...(providerHealthRefreshInterval !== undefined + ? { providerHealthRefreshInterval } + : {}), + }, + } + : automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined + ? { + schemaVersion: 1 as const, + profile: "custom" as const, + baseProfile: getBackgroundActivityBaseProfile(currentBackgroundActivity), + overrides: { + ...(currentBackgroundActivity.profile === "custom" + ? currentBackgroundActivity.overrides + : {}), + ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), + ...(providerHealthRefreshInterval !== undefined + ? { providerHealthRefreshInterval } + : {}), + }, + } + : undefined; const next = deepMerge(current, patchForMerge); - const nextWithReplacements = { + const nextWithReplacementsBase = { ...next, + ...(backgroundActivity !== undefined + ? { + backgroundActivity: { + ...deepMerge(currentBackgroundActivity, backgroundActivity), + ...(backgroundActivity.overrides !== undefined + ? { overrides: backgroundActivity.overrides } + : {}), + }, + } + : { backgroundActivity: currentBackgroundActivity }), + ...(backgroundActivity === undefined && backgroundActivityPatch !== undefined + ? { backgroundActivity: backgroundActivityPatch } + : {}), ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), @@ -131,6 +191,20 @@ export function applyServerSettingsPatch( ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), + ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}), + }; + const normalizedBackgroundActivity = normalizeBackgroundActivitySettings( + nextWithReplacementsBase.backgroundActivity, + ); + const resolvedBackgroundActivity = resolveBackgroundActivitySettings( + normalizedBackgroundActivity, + ); + const nextWithReplacements = { + ...nextWithReplacementsBase, + backgroundActivity: normalizedBackgroundActivity, + automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval, + providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval, + backgroundActivityProfile: resolvedBackgroundActivity.profile, }; if (!selectionPatch) { return nextWithReplacements; diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 9b36bb44d658..b9ab886e7afe 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -15,9 +15,11 @@ import { createBuildConfig, DESKTOP_ELECTRON_LANGUAGES, DESKTOP_FILE_EXCLUSIONS, + DESKTOP_EXTRA_RESOURCES, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, InvalidMockUpdateServerPortError, + UnsupportedDesktopBuildArchitectureError, isMacPasskeySigningConfigurationError, LinuxIconResizeError, MacPasskeySigningConfigurationResolutionError, @@ -32,6 +34,8 @@ import { resolveDesktopProductName, resolveDesktopUpdateChannel, resolveDesktopWebAssetBrand, + resolveResourceMonitorRustTargets, + resourceMonitorExecutableName, resolveGitHubPublishConfig, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, @@ -548,6 +552,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it("stages the resource monitor as an external executable resource", () => { + assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [ + { + from: "apps/desktop/prod-resources/resource-monitor", + to: "resource-monitor", + }, + ]); + assert.deepStrictEqual(resolveResourceMonitorRustTargets("mac", "universal"), [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + ]); + assert.deepStrictEqual(resolveResourceMonitorRustTargets("linux", "x64"), [ + "x86_64-unknown-linux-gnu", + ]); + assert.deepStrictEqual(resolveResourceMonitorRustTargets("win", "arm64"), [ + "aarch64-pc-windows-msvc", + ]); + assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor"); + assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); + }); it("promotes target fff binaries to direct staged dependencies", () => { assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", @@ -678,6 +702,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it.effect("rejects universal builds on Linux and Windows before staging binaries", () => + Effect.gen(function* () { + for (const platform of ["linux", "win"] as const) { + const error = yield* Effect.flip( + resolveBuildOptions({ + platform: Option.some(platform), + target: Option.none(), + arch: Option.some("universal"), + buildVersion: Option.none(), + outputDir: Option.none(), + skipBuild: Option.none(), + keepStage: Option.none(), + signed: Option.none(), + verbose: Option.none(), + mockUpdates: Option.none(), + mockUpdateServerPort: Option.none(), + wslPrebuild: Option.none(), + }), + ); + + assert.instanceOf(error, UnsupportedDesktopBuildArchitectureError); + assert.deepStrictEqual(error.supportedArchitectures, ["x64", "arm64"]); + } + }), + ); + it.effect("preserves explicit false boolean flags over true env defaults", () => Effect.gen(function* () { const resolved = yield* resolveBuildOptions({ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index e8a33d3d21e7..5a07d0034f05 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -94,6 +94,26 @@ interface PlatformConfig { readonly archChoices: ReadonlyArray; } +export function resolveResourceMonitorRustTargets( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +): ReadonlyArray { + if (platform === "mac") { + if (arch === "universal") { + return ["aarch64-apple-darwin", "x86_64-apple-darwin"]; + } + return [arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"]; + } + if (platform === "linux") { + return [arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"]; + } + return [arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"]; +} + +export function resourceMonitorExecutableName(platform: typeof BuildPlatform.Type): string { + return platform === "win" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; +} + const PLATFORM_CONFIG: Record = { mac: { cliFlag: "--mac", @@ -189,6 +209,19 @@ export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass()( + "UnsupportedDesktopBuildArchitectureError", + { + platform: BuildPlatform, + arch: BuildArch, + supportedArchitectures: Schema.Array(BuildArch), + }, +) { + override get message(): string { + return `Unsupported architecture '${this.arch}' for ${this.platform}.`; + } +} + const InvalidMockUpdateServerPortReason = Schema.Literals([ "not-numeric", "not-integer", @@ -236,6 +269,20 @@ export class BuildCommandFailedError extends Schema.TaggedErrorClass()( + "ResourceMonitorBuildOutputMissingError", + { + binaryPath: Schema.String, + rustTarget: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + }, +) { + override get message(): string { + return `Resource monitor build for ${this.rustTarget} did not produce ${this.binaryPath}.`; + } +} + const desktopIconPlatformNames = { mac: "macOS", linux: "Linux", @@ -609,6 +656,12 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // The Windows primary backend reads the same files through the asar redirect, // so nothing is duplicated. export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const; +export const DESKTOP_EXTRA_RESOURCES = [ + { + from: "apps/desktop/prod-resources/resource-monitor", + to: "resource-monitor", + }, +] as const; export interface MacPasskeySigningConfiguration { readonly appId: string; @@ -1060,6 +1113,14 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); const defaultArch = yield* getDefaultArch(platform); const arch = mergeOptions(input.arch, env.arch, defaultArch); + const supportedArchitectures = PLATFORM_CONFIG[platform].archChoices; + if (!supportedArchitectures.includes(arch)) { + return yield* new UnsupportedDesktopBuildArchitectureError({ + platform, + arch, + supportedArchitectures: [...supportedArchitectures], + }); + } const version = mergeOptions(input.buildVersion, env.version, undefined); const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) ? "release-mock" @@ -1133,6 +1194,81 @@ const runCommand = Effect.fn("runCommand")(function* ( } }); +const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: { + readonly repoRoot: string; + readonly stageResourcesDir: string; + readonly platform: typeof BuildPlatform.Type; + readonly arch: typeof BuildArch.Type; + readonly verbose: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const manifestPath = path.join(input.repoRoot, "native/resource-monitor/Cargo.toml"); + const executableName = resourceMonitorExecutableName(input.platform); + const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch); + const builtBinaries: string[] = []; + + for (const rustTarget of rustTargets) { + const spawnCommand = yield* resolveSpawnCommand("cargo", [ + "build", + "--locked", + "--release", + "--manifest-path", + manifestPath, + "--target", + rustTarget, + ]); + yield* runCommand( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.repoRoot, + shell: spawnCommand.shell, + }), + { + label: `cargo build resource monitor (${rustTarget})`, + verbose: input.verbose, + }, + ); + + const binaryPath = path.join( + input.repoRoot, + "native/resource-monitor/target", + rustTarget, + "release", + executableName, + ); + if (!(yield* fs.exists(binaryPath))) { + return yield* new ResourceMonitorBuildOutputMissingError({ + binaryPath, + rustTarget, + platform: input.platform, + arch: input.arch, + }); + } + builtBinaries.push(binaryPath); + } + + const destinationDirectory = path.join(input.stageResourcesDir, "resource-monitor"); + const destinationPath = path.join(destinationDirectory, executableName); + yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore); + yield* fs.makeDirectory(destinationDirectory, { recursive: true }); + + if (builtBinaries.length === 1) { + yield* fs.copyFile(builtBinaries[0]!, destinationPath); + } else { + yield* runCommand( + ChildProcess.make("lipo", ["-create", ...builtBinaries, "-output", destinationPath]), + { + label: "lipo resource monitor universal binary", + verbose: input.verbose, + }, + ); + } + + if (input.platform !== "win") { + yield* fs.chmod(destinationPath, 0o755); + } +}); + function generateMacIconSet( sourcePng: string, targetIcns: string, @@ -1436,6 +1572,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( platform === "win" ? [...WINDOWS_ASAR_UNPACK] : (["node_modules/@github/copilot*/**/*"] as const), + extraResources: DESKTOP_EXTRA_RESOURCES, }; const updateChannel = resolveDesktopUpdateChannel(version); const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); @@ -1728,6 +1865,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); yield* fs.copy(distDirs.desktopResources, stageResourcesDir); yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + yield* stageResourceMonitor({ + repoRoot, + stageResourcesDir, + platform: options.platform, + arch: options.arch, + verbose: options.verbose, + }); yield* assertPlatformBuildResources( options.platform, From 95420f99cbd2376f4a7bd3afe9fb93dd394bdd4b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 13:06:39 -0400 Subject: [PATCH 03/15] Fix editable file focus and live syntax highlighting (#3979) (cherry picked from commit fc28abf24b034502402cba1fedf5fa38658ee2e8) --- .../src/components/files/FilePreviewPanel.tsx | 18 ++++-- .../files/fileContentRevision.test.ts | 29 ++++++++- .../components/files/fileContentRevision.ts | 18 ++++++ ...ch => @pierre%2Fdiffs@1.3.0-beta.10.patch} | 60 ++++++++++++------- pnpm-lock.yaml | 58 ++++++++++-------- pnpm-workspace.yaml | 4 +- 6 files changed, 131 insertions(+), 56 deletions(-) rename patches/{@pierre%2Fdiffs@1.3.0-beta.5.patch => @pierre%2Fdiffs@1.3.0-beta.10.patch} (50%) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 7f0d773c368f..24e63a6d8eaf 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -7,7 +7,7 @@ import type { import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; -import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; +import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -52,7 +52,7 @@ import { } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; -import { projectFileCacheKey } from "./fileContentRevision"; +import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; @@ -365,6 +365,8 @@ function EditableFileSurface({ const editor = useMemo( () => new Editor({ + persistState: true, + persistStateStorage: "inMemory", onChange: (file, nextLineAnnotations) => { setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); saveCoordinator.change(file.contents); @@ -537,7 +539,7 @@ function EditableFileSurface({ ); return ( - +
-
+ ); } diff --git a/apps/web/src/components/files/fileContentRevision.test.ts b/apps/web/src/components/files/fileContentRevision.test.ts index db3ba4f5e269..e2ec7f9f1cad 100644 --- a/apps/web/src/components/files/fileContentRevision.test.ts +++ b/apps/web/src/components/files/fileContentRevision.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileContentRevision, projectFileCacheKey } from "./fileContentRevision"; +import { + fileContentRevision, + projectFileCacheKey, + projectFileEditorCacheKey, +} from "./fileContentRevision"; describe("fileContentRevision", () => { it("changes for same-length edits", () => { @@ -12,4 +16,27 @@ describe("fileContentRevision", () => { projectFileCacheKey("/repo", "file.json", "contents"), ); }); + + it("keeps editor identity stable for locally edited contents", () => { + const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "after", undefined); + + expect( + projectFileEditorCacheKey("local", "/repo", "file.json", "after edit", { + cacheKey, + contents: "after edit", + }), + ).toBe(cacheKey); + }); + + it("rotates editor identity for external contents and environments", () => { + const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "before", undefined); + const editorFile = { cacheKey, contents: "before" }; + + expect( + projectFileEditorCacheKey("local", "/repo", "file.json", "external edit", editorFile), + ).not.toBe(cacheKey); + expect(projectFileEditorCacheKey("remote", "/repo", "file.json", "before", undefined)).not.toBe( + cacheKey, + ); + }); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index 3fa8d07bccb2..e51d464925bd 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -10,3 +10,21 @@ export function fileContentRevision(contents: string): string { export function projectFileCacheKey(cwd: string, relativePath: string, contents: string): string { return `${cwd}:${relativePath}:${fileContentRevision(contents)}`; } + +interface EditorFileIdentity { + readonly cacheKey?: string; + readonly contents: string; +} + +export function projectFileEditorCacheKey( + environmentId: string, + cwd: string, + relativePath: string, + contents: string, + editorFile: EditorFileIdentity | undefined, +): string { + if (editorFile?.contents === contents && editorFile.cacheKey) { + return editorFile.cacheKey; + } + return `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`; +} diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.5.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch similarity index 50% rename from patches/@pierre%2Fdiffs@1.3.0-beta.5.patch rename to patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index 59aa02f6d1cd..b342d4b5dd13 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.5.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -1,38 +1,54 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js -index e8013fc6eb6f243a6c912facf3fc0319ac66a8d0..80c82df4cdeb828bd331f5ec2f443d216bedc304 100644 +index ff78e2a..f9df318 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -77,15 +77,12 @@ var Editor = class { - this.#options = options; - } - edit(component) { -- const { useTokenTransformer, enableGutterUtility, enableLineSelection, expandUnchanged, diffStyle, lineHoverHighlight,...rest } = component.options; -+ const { useTokenTransformer, expandUnchanged, diffStyle,...rest } = component.options; - const isDiff = component.type === "file-diff"; -- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled" || expandUnchanged !== true && isDiff || diffStyle === "unified" && isDiff) { -+ if (useTokenTransformer !== true || expandUnchanged !== true && isDiff || diffStyle === "unified" && isDiff) { - component.setOptions({ +@@ -146,14 +146,11 @@ var Editor = class { + const file = fileInstance.__getCurrentFile?.(); + if (file !== void 0) requirePersistedCacheKey(file); + } +- const { useTokenTransformer, enableGutterUtility, enableLineSelection, lineHoverHighlight = "disabled", ...rest } = fileInstance.options; +- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled") { ++ const { useTokenTransformer, ...rest } = fileInstance.options; ++ if (useTokenTransformer !== true) { + fileInstance.setOptions({ ...rest, - useTokenTransformer: true, +- useTokenTransformer: true, - enableGutterUtility: false, - enableLineSelection: false, -- lineHoverHighlight: "disabled", - expandUnchanged: true, - diffStyle: "split" +- lineHoverHighlight: "disabled" ++ useTokenTransformer: true }); -@@ -511,6 +508,7 @@ var Editor = class { + fileInstance.rerender(); + } +@@ -908,6 +905,7 @@ var Editor = class { return lineNumber - 1; }; this.#editorEventDisposes.push(addEventListener(gutterEl, "pointerdown", (e) => { + if (this.#fileInstance?.options.enableLineSelection === true) return; - const textDocument = this.#textDocument; - const lineIndex = resolveEditableLine(resolveGutterTarget(e.composedPath()[0])); - if (lineIndex === void 0 || textDocument === void 0) return; + const gutterRow = resolveGutterTarget(e.composedPath()[0]); + if (gutterRow?.dataset.lineType === "change-deletion") { + const code = gutterRow.closest("[data-code]"); +@@ -1522,6 +1520,7 @@ var Editor = class { + if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; + } + fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); ++ if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); + if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); + if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); + if (newLineAnnotations !== void 0) { +@@ -1788,6 +1787,7 @@ var Editor = class { + } + } + #setSelectedLinesSafe(range, lineNumberOnly = false) { ++ if (this.#fileInstance?.options.controlledSelection === true) return; + try { + this.#fileInstance?.setSelectedLines(range, { + notify: false, diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js -index cb8e2026fb5d7a19f489c0a2402efbcb7dff3322..510fad6364d4a2214c7dd65fe2b114f1cce0c815 100644 +index e9f62f5..af82a46 100644 --- a/dist/react/utils/useFileInstance.js +++ b/dist/react/utils/useFileInstance.js -@@ -92,10 +92,7 @@ function mergeFileOptions({ options, controlledSelection, contentEditable, hasCu +@@ -91,10 +91,7 @@ function mergeFileOptions({ options, controlledSelection, contentEditable, hasCu }; if (needsEditorOptions) merged = { ...merged, @@ -45,7 +61,7 @@ index cb8e2026fb5d7a19f489c0a2402efbcb7dff3322..510fad6364d4a2214c7dd65fe2b114f1 return merged; } diff --git a/package.json b/package.json -index d1558633de87044b7aa96cff09443db11f163cec..c0b16f0a0bec6fba2026f24f38b2c0a8fa06af7c 100644 +index ff61c90..1e170e5 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,18 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd8fa90c94f2..b505f9fa8282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ catalogs: specifier: 1.8.0 version: 1.8.0 '@pierre/diffs': - specifier: 1.3.0-beta.5 - version: 1.3.0-beta.5 + specifier: 1.3.0-beta.10 + version: 1.3.0-beta.10 '@typescript/native-preview': specifier: 7.0.0-dev.20260604.1 version: 7.0.0-dev.20260604.1 @@ -75,7 +75,7 @@ patchedDependencies: '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 - '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a + '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.102: 71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488 @@ -225,7 +225,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -481,7 +481,7 @@ importers: version: 1.15.13 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) @@ -569,7 +569,7 @@ importers: version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3727,20 +3727,20 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@pierre/diffs@1.3.0-beta.5': - resolution: {integrity: sha512-d7449IY6Phcg9LCRLbPxhsxn6Bv4KoaP/vPyZtGu2uR1SFsSJPQcRoPf8lzyobNGKD0GZGuhgHW5LrOlilFo7w==} + '@pierre/diffs@1.3.0-beta.10': + resolution: {integrity: sha512-efyFM9GRfI6WkmHJP0CnZBopuM8yCwGqIKbZHoe1D5PV15VDkr7Vpi8EZt40AYrN1km//utQtYhHDSwt2KwjSg==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 - '@pierre/theme@1.0.3': - resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==} + '@pierre/theme@1.1.0': + resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} engines: {vscode: ^1.0.0} - '@pierre/theming@0.0.1': - resolution: {integrity: sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A==} + '@pierre/theming@0.0.2': + resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} peerDependencies: - '@pierre/theme': ^1.0.0 + '@pierre/theme': ^1.1.0 '@shikijs/themes': ^3.0.0 || ^4.0.0 react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -6261,6 +6261,10 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -13854,12 +13858,12 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@pierre/theme': 1.0.3 - '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) + '@pierre/theme': 1.1.0 + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 - diff: 8.0.3 + diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 react: 19.2.3 @@ -13868,12 +13872,12 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@pierre/theme': 1.0.3 - '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + '@pierre/theme': 1.1.0 + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 - diff: 8.0.3 + diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 react: 19.2.6 @@ -13882,19 +13886,19 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/theme@1.0.3': {} + '@pierre/theme@1.1.0': {} - '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)': + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)': optionalDependencies: - '@pierre/theme': 1.0.3 + '@pierre/theme': 1.1.0 '@shikijs/themes': 4.2.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) shiki: 4.2.0 - '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': optionalDependencies: - '@pierre/theme': 1.0.3 + '@pierre/theme': 1.1.0 '@shikijs/themes': 4.3.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -16677,6 +16681,8 @@ snapshots: diff@8.0.3: {} + diff@9.0.0: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c254405aa160..d41e50e8784c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,7 @@ catalog: "@effect/vitest": 4.0.0-beta.102 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 - "@pierre/diffs": 1.3.0-beta.5 + "@pierre/diffs": 1.3.0-beta.10 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 effect: 4.0.0-beta.102 @@ -128,7 +128,7 @@ patchedDependencies: "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch - "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch + "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.102: patches/effect@4.0.0-beta.102.patch From 9b776056e1d4aecae6c6b3b23e43c775eef72a98 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 04:45:04 -0700 Subject: [PATCH 04/15] fix(web): show server update progress through reconnect (#4903) (cherry picked from commit 197d3487116372ca2f16661f57d4e869fc3cb6c0) --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/cloud/selfUpdate.test.ts | 45 ++- apps/server/src/cloud/selfUpdate.ts | 71 ++-- .../src/environment/ServerEnvironment.ts | 3 + apps/server/src/ws.ts | 29 ++ apps/web/src/components/ChatView.tsx | 73 ++-- .../components/ServerUpdateAction.test.tsx | 215 +++++------ .../web/src/components/ServerUpdateAction.tsx | 249 +++++++------ .../settings/ConnectionsSettings.tsx | 63 +++- docs/architecture/server-updates.md | 54 +-- docs/user/server-updates.md | 13 +- .../src/connection/supervisor.test.ts | 37 ++ .../src/connection/supervisor.ts | 9 +- packages/client-runtime/src/rpc/client.ts | 3 +- .../client-runtime/src/state/runtime.test.ts | 49 +++ packages/client-runtime/src/state/runtime.ts | 22 ++ .../client-runtime/src/state/server.test.ts | 134 ++++++- packages/client-runtime/src/state/server.ts | 335 +++++++++++++++++- packages/contracts/src/environment.ts | 3 + packages/contracts/src/rpc.ts | 13 + packages/contracts/src/server.ts | 15 + 21 files changed, 1072 insertions(+), 364 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5655f260bc95..b4bea21d2e36 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, + [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 9d6e3801704d..655228c047f1 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -26,6 +26,33 @@ import * as SelfUpdate from "./selfUpdate.ts"; const NODE_PATH = "/usr/local/bin/node"; +const eventuallyFileString = Effect.fn("test.eventuallyFileString")(function* ( + filePath: string, + expected: string, +) { + const fs = yield* FileSystem.FileSystem; + for (let iteration = 0; iteration < 1_000; iteration += 1) { + const contents = yield* fs.readFileString(filePath); + if (contents === expected) { + return; + } + // The rollback performs real filesystem I/O on a detached fiber, which + // advancing TestClock does not await. + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error(`Expected file contents were not observed at ${filePath}.`)); +}); + +const eventuallyTrue = Effect.fn("test.eventuallyTrue")(function* (predicate: () => boolean) { + for (let iteration = 0; iteration < 1_000; iteration += 1) { + if (predicate()) { + return; + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error("Expected condition was not observed.")); +}); + interface RecordedCommand { readonly command: string; readonly args: ReadonlyArray; @@ -457,8 +484,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { it.effect("installs, preflights, and respawns a foreground server", () => Effect.gen(function* () { const context = yield* makeContext(); - const result = yield* context.service.update({ targetVersion: "0.0.29" }); + const progress: Array = []; + const result = yield* context.service.update({ targetVersion: "0.0.29" }, (stage) => + Effect.sync(() => progress.push(stage)), + ); assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.deepEqual(progress, ["downloading", "installing"]); assert.lengthOf(context.spawns, 1); const concurrentError = yield* context.service @@ -506,10 +537,12 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); assert.deepEqual( context.commands.map((entry) => entry.command), - ["npm", NODE_PATH, "systemctl", "systemctl"], + ["npm", NODE_PATH, "systemctl"], ); assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); + // Restart waits until after the update acknowledgement can flush. + yield* TestClock.adjust(Duration.seconds(10)); assert.deepEqual(context.commands[3], { command: "systemctl", args: ["--user", "restart", "t3code.service"], @@ -542,9 +575,11 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { ); const previousUnit = yield* context.fs.readFileString(unitPath); - const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - assert.include(first.reason, "Restarting the systemd boot service failed"); - assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + const first = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(first, { targetVersion: "0.0.29", method: "boot-service" }); + yield* TestClock.adjust(Duration.seconds(10)); + yield* eventuallyFileString(unitPath, previousUnit); + yield* eventuallyTrue(() => context.commands.at(-1)?.args[1] === "daemon-reload"); assert.deepEqual( context.commands.slice(-2).map((entry) => entry.args), [ diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 62bd07fbbc8b..f786cbea8cfa 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,6 +6,7 @@ import { ServerSelfUpdateError, type ServerSelfUpdateCapability, type ServerSelfUpdateInput, + type ServerSelfUpdateProgressStage, type ServerSelfUpdateResult, } from "@t3tools/contracts"; import { @@ -147,6 +148,7 @@ export class ServerSelfUpdate extends Context.Service< { readonly update: ( input: ServerSelfUpdateInput, + reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, ) => Effect.Effect; } >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} @@ -227,7 +229,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", - )(function* (input) { + )(function* (input, reportProgress = () => Effect.void) { if (capability === "desktop-managed") { return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", @@ -250,6 +252,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option } return yield* Effect.gen(function* () { + yield* reportProgress("downloading"); const runtimePaths = yield* ensurePinnedRuntimeInstalled({ baseDir: serverConfig.baseDir, version: targetVersion, @@ -260,6 +263,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), ); + yield* reportProgress("installing"); // A broken artifact (failed native build, incompatible node) must be // caught while the current server is still alive to report it. const preflight = yield* runner @@ -349,38 +353,45 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option yield* Effect.logInfo("Server self-update installed; restarting boot service.", { targetVersion, }); - // A successful systemd restart stops this process, so the RPC is - // interrupted and the reconnecting client observes the new version. - // A rejected restart returns while the old process is still alive; - // restore the previous unit and report that failure through the RPC. - yield* Effect.gen(function* () { - const restart = yield* runner - .run({ - command: "systemctl", - args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], - }) - .pipe( - Effect.mapError((cause) => - failWith("Could not restart the systemd boot service.", cause), + // Restart after the acknowledgement has had time to cross any relay + // hop. If systemd rejects the handoff, restore the previous unit while + // this process is still alive and log the failure for diagnostics. + yield* scheduleRestart( + Effect.gen(function* () { + const restart = yield* runner + .run({ + command: "systemctl", + args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + }) + .pipe( + Effect.mapError((cause) => + failWith("Could not restart the systemd boot service.", cause), + ), + ); + if (restart.code !== 0) { + return yield* failWith( + `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`, + ); + } + }).pipe( + Effect.catch((restartError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.andThen(reloadSystemd()), + Effect.mapError((rollbackError) => + failWith("Could not restore the previous systemd unit.", { + restartError, + rollbackError, + }), + ), + Effect.andThen(Effect.fail(restartError)), ), - ); - if (restart.code !== 0) { - return yield* failWith( - `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`, - ); - } - }).pipe( - Effect.catch((restartError) => - writeUnitAtomically(unitPath, previousUnit).pipe( - Effect.andThen(reloadSystemd()), - Effect.mapError((rollbackError) => - failWith("Could not restore the previous systemd unit.", { - restartError, - rollbackError, - }), + ), + Effect.catch((error) => + Effect.logError("Server self-update could not restart the boot service.").pipe( + Effect.annotateLogs({ targetVersion, error: error.reason }), ), - Effect.andThen(Effect.fail(restartError)), ), + Effect.ensuring(Ref.set(inFlight, false)), ), ); } else { diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0eaf5a7c16a1..dbf3651c2290 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -143,6 +143,9 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadSnooze: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), + ...(serverSelfUpdate === "boot-service" || serverSelfUpdate === "respawn" + ? { serverSelfUpdateProgress: true } + : {}), }, }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e018d4dc4b5f..a4182b4060c0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -40,6 +40,8 @@ import { ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, + type ServerSelfUpdateError, + type ServerSelfUpdateProgressEvent, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -1378,6 +1380,33 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverUpdateServerWithProgress]: (input) => + observeRpcStream( + WS_METHODS.serverUpdateServerWithProgress, + Stream.callback((queue) => + serverSelfUpdate + .update(input, (stage) => + Queue.offer(queue, { + type: "progress", + stage, + }).pipe(Effect.asVoid), + ) + .pipe( + Effect.flatMap((result) => + Queue.offer(queue, { + type: "complete", + result, + }), + ), + Effect.catchTags({ + ServerSelfUpdateError: (error) => Queue.fail(queue, error), + }), + Effect.andThen(Queue.end(queue)), + Effect.forkScoped, + ), + ), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9742925973c7..db3498a66a32 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -296,7 +296,7 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ServerUpdateAction } from "./ServerUpdateAction"; +import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, @@ -1876,12 +1876,16 @@ function ChatViewContent(props: ChatViewProps) { hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; - const versionMismatchEnvironmentId = - versionMismatch && activeThread ? activeThread.environmentId : null; + const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const serverUpdateState = useAtomValue( + serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), + ); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; - if (activeEnvironmentUnavailableState) { + const resumingServerUpdate = + serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; + if (activeEnvironmentUnavailableState && !resumingServerUpdate) { const connection = activeEnvironmentUnavailableState.connection; const isReconnecting = connection.phase === "connecting" || connection.phase === "reconnecting"; @@ -1918,39 +1922,57 @@ function ChatViewContent(props: ChatViewProps) { }); } if ( - showVersionMismatchBanner && - versionMismatch && - versionMismatchDismissKey && - versionMismatchEnvironmentId + serverUpdateEnvironmentId && + (serverUpdateState.status !== "idle" || + (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) ) { + const updateInProgress = serverUpdateState.status === "running"; + const updateFailed = serverUpdateState.status === "failed"; items.push({ - id: `version-mismatch:${versionMismatchDismissKey}`, - variant: "warning", + id: `server-version:${serverUpdateEnvironmentId}`, + variant: updateFailed ? "error" : "warning", icon: , - title: "Client and server versions differ", - description: ( - <> - Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} - {versionMismatch.serverVersion}.{" "} - {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} - - ), + title: + updateInProgress || updateFailed + ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` + : "Client and server versions differ", + description: + updateInProgress || updateFailed ? ( + + ) : versionMismatch ? ( + <> + Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} + {versionMismatch.serverVersion}.{" "} + {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} + + ) : null, // The desktop-managed guidance is already the description; the action // slot would only repeat it. actions: + updateInProgress || + !versionMismatch || versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( ), - dismissLabel: "Dismiss version mismatch warning", - onDismiss: () => { - dismissVersionMismatch(versionMismatchDismissKey); - setDismissedVersionMismatchKey(versionMismatchDismissKey); - }, + ...(updateInProgress || updateFailed || !versionMismatchDismissKey + ? {} + : { + dismissLabel: "Dismiss version mismatch warning", + onDismiss: () => { + dismissVersionMismatch(versionMismatchDismissKey); + setDismissedVersionMismatchKey(versionMismatchDismissKey); + }, + }), }); } return items; @@ -1960,9 +1982,10 @@ function ChatViewContent(props: ChatViewProps) { navigate, setDismissedVersionMismatchKey, showVersionMismatchBanner, + serverUpdateState, versionMismatch, versionMismatchDismissKey, - versionMismatchEnvironmentId, + serverUpdateEnvironmentId, versionMismatchSelfUpdate, versionMismatchServerLabel, ]); diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index c0ba1c693d49..17e44b8bae6b 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,71 +1,15 @@ -import type { Dispatch, ReactElement, SetStateAction } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), })); -const hooks = vi.hoisted(() => { - let cursor = 0; - let slots: unknown[] = []; - const nextIndex = () => cursor++; - - return { - beginRender() { - cursor = 0; - }, - reset() { - cursor = 0; - slots = []; - }, - useEffect() { - nextIndex(); - }, - useMemoCache(size: number): unknown[] { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); - } - return slots[index] as unknown[]; - }, - useRef(initialValue: T): { current: T } { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = { current: initialValue }; - } - return slots[index] as { current: T }; - }, - useState(initialValue: T | (() => T)): [T, Dispatch>] { - const index = nextIndex(); - if (index >= slots.length) { - slots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setValue: Dispatch> = (nextValue) => { - const previous = slots[index] as T; - slots[index] = - typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; - }; - return [slots[index] as T, setValue]; - }, - }; -}); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useEffect: hooks.useEffect, - useRef: hooks.useRef, - useState: hooks.useState, - }; -}); - -vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("~/hooks/useCopyToClipboard", () => ({ useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), })); @@ -79,120 +23,123 @@ vi.mock("./ui/toast", () => ({ toastManager: { add: testState.toast }, })); -import { ServerUpdateAction } from "./ServerUpdateAction"; +import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; type ActionElement = ReactElement<{ - readonly disabled?: boolean; readonly onClick?: () => void; }>; function renderAction(): ActionElement { - hooks.beginRender(); return ServerUpdateAction({ environmentId: "env-test" as EnvironmentId, serverLabel: "Test server", selfUpdate: "boot-service", - targetVersion: "0.0.29", + targetVersion: "0.0.31", }) as ActionElement; } -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((complete) => { - resolve = complete; - }); - return { promise, resolve }; -} - async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); } describe("ServerUpdateAction", () => { beforeEach(() => { - vi.useFakeTimers(); - hooks.reset(); testState.updateServer.mockReset(); testState.toast.mockReset(); }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("starts a fresh reconnect timeout after a long install succeeds", async () => { - const update = - deferred< - ReturnType> - >(); - testState.updateServer.mockReturnValue(update.promise); + it("reports success only after the shared update flow reconnects", async () => { + testState.updateServer.mockResolvedValue( + AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), + ); renderAction().props.onClick?.(); - expect(renderAction().props.disabled).toBe(true); - - await vi.advanceTimersByTimeAsync(11 * 60_000); - update.resolve( - AsyncResult.success({ - targetVersion: "0.0.29", - method: "boot-service", - }), - ); await flushPromises(); - // The click-based deadline would have fired by now. Success gets a fresh - // twelve-minute reconnect window, so the action remains disabled. - await vi.advanceTimersByTimeAsync(2 * 60_000); - expect(renderAction().props.disabled).toBe(true); - expect(testState.toast).not.toHaveBeenCalledWith( - expect.objectContaining({ title: "Server update timed out" }), - ); - - await vi.advanceTimersByTimeAsync(10 * 60_000); - expect(renderAction().props.disabled).not.toBe(true); - expect(testState.toast).toHaveBeenCalledWith( - expect.objectContaining({ title: "Server update timed out" }), - ); + expect(testState.updateServer).toHaveBeenCalledWith({ + environmentId: "env-test", + input: { targetVersion: "0.0.31" }, + }); + expect(testState.toast).toHaveBeenCalledWith({ + type: "success", + title: "Test server updated", + description: "Reconnected on t3@0.0.31.", + }); }); - it("does not let an expired request clear a newer retry", async () => { - const first = deferred>(); - const retry = - deferred< - ReturnType> - >(); - testState.updateServer.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise); - - renderAction().props.onClick?.(); - await vi.advanceTimersByTimeAsync(12 * 60_000); - expect(renderAction().props.disabled).not.toBe(true); - - renderAction().props.onClick?.(); - expect(renderAction().props.disabled).toBe(true); - - first.resolve(AsyncResult.failure(Cause.fail(new Error("first request failed late")))); - await flushPromises(); + it("reports one result when the update action is double-clicked", async () => { + let finishUpdate: (() => void) | undefined; + testState.updateServer.mockImplementation( + () => + new Promise((resolve) => { + finishUpdate = () => + resolve( + AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), + ); + }), + ); - expect(renderAction().props.disabled).toBe(true); - expect(testState.updateServer).toHaveBeenCalledTimes(2); + const action = renderAction(); + action.props.onClick?.(); + action.props.onClick?.(); - retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" })); + expect(testState.updateServer).toHaveBeenCalledTimes(1); + finishUpdate?.(); await flushPromises(); - expect(renderAction().props.disabled).toBe(true); + expect(testState.toast).toHaveBeenCalledTimes(1); }); - it("quietly releases the action when a restart RPC is interrupted", async () => { + it("quietly releases the action when the operation is interrupted", async () => { testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); renderAction().props.onClick?.(); await flushPromises(); - expect(renderAction().props.disabled).not.toBe(true); - expect(testState.toast).not.toHaveBeenCalledWith( - expect.objectContaining({ title: "Server update failed" }), + expect(testState.toast).not.toHaveBeenCalled(); + }); +}); + +describe("ServerUpdateProgress", () => { + it("renders the chosen horizontal step rail without an animated spinner", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("0.0.30"); + expect(markup).toContain("0.0.31"); + expect(markup).toContain("Download"); + expect(markup).toContain("Install"); + expect(markup).toContain("Resume"); + expect(markup).toContain("Waiting for bb-1 to accept commands."); + expect(markup).not.toContain("animate-spin"); + }); + + it("keeps the failed stage visible with its retryable error", () => { + const markup = renderToStaticMarkup( + , ); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain("The package could not be verified."); }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 5e82c4e4d936..929ea2e56c7f 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,55 +1,147 @@ -import { useEffect, useRef, useState } from "react"; import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { CheckIcon } from "lucide-react"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { cn } from "~/lib/utils"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; -import { Spinner } from "./ui/spinner"; import { toastManager } from "./ui/toast"; -/** - * The npm install on the server side is capped at 10 minutes; expire the - * spinner a bit beyond that so a dead transport never strands a disabled - * button, while a legitimately slow install is never cut off. - */ -const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; +const UPDATE_STEPS = [ + { stage: "downloading", label: "Download" }, + { stage: "installing", label: "Install" }, + { stage: "resuming", label: "Resume" }, +] as const; +const pendingUpdateEnvironmentIds = new Set(); function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } +function updateStatusCopy( + state: Exclude, + serverLabel: string, +): string { + if (state.status === "failed") { + return state.message; + } + switch (state.stage) { + case "downloading": + return "Downloading the matching server version."; + case "installing": + return `Installing and verifying t3@${state.targetVersion}.`; + case "resuming": + return `Waiting for ${serverLabel} to accept commands.`; + } +} + +export function ServerUpdateProgress({ + fromVersion, + serverLabel, + state, +}: { + readonly fromVersion: string; + readonly serverLabel: string; + readonly state: Exclude; +}) { + const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage); + + return ( +
+

+ {fromVersion} {state.targetVersion} +

+
    + {UPDATE_STEPS.map((step, index) => { + const complete = index < currentIndex; + const current = index === currentIndex; + const failed = current && state.status === "failed"; + return ( +
  1. + + {step.label} + {index < UPDATE_STEPS.length - 1 ? ( +
  2. + ); + })} +
+

+ {updateStatusCopy(state, serverLabel)} +

+
+ ); +} + /** - * The call-to-action for a version-skewed server, matched to the update path - * it advertises: a one-click install-and-restart for servers that can update - * themselves, an update-the-desktop-app hint for desktop-managed backends - * (running `npx t3` there would start a second server, not update this one), - * and copying the manual relaunch command for everything else — so the skew - * warning always offers a way out. + * Offers the update path advertised by a version-skewed server. Self-updates + * delegate their full lifecycle to client-runtime so this component can + * unmount during reconnect without losing operation state. */ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, targetVersion, + label = "Update server", }: { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; readonly targetVersion: string; + readonly label?: string; }) { const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false, }); - const [pending, setPending] = useState(false); - const inFlightRef = useRef(false); - const attemptRef = useRef(0); - const expiryRef = useRef | null>(null); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: "update command", onCopy: ({ command }) => { @@ -68,107 +160,35 @@ export function ServerUpdateAction({ }, }); - useEffect( - () => () => { - if (expiryRef.current !== null) { - clearTimeout(expiryRef.current); - expiryRef.current = null; - } - attemptRef.current += 1; - inFlightRef.current = false; - }, - [], - ); - - const handleUpdate = () => { - // Synchronous re-entry guard: setPending is async, so a rapid - // double-click would otherwise dispatch two updates. - if (inFlightRef.current) { + const handleUpdate = async () => { + if (pendingUpdateEnvironmentIds.has(environmentId)) { return; } - inFlightRef.current = true; - const attempt = attemptRef.current + 1; - attemptRef.current = attempt; - const ownsAttempt = () => attemptRef.current === attempt; - setPending(true); - const armExpiry = () => { - const expiry = setTimeout(() => { - if (!ownsAttempt()) return; - expiryRef.current = null; - attemptRef.current += 1; - inFlightRef.current = false; - setPending(false); - toastManager.add({ - type: "error", - title: "Server update timed out", - description: "The update may still be running on the server — check again in a minute.", - }); - }, UPDATE_PENDING_EXPIRY_MS); - expiryRef.current = expiry; - return expiry; - }; - let expiry = armExpiry(); - let restartAccepted = false; - const keepPendingForRestart = () => { - restartAccepted = true; - if (expiryRef.current === expiry) { - clearTimeout(expiry); - expiry = armExpiry(); - } - }; - void Promise.resolve() - .then(() => - updateServer({ - environmentId, - input: { targetVersion }, - }), - ) - .then((result) => { - if (!ownsAttempt()) return; - if (result._tag === "Failure") { - // An interrupt may be the expected boot-service disconnect, but it - // can also be client-side cancellation before restart was accepted. - // Release the action quietly; version sync will remove it when a - // successful replacement reconnects. - if (isAtomCommandInterrupted(result)) { - return; - } - toastManager.add({ - type: "error", - title: "Server update failed", - description: updateFailureMessage(squashAtomCommandFailure(result)), - }); + pendingUpdateEnvironmentIds.add(environmentId); + try { + const result = await updateServer({ + environmentId, + input: { targetVersion }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { return; } - keepPendingForRestart(); - // Installation can legitimately consume most of the request window. - // Give restart/reconnect a fresh full window after the server accepts - // the handoff instead of expiring based on the original click time. - toastManager.add({ - type: "success", - title: `Updating ${serverLabel}`, - description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`, - }); - }) - .catch((error: unknown) => { - if (!ownsAttempt()) return; toastManager.add({ type: "error", title: "Server update failed", - description: updateFailureMessage(error), + description: updateFailureMessage(squashAtomCommandFailure(result)), }); - }) - .finally(() => { - // A successful RPC only acknowledges that restart is scheduled. Keep - // the action disabled until version sync unmounts it, or until the - // safety expiry reports that reconnection never arrived. - if (restartAccepted || !ownsAttempt() || expiryRef.current !== expiry) return; - expiryRef.current = null; - clearTimeout(expiry); - attemptRef.current += 1; - inFlightRef.current = false; - setPending(false); + return; + } + toastManager.add({ + type: "success", + title: `${serverLabel} updated`, + description: `Reconnected on t3@${result.value.targetVersion}.`, }); + } finally { + pendingUpdateEnvironmentIds.delete(environmentId); + } }; if (selfUpdate === "desktop-managed") { @@ -188,14 +208,9 @@ export function ServerUpdateAction({ ); } - return pending ? ( - - ) : ( + return ( ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index aa8fc7705838..8fa1a6de7a02 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -7,6 +7,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; import { AuthAccessReadScope, @@ -131,8 +132,9 @@ import { usePrimaryEnvironment, } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; +import { serverEnvironment } from "~/state/server"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; -import { ServerUpdateAction } from "../ServerUpdateAction"; +import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; @@ -1389,6 +1391,9 @@ function SavedBackendListRow({ [copyTraceIdToClipboard], ); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); + const resumingServerUpdate = + serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; const sshTarget = environment.entry.target._tag === "SshConnectionTarget" && Option.isSome(environment.entry.profile) && @@ -1425,14 +1430,22 @@ function SavedBackendListRow({ {metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

) : null} - {versionMismatch ? ( + {serverUpdateState.status !== "idle" ? ( +
+ +
+ ) : versionMismatch ? (

Version drift: client {versionMismatch.clientVersion}, server{" "} {versionMismatch.serverVersion}.

) : null} - {environment.connection.error ? ( + {environment.connection.error && !resumingServerUpdate ? (

{connectionStatusText(environment.connection)} {errorTraceId ? ( @@ -1448,12 +1461,14 @@ function SavedBackendListRow({ ) : null}

- {versionMismatch ? ( + {versionMismatch && + (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? ( ) : null} {isWslEnvironment ? ( @@ -1834,6 +1849,9 @@ export function ConnectionsSettings() { >(null); const primaryServerConfig = primaryEnvironment?.serverConfig ?? null; const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); + const primaryServerUpdateState = useAtomValue( + serverEnvironment.updateStateAtom(primaryEnvironmentId), + ); const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false); const defaultAdvertisedEndpointKey = useUiStateStore( (state) => state.defaultAdvertisedEndpointKey, @@ -2969,24 +2987,43 @@ export function ConnectionsSettings() { {canManageLocalBackend ? ( <> - {primaryVersionMismatch ? ( + {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( - - Client {primaryVersionMismatch.clientVersion}, server{" "} - {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects - fail. - + primaryServerUpdateState.status !== "idle" ? ( + + ) : primaryVersionMismatch ? ( + + + Client {primaryVersionMismatch.clientVersion}, server{" "} + {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects + fail. + + ) : null } control={ - primaryEnvironmentId !== null ? ( + primaryVersionMismatch && + primaryEnvironmentId !== null && + primaryServerUpdateState.status !== "running" ? ( ) : undefined } diff --git a/docs/architecture/server-updates.md b/docs/architecture/server-updates.md index 2e8c9d6ef610..981f4e1eb420 100644 --- a/docs/architecture/server-updates.md +++ b/docs/architecture/server-updates.md @@ -13,8 +13,9 @@ The feature has three boundaries: ## Detection and Presentation `ExecutionEnvironmentDescriptor` includes the server version and an optional -`capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after -loading server config. +`capabilities.serverSelfUpdate` value. Progress-capable servers also advertise +`capabilities.serverSelfUpdateProgress`. The client compares the server version with `APP_VERSION` +after loading server config. The optional capability is intentionally backward compatible. An older server does not know about the field, so a missing value means the client must offer a manual relaunch instead of sending an @@ -28,6 +29,10 @@ The shared `ServerUpdateAction` is rendered in both user-facing version-drift su Both surfaces target the client's exact version. When the reconnected server reports that version, the mismatch and action disappear. +The operation state lives in `packages/client-runtime`, keyed by environment. Both web surfaces read +the same `downloading`, `installing`, or `resuming` state, so route changes do not own or cancel the +operation. + ## Capability Selection The server resolves its capability once at startup and publishes it in the environment descriptor. @@ -51,20 +56,25 @@ flowchart TD A[Client detects different versions] --> B{Advertised update path} B -->|desktop-managed| C[Update desktop app on server machine] B -->|missing| D[Copy exact manual relaunch command] - B -->|boot-service or respawn| E[server.updateServer] - E --> F[Install exact t3 version in pinned runtime] - F --> G[Run version preflight] - G -->|fails| H[Remove failed runtime and keep current server] - G -->|passes| I{Handoff method} - I -->|boot-service| J[Rewrite and restart T3 systemd unit] - I -->|respawn| K[Start delayed replacement and exit current process] - J --> L[Client reconnects] - K --> L + B -->|boot-service or respawn| E{Progress capability} + E -->|present| F[server.updateServerWithProgress] + E -->|missing| G[server.updateServer fallback] + F --> H[Download exact t3 version] + G --> H + H --> I[Install and run version preflight] + I -->|fails| J[Remove failed runtime and keep current server] + I -->|passes| K{Handoff method} + K -->|boot-service| L[Rewrite and restart T3 systemd unit] + K -->|respawn| M[Start delayed replacement and exit current process] + L --> N[Reconnect with fresh backoff] + M --> N + N --> O[Replacement publishes ready at target version] ``` -`server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its +Both update RPCs require the environment's `orchestration:operate` authorization scope. Their payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as -`latest` and `nightly` are rejected. +`latest` and `nightly` are rejected. The unary `server.updateServer` method remains available so a +new client can still repair skew with an older server. The update service permits one update at a time. It installs `t3@` under `/runtime/versions/` and writes an install-complete sentinel only after npm exits @@ -89,20 +99,20 @@ authorization; it does not uninstall the host service. ## Process Handoff For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified -runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous -unit before returning an error. +runtime and reloads systemd. It acknowledges the handoff, then restarts the unit after the same +short grace period used by foreground respawn. A rejected deferred restart restores the previous +unit and is logged by the still-running process. For `respawn`, the server starts a detached, delayed replacement that replays the original CLI arguments. It then acknowledges the request and schedules the current process to exit. The delays give the acknowledgement time to cross direct or relayed connections before the socket closes. -There is no separate progress stream. The update request remains pending while npm installs and the -client shows a disabled update action. A restart can interrupt the request normally; the connection -runtime keeps the environment registered and reconnects through its usual retry path. After an -acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a -safety timeout releases it. If a boot-service restart closes the connection before acknowledgement, -the UI releases the interrupted action without reporting a false update failure and lets reconnect -and the next version check determine the result. +Progress-capable servers emit `downloading` before installing the pinned runtime and `installing` +before preflight and handoff. A terminal stream event acknowledges that restart is scheduled. The +client then enters `resuming`, waits for the replacement lifecycle stream to publish `ready` with +the target version, and only then completes the operation. It watches for the intentional +disconnect's first backoff state and requests one fresh retry, which clears historical backoff debt +without adding a separate reconnect loop. ## Release Invariant diff --git a/docs/user/server-updates.md b/docs/user/server-updates.md index 6a8e33977b7e..27577c6f948b 100644 --- a/docs/user/server-updates.md +++ b/docs/user/server-updates.md @@ -31,6 +31,11 @@ The update does not remove saved threads, settings, or project files. The available action depends on how that server was started. T3 Code does not update connected servers silently in the background. +After selecting **Update server**, the warning becomes a three-step progress rail: +**Download**, **Install**, and **Resume**. The same progress appears in the conversation and in +Connections, so navigating between them does not lose the update. A failed step remains visible +with its error and an option to retry. + If the server uses the T3 Code background service, you can also update it directly on the host: ```sh @@ -42,11 +47,11 @@ commands. ## After the Update -Keep the web or desktop app open while the server restarts. When it reconnects with the matching -version, the warning and update action disappear. +Keep the web or desktop app open while the server restarts. The update completes only after the +replacement server reports the requested version and is ready to accept commands. The warning and +progress rail then disappear. -If the client reports a timeout, the server may still be finishing the update. Wait a minute, then -reconnect or open **Settings** → **Connections** again. If the warning remains: +If a step fails: 1. Retry the offered action once. 2. Make sure you updated the machine named in the warning, not only the device you are using. diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index b31ea9b4fc95..a925859049ff 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -513,6 +513,43 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("explicit retry starts a fresh backoff sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.fail(transient()), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("1 second"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + + yield* supervisor.retryNow; + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + + yield* TestClock.adjust("999 millis"); + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + yield* TestClock.adjust("1 milli"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(4); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("keeps blocked failures idle until an external signal requests another attempt", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index e4ac359e5b16..2a9c7519072b 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -231,6 +231,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }; const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); + const resetRetryState = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired ? availableState(initialIntent, 0) @@ -643,6 +644,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }; for (;;) { + if (yield* Ref.getAndSet(resetRetryState, false)) { + failureCount = 0; + latestFailure = null; + pendingRetry = Option.none(); + } const currentIntent = yield* Ref.get(intent); if (!currentIntent.desired) { resetRetryLadder(); @@ -763,7 +769,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( Effect.withSpan("EnvironmentSupervisor.disconnect"), ); - const retryNow = signal({ _tag: "RetryRequested" }).pipe( + const retryNow = Ref.set(resetRetryState, true).pipe( + Effect.andThen(signal({ _tag: "RetryRequested" })), Effect.withSpan("EnvironmentSupervisor.retryNow"), ); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 8013a1358967..bfe57a6c0dd5 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag = export type EnvironmentStreamCommandRpcTag = | typeof WS_METHODS.cloudInstallRelayClient + | typeof WS_METHODS.serverUpdateServerWithProgress | typeof WS_METHODS.gitRunStackedAction; export type EnvironmentStreamRpcTag = @@ -80,7 +81,7 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ }), }) {} -const isRpcClientError = Schema.is(RpcClientError.RpcClientError); +export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); export type EnvironmentRpcInput = Parameters>[0]; diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 7584e55d52eb..f36087ebf66a 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -13,6 +13,7 @@ import { environmentRpcKey, createAtomCommandScheduler, createRuntimeCommand, + scheduleAtomCommandEffect, executeAtomCommand, executeAtomQuery, isAtomCommandInterrupted, @@ -399,6 +400,54 @@ describe("runtime command runner", () => { registry.dispose(); }); + it.effect("releases a shared scheduler lane before the outer command finishes", () => + Effect.gen(function* () { + const handoffStarted = Latch.makeUnsafe(); + const handoffComplete = Latch.makeUnsafe(); + const resumeComplete = Latch.makeUnsafe(); + const runtime = Atom.runtime(Layer.empty); + const scheduler = createAtomCommandScheduler(); + const concurrency = { mode: "serial" as const, key: () => "shared" }; + const updateCommand = createRuntimeCommand(runtime, { + label: "test.update", + execute: (_input: void, registry) => + scheduleAtomCommandEffect( + registry, + scheduler, + concurrency, + undefined, + Effect.sync(() => handoffStarted.openUnsafe()).pipe( + Effect.andThen(handoffComplete.await), + ), + ).pipe(Effect.andThen(resumeComplete.await)), + }); + const configCommand = createRuntimeCommand(runtime, { + label: "test.config", + scheduler, + concurrency, + execute: () => Effect.succeed("configured"), + }); + const registry = AtomRegistry.make(); + + const update = updateCommand.run(registry, undefined); + yield* handoffStarted.await; + const config = configCommand.run(registry, undefined); + handoffComplete.openUnsafe(); + + expect(yield* Effect.promise(() => config)).toMatchObject({ + _tag: "Success", + value: "configured", + waiting: false, + }); + resumeComplete.openUnsafe(); + expect(yield* Effect.promise(() => update)).toMatchObject({ + _tag: "Success", + waiting: false, + }); + registry.dispose(); + }), + ); + it("deduplicates single-flight commands by key", async () => { const latch = Latch.makeUnsafe(); let executions = 0; diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index fb5e9a0ab556..0a00e919c4ba 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -253,6 +253,28 @@ export function createAtomCommandScheduler(): AtomCommandScheduler { }; } +/** Runs one effect inside an existing command scheduler lane. */ +export function scheduleAtomCommandEffect( + registry: AtomRegistry.AtomRegistry, + scheduler: AtomCommandScheduler, + concurrency: AtomCommandConcurrency, + input: W, + effect: Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const context = yield* Effect.context(); + const result = yield* Effect.promise((signal) => + scheduler.schedule(registry, concurrency, input, async () => { + const exit = await Effect.runPromiseExitWith(context)(effect, { signal }); + return Exit.isSuccess(exit) + ? AsyncResult.success(exit.value) + : AsyncResult.failure(exit.cause); + }), + ); + return result._tag === "Success" ? result.value : yield* Effect.failCause(result.cause); + }); +} + export async function runAtomCommand( registry: AtomRegistry.AtomRegistry, command: AtomCommand, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index ddc8813316fc..12925a998677 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -6,11 +6,15 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import { RpcClientError } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; import { AVAILABLE_CONNECTION_STATE, @@ -24,8 +28,12 @@ import type { RpcSession } from "../rpc/session.ts"; import { applyServerConfigProjection, makeEnvironmentServerConfigState, + isLegacyUpdateHandoffLoss, projectServerWelcome, resolveServerConfigValue, + resolveServerUpdateProgressResult, + serverUpdateStateForProgressEvent, + serverUpdateStateForServerVersion, } from "./server.ts"; const CONFIG = { @@ -62,6 +70,109 @@ function session(client: WsRpcProtocolClient): RpcSession { } describe("server state projection", () => { + it("only treats a legacy transport interruption as an unacknowledged handoff", () => { + expect(isLegacyUpdateHandoffLoss(Cause.interrupt(1))).toBe(true); + expect( + isLegacyUpdateHandoffLoss( + Cause.fail( + new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }), + ), + ), + ).toBe(true); + expect( + isLegacyUpdateHandoffLoss( + Cause.fail( + new RpcClientError.RpcClientError({ + reason: new Socket.SocketOpenError({ + kind: "Unknown", + cause: new Error("connection refused"), + }), + }), + ), + ), + ).toBe(false); + expect( + isLegacyUpdateHandoffLoss( + Cause.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "incompatible protocol", + cause: new Error("invalid response"), + }), + }), + ), + ), + ).toBe(false); + expect(isLegacyUpdateHandoffLoss(Cause.fail(new Error("Install failed.")))).toBe(false); + }); + + it.effect("resumes after the progress stream disconnects following completion", () => { + const result = { + targetVersion: "0.0.31", + method: "respawn" as const, + }; + const disconnect = new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }); + + return Effect.gen(function* () { + const resumed = yield* resolveServerUpdateProgressResult( + result.targetVersion, + Option.some(result), + Exit.fail(disconnect), + ); + expect(resumed).toEqual(result); + }); + }); + + it("projects streamed update milestones into the shared operation state", () => { + expect( + serverUpdateStateForProgressEvent("0.0.30", "0.0.31", { + type: "progress", + stage: "installing", + }), + ).toEqual({ + status: "running", + stage: "installing", + fromVersion: "0.0.30", + targetVersion: "0.0.31", + }); + expect( + serverUpdateStateForProgressEvent("0.0.30", "0.0.31", { + type: "complete", + result: { targetVersion: "0.0.31", method: "respawn" }, + }), + ).toEqual({ + status: "running", + stage: "resuming", + fromVersion: "0.0.30", + targetVersion: "0.0.31", + }); + }); + + it("keeps active update state and hides stale failures after a version change", () => { + const running = { + status: "running" as const, + stage: "resuming" as const, + fromVersion: "0.0.30", + targetVersion: "0.0.31", + }; + const failed = { + status: "failed" as const, + stage: "installing" as const, + fromVersion: "0.0.30", + targetVersion: "0.0.31", + message: "Install failed.", + }; + + expect(serverUpdateStateForServerVersion(running, "0.0.31")).toBe(running); + expect(serverUpdateStateForServerVersion(failed, "0.0.30")).toBe(failed); + expect(serverUpdateStateForServerVersion(failed, null)).toBe(failed); + expect(serverUpdateStateForServerVersion(failed, "0.0.31")).toEqual({ status: "idle" }); + }); + it("applies every config category to the projected snapshot", () => { const snapshot = applyServerConfigProjection(Option.none(), { version: 1, @@ -100,9 +211,16 @@ describe("server state projection", () => { }); it("prefers an active session config over cache until a live event arrives", () => { - const cached = { ...CONFIG, settings: { source: "cache" } } as unknown as ServerConfig; - const initial = { ...CONFIG, settings: { source: "session" } } as unknown as ServerConfig; - const live = { ...CONFIG, settings: { source: "live" } } as unknown as ServerConfig; + const config = (source: string, serverVersion: string) => + ({ + ...CONFIG, + environment: { serverVersion }, + settings: { source }, + }) as unknown as ServerConfig; + const cached = config("cache", "0.0.29"); + const staleLive = config("stale-live", "0.0.29"); + const initial = config("session", "0.0.30"); + const live = config("live", "0.0.30"); expect( resolveServerConfigValue( @@ -114,6 +232,16 @@ describe("server state projection", () => { initial, ), ).toBe(initial); + expect( + resolveServerConfigValue( + { + config: staleLive, + latestEvent: snapshotEvent(staleLive), + source: "live", + }, + initial, + ), + ).toBe(initial); expect( resolveServerConfigValue( { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5f93a3edb6e7..edd1893f7399 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -3,13 +3,19 @@ import { type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, + type ServerSelfUpdateProgressEvent, + type ServerSelfUpdateResult, WS_METHODS, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -19,14 +25,148 @@ import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, + createRuntimeCommand, + scheduleAtomCommandEffect, } from "./runtime.ts"; -import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { + isRpcClientError, + request, + runStream, + subscribe, + type EnvironmentRpcInput, +} from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; +export type ServerUpdateStage = "downloading" | "installing" | "resuming"; + +export type ServerUpdateState = + | { readonly status: "idle" } + | { + readonly status: "running"; + readonly stage: ServerUpdateStage; + readonly fromVersion: string; + readonly targetVersion: string; + } + | { + readonly status: "failed"; + readonly stage: ServerUpdateStage; + readonly fromVersion: string; + readonly targetVersion: string; + readonly message: string; + }; + +export interface ServerUpdateTarget { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; +} + +const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; +const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( + Atom.withLabel("environment-data:server:update-state:empty"), +); +const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( + Atom.withLabel(`environment-data:server:update-state:${environmentId}`), + ), +); + +export class ServerUpdateResumeTimeoutError extends Schema.TaggedErrorClass()( + "ServerUpdateResumeTimeoutError", + { + environmentId: Schema.String, + targetVersion: Schema.String, + }, +) { + override get message(): string { + return `The server did not resume on t3@${this.targetVersion}.`; + } +} + +export class ServerUpdateProgressIncompleteError extends Schema.TaggedErrorClass()( + "ServerUpdateProgressIncompleteError", + { + targetVersion: Schema.String, + }, +) { + override get message(): string { + return `The t3@${this.targetVersion} update ended before the server accepted the restart.`; + } +} + +export function serverUpdateStateForProgressEvent( + fromVersion: string, + targetVersion: string, + event: ServerSelfUpdateProgressEvent, +): Extract { + return { + status: "running", + stage: event.type === "complete" ? "resuming" : event.stage, + fromVersion, + targetVersion, + }; +} + +export function serverUpdateStateForServerVersion( + state: ServerUpdateState, + serverVersion: string | null, +): ServerUpdateState { + return state.status === "idle" || + state.status === "running" || + serverVersion === null || + state.fromVersion === serverVersion + ? state + : IDLE_SERVER_UPDATE_STATE; +} + +function serverUpdateFailureMessage(error: unknown): string { + return error instanceof Error ? error.message : "Server update failed."; +} + +function isRpcSocketError(error: unknown): boolean { + if (!isRpcClientError(error)) { + return false; + } + switch (error.reason._tag) { + case "SocketReadError": + case "SocketWriteError": + case "SocketCloseError": + return true; + default: + return false; + } +} + +export function isLegacyUpdateHandoffLoss(cause: Cause.Cause): boolean { + if (Cause.hasInterruptsOnly(cause)) { + return true; + } + return ( + cause.reasons.length > 0 && + cause.reasons.every((reason) => Cause.isFailReason(reason) && isRpcSocketError(reason.error)) + ); +} + +export function resolveServerUpdateProgressResult( + targetVersion: string, + terminal: Option.Option, + streamExit: Exit.Exit, +): Effect.Effect { + if ( + Option.isSome(terminal) && + (Exit.isSuccess(streamExit) || isLegacyUpdateHandoffLoss(streamExit.cause)) + ) { + return Effect.succeed(terminal.value); + } + if (Exit.isFailure(streamExit)) { + return Effect.failCause(streamExit.cause); + } + return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); +} + export interface ServerConfigProjection { readonly config: ServerConfig; readonly latestEvent: ServerConfigStreamEvent; @@ -225,7 +365,13 @@ export function resolveServerConfigValue( projection: ServerConfigProjection | null, initialConfig: ServerConfig | null, ): ServerConfig | null { - if (projection?.source === "live") return projection.config; + if ( + projection?.source === "live" && + (initialConfig === null || + projection.config.environment.serverVersion === initialConfig.environment.serverVersion) + ) { + return projection.config; + } return initialConfig ?? projection?.config ?? null; } @@ -238,6 +384,8 @@ export function createServerEnvironmentAtoms( }, ) { const configScheduler = createAtomCommandScheduler(); + // Updates stay serial end-to-end, but only their handoff phase occupies the config lane. + const updateScheduler = createAtomCommandScheduler(); const configConcurrency = { mode: "serial" as const, key: ({ environmentId }: { readonly environmentId: string }) => environmentId, @@ -271,6 +419,179 @@ export function createServerEnvironmentAtoms( ); }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); }); + const updateStateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + serverUpdateStateForServerVersion( + get(serverUpdateStateAtom(environmentId)), + get(configValueAtom(environmentId))?.environment.serverVersion ?? null, + ), + ).pipe(Atom.withLabel(`environment-data:server:update-state-value:${environmentId}`)), + ); + const updateStateAtom = (environmentId: EnvironmentId | null) => + environmentId === null ? EMPTY_SERVER_UPDATE_STATE_ATOM : updateStateValueAtom(environmentId); + const updateServer = createRuntimeCommand< + EnvironmentRegistry | EnvironmentCacheStore | R, + E, + ServerUpdateTarget, + ServerSelfUpdateResult, + unknown + >(runtime, { + label: "environment-data:server:update-server", + scheduler: updateScheduler, + concurrency: configConcurrency, + execute: (target, atomRegistry) => { + const stateAtom = serverUpdateStateAtom(target.environmentId); + const targetVersion = target.input.targetVersion; + let fromVersion = + atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ?? + targetVersion; + let currentStage: ServerUpdateStage = "downloading"; + atomRegistry.set(stateAtom, { + status: "running", + stage: currentStage, + fromVersion, + targetVersion, + }); + + return Effect.gen(function* () { + const environmentRegistry = yield* EnvironmentRegistry; + const result = yield* scheduleAtomCommandEffect( + atomRegistry, + configScheduler, + configConcurrency, + target, + Effect.gen(function* () { + const currentConfig = atomRegistry.get(configValueAtom(target.environmentId)); + fromVersion = currentConfig?.environment.serverVersion ?? targetVersion; + atomRegistry.set(stateAtom, { + status: "running", + stage: currentStage, + fromVersion, + targetVersion, + }); + + const supportsProgress = + currentConfig?.environment.capabilities.serverSelfUpdateProgress === true; + const updateResult: ServerSelfUpdateResult = supportsProgress + ? yield* Effect.gen(function* () { + const terminal = yield* Ref.make>( + Option.none(), + ); + const streamExit = yield* environmentRegistry + .runStream( + target.environmentId, + runStream(WS_METHODS.serverUpdateServerWithProgress, target.input), + ) + .pipe( + Stream.runForEach((event) => + Effect.sync(() => { + currentStage = event.type === "complete" ? "resuming" : event.stage; + atomRegistry.set( + stateAtom, + serverUpdateStateForProgressEvent(fromVersion, targetVersion, event), + ); + }).pipe( + Effect.andThen( + event.type === "complete" + ? Ref.set(terminal, Option.some(event.result)) + : Effect.void, + ), + ), + ), + Effect.exit, + ); + return yield* resolveServerUpdateProgressResult( + targetVersion, + yield* Ref.get(terminal), + streamExit, + ); + }) + : yield* Effect.gen(function* () { + const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate; + const exit = yield* environmentRegistry + .run(target.environmentId, request(WS_METHODS.serverUpdateServer, target.input)) + .pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + return exit.value; + } + if ( + (selfUpdateMethod === "boot-service" || selfUpdateMethod === "respawn") && + isLegacyUpdateHandoffLoss(exit.cause) + ) { + // Older servers can tear down the transport before their + // unary acknowledgement arrives. Treat only that transport + // loss as a handoff, then prove it by waiting for target ready. + return { targetVersion, method: selfUpdateMethod }; + } + return yield* Effect.failCause(exit.cause); + }); + + currentStage = "resuming"; + atomRegistry.set(stateAtom, { + status: "running", + stage: currentStage, + fromVersion, + targetVersion, + }); + return updateResult; + }), + ); + + // The update restart is intentional. As soon as the supervisor sees + // that first failed connection, discard any prior backoff debt and + // retry immediately instead of carrying an old 16-second delay. + yield* environmentRegistry.stateChanges(target.environmentId).pipe( + Stream.filter((state) => state.phase === "backoff"), + Stream.take(1), + Stream.runDrain, + Effect.andThen(environmentRegistry.retryNow(target.environmentId)), + Effect.timeoutOption(Duration.seconds(30)), + Effect.ignore, + Effect.forkChild, + ); + + const resumed = yield* environmentRegistry + .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {})) + .pipe( + Stream.filter( + (event) => + event.type === "ready" && event.payload.environment.serverVersion === targetVersion, + ), + Stream.runHead, + Effect.timeoutOption(Duration.seconds(120)), + Effect.map(Option.flatten), + ); + if (Option.isNone(resumed)) { + return yield* new ServerUpdateResumeTimeoutError({ + environmentId: target.environmentId, + targetVersion, + }); + } + + atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE); + return result; + }).pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (Exit.isSuccess(exit)) { + return; + } + if (Cause.hasInterruptsOnly(exit.cause)) { + atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE); + return; + } + atomRegistry.set(stateAtom, { + status: "failed", + stage: currentStage, + fromVersion, + targetVersion, + message: serverUpdateFailureMessage(Cause.squash(exit.cause)), + }); + }), + ), + ); + }, + }); const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe( Atom.withLabel(`environment-data:server:settings:${environmentId}`), @@ -284,6 +605,7 @@ export function createServerEnvironmentAtoms( return { configValueAtom, + updateStateAtom, settingsValueAtom, providersValueAtom, traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { @@ -331,12 +653,7 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), - updateServer: createEnvironmentRpcCommand(runtime, { - label: "environment-data:server:update-server", - tag: WS_METHODS.serverUpdateServer, - scheduler: configScheduler, - concurrency: configConcurrency, - }), + updateServer, upsertKeybinding: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:upsert-keybinding", tag: WS_METHODS.serverUpsertKeybinding, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f4b6c165410..7b1769fdfc03 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -51,6 +51,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability), + /** Server can stream self-update progress before acknowledging the + restart. Clients fall back to server.updateServer when absent. */ + serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0701e15a6689..d1a5f2504c72 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -127,6 +127,7 @@ import { ServerProviderUpdatedPayload, ServerSelfUpdateError, ServerSelfUpdateInput, + ServerSelfUpdateProgressEvent, ServerSelfUpdateResult, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, @@ -218,6 +219,7 @@ export const WS_METHODS = { serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", + serverUpdateServerWithProgress: "server.updateServerWithProgress", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", @@ -305,6 +307,16 @@ export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); +export const WsServerUpdateServerWithProgressRpc = Rpc.make( + WS_METHODS.serverUpdateServerWithProgress, + { + payload: ServerSelfUpdateInput, + success: ServerSelfUpdateProgressEvent, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), + stream: true, + }, +); + export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -759,6 +771,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, + WsServerUpdateServerWithProgressRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8e42c938ca48..e083523bbdf7 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -592,6 +592,21 @@ export const ServerSelfUpdateResult = Schema.Struct({ }); export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; +export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]); +export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type; + +export const ServerSelfUpdateProgressEvent = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("progress"), + stage: ServerSelfUpdateProgressStage, + }), + Schema.Struct({ + type: Schema.Literal("complete"), + result: ServerSelfUpdateResult, + }), +]); +export type ServerSelfUpdateProgressEvent = typeof ServerSelfUpdateProgressEvent.Type; + export class ServerSelfUpdateError extends Schema.TaggedErrorClass()( "ServerSelfUpdateError", { From 7898b94777af32fb194eeea3de1b46c9f7836e2e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 07:30:24 -0700 Subject: [PATCH 05/15] fix(web): server updates no longer look like warnings (#4992) Co-authored-by: Claude Fable 5 (cherry picked from commit e0513d29839f934df1d5a13551e6ccd76d11006b) --- apps/web/src/components/ChatView.tsx | 115 ++++++++++++------ .../components/ServerUpdateAction.test.tsx | 13 +- .../web/src/components/ServerUpdateAction.tsx | 96 +++++++-------- .../components/chat/ComposerBannerStack.tsx | 2 +- .../settings/ConnectionsSettings.tsx | 2 - 5 files changed, 126 insertions(+), 102 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index db3498a66a32..4a554769ce8d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1883,46 +1883,75 @@ function ChatViewContent(props: ChatViewProps) { ); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; - const resumingServerUpdate = - serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; - if (activeEnvironmentUnavailableState && !resumingServerUpdate) { - const connection = activeEnvironmentUnavailableState.connection; - const isReconnecting = - connection.phase === "connecting" || connection.phase === "reconnecting"; - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: connection.phase === "error" ? "error" : "warning", - icon: , - title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`, - description: - connection.error ?? - "Reconnect this environment before sending messages or running actions.", - actions: ( - <> - - - - ), - }); + const updateRunning = serverUpdateState.status === "running"; + const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const environmentReconnecting = + unavailableConnection !== null && + (unavailableConnection.phase === "connecting" || + unavailableConnection.phase === "reconnecting"); + // Reconnecting to a version-skewed server with no update in flight + // usually means the server is restarting mid-update and a refresh wiped + // the in-memory update state. Fold the reconnect and version banners + // into one calm line instead of stacking "Failed to connect" on + // "versions differ". A failed update never folds: its error and retry + // action must stay visible. + const reconnectingThroughVersionSkew = + serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; + // While an update runs, transient connect blips are expected (the server + // restarts) and the update banner already shows progress. Hard failure + // phases still surface so the Reconnect action stays reachable. + const suppressUnavailableBanner = updateRunning && environmentReconnecting; + if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { + if (reconnectingThroughVersionSkew) { + items.push({ + id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, + variant: "default", + icon: ( +
-
{title}
+
+ {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
{thread.branch ? ( {thread.branch} @@ -1912,16 +1929,28 @@ export default function SidebarV2() { const count = threadKeys.length; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. - const selectionNow = new Date().toISOString(); - const snoozableThreads = threadKeys.flatMap((threadKey) => { + const selectionNow = new Date(); + const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); - const canSnoozeSelection = snoozableThreads.every( + const canSnoozeSelection = selectedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && - canSnooze(thread, { now: selectionNow }), + canSnooze(thread, { now: selectionNow.toISOString() }), ); + const titleRegenerationThreads = selectedThreads.filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true, + ); + const regeneratableTitleThreads = titleRegenerationThreads.filter( + (thread) => thread.titleRegeneration == null, + ); + const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ + supportedCount: titleRegenerationThreads.length, + actionableCount: regeneratableTitleThreads.length, + }); const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( @@ -1939,6 +1968,7 @@ export default function SidebarV2() { }, ] : []), + ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1954,7 +1984,7 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of snoozableThreads) { + for (const thread of selectedThreads) { attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { coSnoozingKeys, }); @@ -1963,6 +1993,28 @@ export default function SidebarV2() { } return; } + if (clicked.value === "regenerate-title") { + for (const thread of regeneratableTitleThreads) { + const result = await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, regenerateTitle: true }, + }); + if (result._tag === "Success") continue; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread titles", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + clearSelection(); + return; + } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -2034,6 +2086,7 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, + updateThreadMetadata, ], ); @@ -2063,6 +2116,10 @@ export default function SidebarV2() { true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const supportsTitleRegeneration = + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true; + const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). @@ -2101,6 +2158,15 @@ export default function SidebarV2() { ] : []), { id: "rename", label: "Rename thread" }, + ...(supportsTitleRegeneration + ? [ + { + id: "regenerate-title", + label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + disabled: isRegeneratingTitle, + }, + ] + : []), { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy path", icon: "copy" }, ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), @@ -2153,6 +2219,24 @@ export default function SidebarV2() { case "rename": startThreadRename(threadRef, thread.title); return; + case "regenerate-title": { + if (isRegeneratingTitle) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread title", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; @@ -2219,6 +2303,7 @@ export default function SidebarV2() { projectCwdByKey, serverConfigs, startThreadRename, + updateThreadMetadata, ], ); diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 211f8748f4e3..967c30960f9c 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { CheckpointRef, + CommandId, EventId, MessageId, ProjectId, @@ -123,8 +124,15 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.archived / thread.unarchived", () => { - it("sets archivedAt", () => { - const result = applyThreadDetailEvent(baseThread, { + it("sets archivedAt and clears title regeneration", () => { + const regeneratingThread: OrchestrationThread = { + ...baseThread, + titleRegeneration: { + requestId: CommandId.make("regenerate-title"), + startedAt: "2026-04-01T02:00:00.000Z", + }, + }; + const result = applyThreadDetailEvent(regeneratingThread, { ...baseEventFields, sequence: 3, occurredAt: "2026-04-01T03:00:00.000Z", @@ -141,6 +149,7 @@ describe("applyThreadDetailEvent", () => { expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z"); + expect(result.thread.titleRegeneration).toBeNull(); } }); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index ce0dca52f5a6..6b04f094d826 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -94,6 +94,7 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: event.payload.archivedAt, + titleRegeneration: null, updatedAt: event.payload.updatedAt, }, }; @@ -155,6 +156,9 @@ export function applyThreadDetailEvent( thread: { ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleRegeneration !== undefined + ? { titleRegeneration: event.payload.titleRegeneration } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7b1769fdfc03..5d4238994fbb 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server understands regenerateTitle on thread.meta.update. Absent on + older servers, so clients hide the action instead of sending it. */ + threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 1ebc23a483b5..ecf7afa06105 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -320,12 +320,20 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => Effect.gen(function* () { const parsed = yield* decodeThreadMetaUpdatedPayload({ threadId: "thread-1", + regenerateTitle: true, + previousTitle: "Previous title", + titleRegeneration: { + requestId: "cmd-title-regenerate", + startedAt: "2026-01-01T00:00:00.000Z", + }, modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", }, updatedAt: "2026-01-01T00:00:00.000Z", }); + assert.strictEqual(parsed.previousTitle, "Previous title"); + assert.strictEqual(parsed.titleRegeneration?.requestId, "cmd-title-regenerate"); assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent"); }), ); @@ -628,6 +636,53 @@ it.effect("accepts a title seed in thread.turn.start", () => }), ); +it.effect("accepts a title regeneration intent in thread.meta.update", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-title-regenerate", + threadId: "thread-1", + regenerateTitle: true, + }); + assert.strictEqual(parsed.type, "thread.meta.update"); + if (parsed.type === "thread.meta.update") { + assert.strictEqual(parsed.regenerateTitle, true); + } + }), +); + +it.effect("accepts an internal title regeneration completion", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.title.regeneration.complete", + commandId: "cmd-title-regeneration-complete", + threadId: "thread-1", + requestId: "cmd-title-regenerate", + title: "Updated title", + }); + assert.strictEqual(parsed.type, "thread.title.regeneration.complete"); + if (parsed.type === "thread.title.regeneration.complete") { + assert.strictEqual(parsed.requestId, "cmd-title-regenerate"); + assert.strictEqual(parsed.title, "Updated title"); + } + }), +); + +it.effect("rejects an explicit title combined with title regeneration", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-title-regenerate-with-title", + threadId: "thread-1", + title: "Explicit title", + regenerateTitle: true, + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9d45e4455473..e54c088ba3f2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -342,6 +342,12 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ThreadTitleRegeneration = Schema.Struct({ + requestId: CommandId, + startedAt: IsoDateTime, +}); +export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -367,6 +373,8 @@ export const OrchestrationThread = Schema.Struct({ // Optional so payloads from pre-snooze servers still decode. snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // Pending-only state. Optional so older servers remain compatible. + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -419,6 +427,7 @@ export const OrchestrationThreadShell = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -617,11 +626,18 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + regenerateTitle: Schema.optional(Schema.Literal(true)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), -}); +}).check( + Schema.makeFilter( + (input) => + !(input.title !== undefined && input.regenerateTitle === true) || + "title and regenerateTitle cannot be specified together", + ), +); const ThreadRuntimeModeSetCommand = Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), @@ -860,6 +876,14 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.title.regeneration.complete"), + commandId: CommandId, + threadId: ThreadId, + requestId: CommandId, + title: Schema.optional(TrimmedNonEmptyString), +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -868,6 +892,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadTitleRegenerationCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1000,6 +1025,13 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + /** Intent marker consumed by the title-generation reactor. Keeping this on + the existing event lets older clients safely ignore the new field. */ + regenerateTitle: Schema.optional(Schema.Literal(true)), + /** Title at request time, used to avoid overwriting a later manual rename. */ + previousTitle: Schema.optional(TrimmedNonEmptyString), + /** Pending state shared with clients. Null clears a matching request. */ + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From aab68ac8d4b8c6b9135faa12139821e41816e868 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 05:30:11 -0700 Subject: [PATCH 07/15] feat(search): find threads by conversation content (#4959) (cherry picked from commit 4b71a2ae2ffbbb7b6936051552094b71364cefd4) --- .../src/features/home/HomeRouteScreen.tsx | 30 +- apps/mobile/src/features/home/HomeScreen.tsx | 124 ++++++-- .../src/features/home/homeThreadList.test.ts | 28 ++ .../src/features/home/homeThreadList.ts | 13 +- .../threads/ThreadNavigationSidebar.tsx | 82 +++++- .../features/threads/thread-list-items.tsx | 18 ++ .../features/threads/thread-list-v2-items.tsx | 40 ++- .../features/threads/thread-search-match.tsx | 92 ++++++ .../src/features/threads/threadListV2.test.ts | 22 ++ .../src/features/threads/threadListV2.ts | 15 +- apps/mobile/src/state/queries.ts | 47 ++++ apps/server/src/auth/RpcAuthorization.ts | 1 + .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 265 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 132 +++++++++ .../Services/ProjectionSnapshotQuery.ts | 10 + .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/server.test.ts | 30 ++ apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/ws.ts | 15 + .../components/CommandPalette.logic.test.ts | 23 ++ .../src/components/CommandPalette.logic.ts | 17 +- apps/web/src/components/CommandPalette.tsx | 51 +++- .../src/components/CommandPaletteResults.tsx | 89 +++++- apps/web/src/state/queries.ts | 45 +++ docs/user/keybindings.md | 5 + packages/client-runtime/package.json | 4 + .../client-runtime/src/state/orchestration.ts | 6 + .../src/state/threadSearch.test.ts | 72 +++++ .../client-runtime/src/state/threadSearch.ts | 81 ++++++ packages/contracts/src/orchestration.ts | 39 +++ packages/contracts/src/rpc.ts | 9 + 34 files changed, 1345 insertions(+), 72 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-search-match.tsx create mode 100644 packages/client-runtime/src/state/threadSearch.test.ts create mode 100644 packages/client-runtime/src/state/threadSearch.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 79933e14c392..62f6e324602b 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -26,7 +26,7 @@ export function HomeRouteScreen() { const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); - const { state: catalogState } = useWorkspaceState(); + const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); @@ -39,20 +39,22 @@ export function HomeRouteScreen() { useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); - const environments = useMemo( - () => - Arr.sort( - Object.values(savedConnectionsById).map((connection) => ({ - environmentId: connection.environmentId, - label: connection.environmentLabel, - })), - Order.mapInput( - Order.String, - (environment: { readonly label: string }) => environment.label, - ), + const environments = useMemo(() => { + const connectionStateByEnvironmentId = new Map( + workspaceEnvironments.map( + (environment) => [environment.environmentId, environment.connectionState] as const, ), - [savedConnectionsById], - ); + ); + return Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + connectionState: + connectionStateByEnvironmentId.get(connection.environmentId) ?? "available", + })), + Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), + ); + }, [savedConnectionsById, workspaceEnvironments]); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cd802f05130a..60f51c153298 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -7,6 +7,10 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { + threadSearchMatchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -22,11 +26,12 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; -import type { WorkspaceState } from "../../state/workspaceModel"; +import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -72,7 +77,9 @@ interface HomeScreenProps { readonly pendingTasks: ReadonlyArray; readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly>; - readonly environments: ReadonlyArray; + readonly environments: ReadonlyArray< + HomeListFilterMenuEnvironment & Pick + >; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; @@ -194,6 +201,35 @@ export function HomeScreen(props: HomeScreenProps) { Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT : 0; + const searchEnvironmentIds = useMemo( + () => + props.selectedEnvironmentId === null + ? props.environments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId) + : props.environments.some( + (environment) => + environment.environmentId === props.selectedEnvironmentId && + environment.connectionState === "connected", + ) + ? [props.selectedEnvironmentId] + : [], + [props.environments, props.selectedEnvironmentId], + ); + const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); + const threadSearchMatchByKey = useMemo(() => { + const matches = new Map(); + for (const match of threadSearch.matches) { + if (match.source === "user" || match.source === "assistant") { + matches.set(threadSearchMatchKey(match), match); + } + } + return matches; + }, [threadSearch.matches]); + const matchedThreadKeys = useMemo( + () => new Set(threadSearch.matches.map(threadSearchMatchKey)), + [threadSearch.matches], + ); const effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); if (!AsyncResult.isSuccess(preferencesResult)) { @@ -323,6 +359,7 @@ export function HomeScreen(props: HomeScreenProps) { pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, + matchedThreadKeys, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, projectGroupingMode: props.projectGroupingMode, @@ -333,6 +370,7 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, + matchedThreadKeys, scopedPendingTasks, scopedProjects, scopedThreads, @@ -525,6 +563,7 @@ export function HomeScreen(props: HomeScreenProps) { environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, + matchedThreadKeys, changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -542,6 +581,7 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.selectedEnvironmentId, props.threads, + matchedThreadKeys, threadListV2Enabled, v2ScopedProjectGroup, ]); @@ -638,6 +678,13 @@ export function HomeScreen(props: HomeScreenProps) { ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} @@ -669,7 +716,9 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById, serverConfigs, settlementEnvironmentIds, + threadSearchMatchByKey, v2ProjectTitleByProjectKey, + props.searchQuery, ], ); const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); @@ -684,19 +733,28 @@ export function HomeScreen(props: HomeScreenProps) { projectTitleByProjectKey: v2ProjectTitleByProjectKey, serverConfigs, savedConnectionsById: props.savedConnectionsById, + searchQuery: props.searchQuery, + threadSearchMatchByKey, }), [ projectByKey, projectCwdByKey, + props.searchQuery, props.savedConnectionsById, serverConfigs, + threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], ); const extraData = useMemo( - () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), - [props.savedConnectionsById, projectCwdByKey], + () => ({ + projectCwdByKey, + savedConnectionsById: props.savedConnectionsById, + searchQuery: props.searchQuery, + threadSearchMatchByKey, + }), + [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], ); const renderItem = useCallback( @@ -749,6 +807,13 @@ export function HomeScreen(props: HomeScreenProps) { null } isLast={item.isLast} + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} onSelectThread={props.onSelectThread} @@ -779,7 +844,9 @@ export function HomeScreen(props: HomeScreenProps) { props.onNewThreadInProject, props.onSelectPendingTask, props.onSelectThread, + props.searchQuery, props.savedConnectionsById, + threadSearchMatchByKey, updateGroupDisplay, ], ); @@ -872,7 +939,7 @@ export function HomeScreen(props: HomeScreenProps) { const v2ListHeader = listHeader; const listEmpty = !hasResults ? ( - hasSearchQuery ? ( + hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( ) : selectedProjectScope !== null ? ( 0 ? ( - // The snoozed threads already passed this search filter: "No - // results" would claim nothing matched when matches are merely - // parked. + const v2ListEmpty = + hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? ( + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. + + ) : ( + + ) + ) : v2SnoozedCount > 0 ? ( + + ) : v2ScopedProjectGroup !== null ? ( ) : ( - - ) - ) : v2SnoozedCount > 0 ? ( - - ) : v2ScopedProjectGroup !== null ? ( - - ) : ( - listEmpty - ); + listEmpty + ); if (threadListV2Enabled) { return ( diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index e791cd3b36af..75964aa23d2e 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -2,6 +2,7 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -661,6 +662,33 @@ describe("buildHomeThreadGroups", () => { ); }); + it("includes a thread matched by message content", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const thread = makeThread({ + environmentId, + id: ThreadId.make("thread-content"), + projectId: project.id, + title: "Unrelated title", + }); + + const groups = buildGroups([project], [thread], { + searchQuery: "relay reconnect", + matchedThreadKeys: new Set([ + threadSearchMatchKey({ + environmentId, + threadId: thread.id, + }), + ]), + }); + + expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]); + }); + it("targets quick new threads at the group member with the newest thread", () => { const laptopEnv = EnvironmentId.make("environment-laptop"); const desktopEnv = EnvironmentId.make("environment-desktop"); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 21084f0f5fe5..5bd14086e293 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -12,6 +12,7 @@ import { sortThreads, toSortableTimestamp, } from "@t3tools/client-runtime/state/thread-sort"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ScopedProjectRef, @@ -254,6 +255,7 @@ export function buildHomeThreadGroups(input: { readonly pendingTasks?: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly searchQuery: string; + readonly matchedThreadKeys?: ReadonlySet; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; @@ -350,7 +352,16 @@ export function buildHomeThreadGroups(input: { group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); const matchingThreads = groupMatches ? group.threads - : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); + : group.threads.filter( + (thread) => + thread.title.toLocaleLowerCase().includes(query) || + input.matchedThreadKeys?.has( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) === true, + ); const matchingPendingTasks = groupMatches ? group.pendingTasks : group.pendingTasks.filter((pendingTask) => diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 30be44330123..bd2bace9311e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -3,6 +3,10 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { + threadSearchMatchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; @@ -24,6 +28,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; +import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; @@ -180,7 +185,7 @@ function ThreadNavigationSidebarPane( const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; const projects = useProjects(); const threads = useThreadShells(); - const { state: catalogState } = useWorkspaceState(); + const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const [headerIsOverContent, setHeaderIsOverContent] = useState(false); const searchInputRef = useRef(null); @@ -209,6 +214,35 @@ function ThreadNavigationSidebarPane( ); const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = useHomeListOptions(availableEnvironmentIds); + const searchEnvironmentIds = useMemo( + () => + options.selectedEnvironmentId === null + ? workspaceEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId) + : workspaceEnvironments.some( + (environment) => + environment.environmentId === options.selectedEnvironmentId && + environment.connectionState === "connected", + ) + ? [options.selectedEnvironmentId] + : [], + [options.selectedEnvironmentId, workspaceEnvironments], + ); + const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); + const threadSearchMatchByKey = useMemo(() => { + const matches = new Map(); + for (const match of threadSearch.matches) { + if (match.source === "user" || match.source === "assistant") { + matches.set(threadSearchMatchKey(match), match); + } + } + return matches; + }, [threadSearch.matches]); + const matchedThreadKeys = useMemo( + () => new Set(threadSearch.matches.map(threadSearchMatchKey)), + [threadSearch.matches], + ); const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectScopes = useMemo( () => @@ -305,11 +339,19 @@ function ThreadNavigationSidebarPane( pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, + matchedThreadKeys, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), - [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], + [ + matchedThreadKeys, + options, + props.searchQuery, + scopedPendingTasks, + scopedProjects, + scopedThreads, + ], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap @@ -433,6 +475,7 @@ function ThreadNavigationSidebarPane( environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, + matchedThreadKeys, changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -446,6 +489,7 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, options.selectedEnvironmentId, props.searchQuery, + matchedThreadKeys, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -688,6 +732,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + threadSearchMatchByKey, }), [ props.selectedThreadKey, @@ -696,6 +741,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + threadSearchMatchByKey, ], ); const sidebarItemsAreEqual = useCallback( @@ -798,6 +844,13 @@ function ThreadNavigationSidebarPane( ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} pane="sidebar" selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey @@ -877,6 +930,13 @@ function ThreadNavigationSidebarPane( null } isLast={item.isLast} + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey } @@ -915,10 +975,12 @@ function ThreadNavigationSidebarPane( projectCwdByKey, projectTitleByProjectKey, props.onNewThreadInProject, + props.searchQuery, props.selectedThreadKey, props.width, savedConnectionsById, serverConfigs, + threadSearchMatchByKey, settleThread, settlementEnvironmentIds, showMoreSettled, @@ -978,13 +1040,15 @@ function ThreadNavigationSidebarPane( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 - ? snoozedCount > 0 - ? // Snoozed matches passed this same search filter — "No - // matching threads" would misreport them as nonexistent. - snoozedCount === 1 - ? "1 matching thread snoozed" - : "All matching threads snoozed" - : "No matching threads" + ? threadSearch.isPending && snoozedCount === 0 + ? "Searching thread messages…" + : snoozedCount > 0 + ? // Snoozed matches passed this same search filter — "No + // matching threads" would misreport them as nonexistent. + snoozedCount === 1 + ? "1 matching thread snoozed" + : "All matching threads snoozed" + : "No matching threads" : snoozedCount > 0 ? snoozedCount === 1 ? "1 thread snoozed" diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index c2eccc725aeb..9ac4002a9b04 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -3,6 +3,7 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; @@ -21,6 +22,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadStatus } from "./threadPresentation"; +import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * Shared presentation for the thread lists: the compact (phone) Home list and @@ -416,6 +418,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; readonly projectCwd: string | null; + readonly searchMatch?: EnvironmentThreadSearchMatch; + readonly searchQuery?: string; readonly isLast: boolean; /** Sidebar only: the thread currently open in the detail pane. */ readonly selected?: boolean; @@ -569,6 +573,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { /> + {props.searchMatch ? ( + + ) : null} {subtitleRow} @@ -623,6 +634,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { + {props.searchMatch ? ( + + ) : null} {subtitleRow} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 2ab7e6cf9f43..6af5795a94a5 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -2,6 +2,7 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; import { Platform, Pressable, useWindowDimensions, View } from "react-native"; @@ -18,6 +19,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; +import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * Thread List v2 renders one flat native list: rich edge-to-edge rows for @@ -243,6 +245,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { state: "open" | "closed" | "merged" | null, ) => void; readonly projectCwd?: string | null; + readonly searchMatch?: EnvironmentThreadSearchMatch; + readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; @@ -369,6 +373,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {thread.title} + {props.searchMatch ? ( + + + + ) : null} {status === "failed" && thread.session?.lastError ? ( ) : null} - - {thread.title} - + + + {thread.title} + + {props.searchMatch ? ( + + ) : null} + character.toLowerCase()); +} + +function splitHighlightParts(text: string, query: string) { + const normalizedText = foldAsciiCase(text); + const normalizedQuery = foldAsciiCase(query.trim()); + if (normalizedQuery.length === 0) { + return [{ text, highlighted: false, start: 0 }]; + } + + const parts: Array<{ + readonly text: string; + readonly highlighted: boolean; + readonly start: number; + }> = []; + let cursor = 0; + while (cursor < text.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + if (matchIndex === -1) { + parts.push({ text: text.slice(cursor), highlighted: false, start: cursor }); + break; + } + if (matchIndex > cursor) { + parts.push({ + text: text.slice(cursor, matchIndex), + highlighted: false, + start: cursor, + }); + } + parts.push({ + text: text.slice(matchIndex, matchIndex + normalizedQuery.length), + highlighted: true, + start: matchIndex, + }); + cursor = matchIndex + normalizedQuery.length; + } + return parts; +} + +export function ThreadSearchMatchExcerpt(props: { + readonly match: EnvironmentThreadSearchMatch; + readonly query: string; + readonly selected?: boolean; + readonly compact?: boolean; +}) { + const isUser = props.match.source === "user"; + const parts = splitHighlightParts(props.match.snippet, props.query); + return ( + + + {isUser ? "You:" : "Agent:"}{" "} + + {parts.map((part) => ( + + {part.text} + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1b15905ef7be..90b5897f18ed 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { CommandId, EnvironmentId, @@ -263,6 +264,27 @@ describe("buildThreadListV2Items", () => { ]); }); + it("includes a thread matched by message content", () => { + const thread = makeThread({ + id: ThreadId.make("content-match"), + title: "Unrelated title", + }); + const { items } = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "relay reconnect", + matchedThreadKeys: new Set([ + threadSearchMatchKey({ + environmentId, + threadId: thread.id, + }), + ]), + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["content-match"]); + }); + it("scopes the flat list to one project", () => { const otherProjectId = ProjectId.make("project-2"); const { items } = buildThreadListV2Items({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ab955d16d4d1..920b7f0b53aa 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,5 +1,6 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -183,6 +184,7 @@ export function buildThreadListV2Items(input: { readonly projectId: ProjectId; }> | null; readonly searchQuery: string; + readonly matchedThreadKeys?: ReadonlySet; /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ readonly changeRequestStateByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on @@ -222,7 +224,18 @@ export function buildThreadListV2Items(input: { if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } - if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + if ( + query.length > 0 && + !thread.title.toLocaleLowerCase().includes(query) && + input.matchedThreadKeys?.has( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) !== true + ) { + continue; + } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index ea6259959280..b02b190db259 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,5 +1,12 @@ import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import { + createThreadSearchResultsAtomFamily, + makeThreadSearchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; +import { useAtomValue } from "@effect/atom-react"; import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; import { useEffect, useMemo, useState } from "react"; import { orchestrationEnvironment } from "./orchestration"; @@ -15,7 +22,22 @@ import { const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; const COMPOSER_PATH_SEARCH_LIMIT = 20; +const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; +const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ + matches: EMPTY_THREAD_SEARCH_MATCHES, + isLoading: false, +}).pipe(Atom.withLabel("mobile:thread-search:empty")); + +const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId, query) => + orchestrationEnvironment.threadSearch({ + environmentId, + input: { query }, + }), + labelPrefix: "mobile:thread-search", +}); export interface ThreadDetailView { readonly data: OrchestrationThread | null; @@ -45,6 +67,31 @@ function useDebouncedValue(value: A, delayMs: number): A { return debounced; } +export function useThreadSearch( + environmentIds: ReadonlyArray, + query: string, +): { + readonly matches: ReadonlyArray; + readonly isPending: boolean; +} { + const normalizedQuery = query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); + const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2; + const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; + const searchKey = useMemo( + () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)), + [environmentIds, settledQuery], + ); + const result = useAtomValue( + searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), + ); + const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + return { + matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + isPending: canSearch && (isDebouncing || result.isLoading), + }; +} + export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index b4bea21d2e36..80b1cb4aa1fe 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -24,6 +24,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d51..fe093c451e25 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -108,6 +108,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -352,6 +355,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -405,6 +409,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index d97826b251a7..d5f64455fa5f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 12afffea7e7d..6fe7f831a032 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1551,6 +1551,271 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.equal(shellSnapshot.threads.length, 0); }), ); + + it.effect("searches active user messages and canonical assistant outputs", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_projects`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-search', + 'Project Needle', + '/tmp/project-search', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-05-01T00:00:00.000Z', + '2026-05-01T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-active', + 'project-search', + 'Literal 100% fix', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + 'search-branch', + NULL, + 'turn-active', + '2026-05-01T00:00:02.000Z', + 0, + 0, + 0, + '2026-05-01T00:00:02.000Z', + '2026-05-01T00:00:03.000Z', + NULL, + NULL + ), + ( + 'thread-percent-decoy', + 'project-search', + 'Literal 100x fix', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-05-01T00:00:04.000Z', + '2026-05-01T00:00:05.000Z', + NULL, + NULL + ), + ( + 'thread-hidden', + 'project-search', + 'Archived search', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-05-01T00:00:06.000Z', + '2026-05-01T00:00:07.000Z', + '2026-05-01T00:00:08.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES + ( + 'message-user', + 'thread-active', + 'turn-active', + 'user', + 'Please find this USER needle in an old prompt.', + 0, + '2026-05-01T00:00:12.000Z', + '2026-05-01T00:00:12.000Z' + ), + ( + 'message-percent', + 'thread-active', + NULL, + 'user', + 'Literal 100% fix in a prompt.', + 0, + '2026-05-01T00:00:11.000Z', + '2026-05-01T00:00:11.000Z' + ), + ( + 'message-percent-decoy', + 'thread-percent-decoy', + NULL, + 'user', + 'Literal 100x fix in a prompt.', + 0, + '2026-05-01T00:00:11.000Z', + '2026-05-01T00:00:11.000Z' + ), + ( + 'message-final', + 'thread-active', + 'turn-active', + 'assistant', + 'The canonical final needle appears in this completed answer.', + 0, + '2026-05-01T00:00:13.000Z', + '2026-05-01T00:00:13.000Z' + ), + ( + 'message-interim', + 'thread-active', + 'turn-active', + 'assistant', + 'Interim needle must not be searchable.', + 0, + '2026-05-01T00:00:14.000Z', + '2026-05-01T00:00:14.000Z' + ), + ( + 'message-system', + 'thread-active', + NULL, + 'system', + 'System needle must not be searchable.', + 0, + '2026-05-01T00:00:15.000Z', + '2026-05-01T00:00:15.000Z' + ), + ( + 'message-hidden', + 'thread-hidden', + NULL, + 'user', + 'Hidden needle in archive.', + 0, + '2026-05-01T00:00:16.000Z', + '2026-05-01T00:00:16.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_files_json + ) + VALUES ( + 'thread-active', + 'turn-active', + 'message-user', + 'message-final', + 'completed', + '2026-05-01T00:00:12.000Z', + '2026-05-01T00:00:12.000Z', + '2026-05-01T00:00:13.000Z', + '[]' + ) + `; + + const literalPercent = yield* snapshotQuery.searchThreads({ query: "100%" }); + assert.deepStrictEqual( + literalPercent.matches.map((match) => [match.threadId, match.source]), + [[ThreadId.make("thread-active"), "user"]], + ); + + const user = yield* snapshotQuery.searchThreads({ query: "user needle" }); + assert.equal(user.matches[0]?.source, "user"); + assert.match(user.matches[0]?.snippet ?? "", /USER needle/); + + const assistant = yield* snapshotQuery.searchThreads({ query: "FINAL NEEDLE" }); + assert.equal(assistant.matches[0]?.source, "assistant"); + + const deduped = yield* snapshotQuery.searchThreads({ query: "needle" }); + assert.deepStrictEqual( + deduped.matches.map((match) => [match.threadId, match.source]), + [[ThreadId.make("thread-active"), "user"]], + ); + + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "interim needle" })).matches, + [], + ); + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "system needle" })).matches, + [], + ); + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "hidden needle" })).matches, + [], + ); + yield* sql` + UPDATE projection_threads + SET deleted_at = '2026-05-01T00:00:20.000Z' + WHERE thread_id = 'thread-active' + `; + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "user needle" })).matches, + [], + ); + }), + ); }); it.effect( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1cd4289fe0cc..4dcc43913c4b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ import { OrchestrationCheckpointFile, OrchestrationProposedPlanId, OrchestrationReadModel, + OrchestrationThreadSearchSource, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, @@ -108,6 +109,17 @@ const ProjectionCountsRowSchema = Schema.Struct({ projectCount: Schema.Number, threadCount: Schema.Number, }); +const ProjectionThreadSearchRequest = Schema.Struct({ + pattern: Schema.String, + limit: Schema.Int, +}); +const ProjectionThreadSearchRow = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + source: OrchestrationThreadSearchSource, + matchText: Schema.String, + messageCreatedAt: Schema.NullOr(IsoDateTime), +}); const WorkspaceRootLookupInput = Schema.Struct({ workspaceRoot: Schema.String, }); @@ -157,6 +169,31 @@ function maxIso(left: string | null, right: string): string { return left > right ? left : right; } +function escapeLikePattern(value: string): string { + return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_"); +} + +function foldAsciiCase(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function buildSearchSnippet(text: string, query: string): string { + const normalizedText = text.replace(/\s+/g, " ").trim(); + if (normalizedText.length <= 240) { + return normalizedText; + } + + const normalizedQuery = foldAsciiCase(query.replace(/\s+/g, " ").trim()); + const matchIndex = foldAsciiCase(normalizedText).indexOf(normalizedQuery); + const bodyLength = 236; + const idealStart = Math.max(0, matchIndex - 72); + const start = Math.min(idealStart, normalizedText.length - bodyLength); + const end = Math.min(normalizedText.length, start + bodyLength); + return `${start > 0 ? "…" : ""}${normalizedText.slice(start, end)}${ + end < normalizedText.length ? "…" : "" + }`; +} + function computeSnapshotSequence( stateRows: ReadonlyArray>, ): number { @@ -685,6 +722,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const searchActiveThreadRows = SqlSchema.findAll({ + Request: ProjectionThreadSearchRequest, + Result: ProjectionThreadSearchRow, + execute: ({ pattern, limit }) => + sql` + WITH ranked AS ( + SELECT + threads.thread_id AS thread_id, + threads.project_id AS project_id, + CASE messages.role + WHEN 'user' THEN 'user' + ELSE 'assistant' + END AS source, + messages.text AS match_text, + messages.created_at AS message_created_at, + CASE messages.role + WHEN 'user' THEN 0 + ELSE 1 + END AS match_rank, + threads.updated_at AS thread_updated_at, + ROW_NUMBER() OVER ( + PARTITION BY threads.thread_id + ORDER BY + CASE messages.role + WHEN 'user' THEN 0 + ELSE 1 + END ASC, + messages.created_at DESC, + messages.message_id ASC + ) AS thread_match_rank + FROM projection_thread_messages AS messages + INNER JOIN projection_threads AS threads + ON threads.thread_id = messages.thread_id + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND messages.is_streaming = 0 + AND ( + messages.role = 'user' + OR ( + messages.role = 'assistant' + AND messages.message_id IN ( + SELECT turns.assistant_message_id + FROM projection_turns AS turns + WHERE turns.assistant_message_id IS NOT NULL + ) + ) + ) + AND messages.text LIKE ${pattern} ESCAPE '!' + ) + SELECT + thread_id AS "threadId", + project_id AS "projectId", + source, + match_text AS "matchText", + message_created_at AS "messageCreatedAt" + FROM ranked + WHERE thread_match_rank = 1 + ORDER BY + match_rank ASC, + thread_updated_at DESC, + thread_id ASC + LIMIT ${limit} + `, + }); + const getActiveProjectRowByWorkspaceRoot = SqlSchema.findOneOption({ Request: WorkspaceRootLookupInput, Result: ProjectionProjectLookupRowSchema, @@ -1758,6 +1863,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const searchThreads: ProjectionSnapshotQueryShape["searchThreads"] = Effect.fn( + "ProjectionSnapshotQuery.searchThreads", + )(function* (input) { + const escapedQuery = escapeLikePattern(input.query); + const rows = yield* searchActiveThreadRows({ + pattern: `%${escapedQuery}%`, + limit: input.limit ?? 50, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.searchThreads:query", + "ProjectionSnapshotQuery.searchThreads:decodeRows", + ), + ), + ); + return { + matches: rows.map((row) => ({ + threadId: row.threadId, + projectId: row.projectId, + source: row.source, + snippet: buildSearchSnippet(row.matchText, input.query), + messageCreatedAt: row.messageCreatedAt, + })), + }; + }); + const getActiveProjectByWorkspaceRoot: ProjectionSnapshotQueryShape["getActiveProjectByWorkspaceRoot"] = (workspaceRoot) => getActiveProjectRowByWorkspaceRoot({ workspaceRoot }).pipe( @@ -2131,6 +2262,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getSnapshot, getShellSnapshot, getArchivedShellSnapshot, + searchThreads, getSnapshotSequence, getCounts, getActiveProjectByWorkspaceRoot, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778a..64138fb75596 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -12,6 +12,8 @@ import type { OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationSearchThreadsInput, + OrchestrationSearchThreadsResult, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, @@ -94,6 +96,14 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Search active thread navigation metadata, user messages, and canonical + * assistant outputs without hydrating thread detail snapshots. + */ + readonly searchThreads: ( + input: OrchestrationSearchThreadsInput, + ) => Effect.Effect; + /** * Read the latest projection snapshot sequence without hydrating read-model * entities. diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079a..5c5da4666b0d 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }); const makeTerminalManagerLayer = ( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3843c8acbcd9..f3f4ca39d477 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -212,6 +212,7 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f12cb8fffdc2..7efe815e0bf4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -732,6 +732,7 @@ const buildAppUnderTest = (options?: { threads: [], updatedAt: "1970-01-01T00:00:00.000Z", }), + searchThreads: () => Effect.succeed({ matches: [] }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getProjectShellById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), @@ -5726,6 +5727,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { projectionSnapshotQuery: { getSnapshot: () => Effect.succeed(snapshot), + searchThreads: () => + Effect.succeed({ + matches: [ + { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), + source: "assistant", + snippet: "Search reached the final response.", + messageCreatedAt: now, + }, + ], + }), }, orchestrationEngine: { dispatch: () => Effect.succeed({ sequence: 7 }), @@ -5783,6 +5796,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); assert.equal(fullDiffResult.diff, "full-diff"); + + const searchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.searchThreads]({ + query: "final response", + }), + ), + ); + assert.deepEqual(searchResult.matches, [ + { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), + source: "assistant", + snippet: "Search reached the final response.", + messageCreatedAt: now, + }, + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 78e6442c7a68..b56c964a538d 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -104,6 +104,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -167,6 +168,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -212,6 +214,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -263,6 +266,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a4182b4060c0..1a59342bf777 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -28,6 +28,7 @@ import { type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, type ProjectId, @@ -1103,6 +1104,20 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.searchThreads]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.searchThreads, + projectionSnapshotQuery.searchThreads(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationSearchThreadsError({ + message: "Failed to search threads", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 04de1784715e..5b4accc9fc7b 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -169,6 +169,29 @@ describe("buildThreadActionItems", () => { expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); + it("keeps message excerpts searchable without replacing thread metadata", () => { + const [item] = buildThreadActionItems({ + threads: [makeThread({ branch: "feat/search" })], + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + getContentMatch: () => ({ + source: "assistant", + snippet: "The relay reconnect is now bounded.", + query: "reconnect", + }), + runThread: async (_thread) => undefined, + }); + + expect(item?.searchTerms).toContain("The relay reconnect is now bounded."); + expect(item?.threadContentMatch).toEqual({ + source: "assistant", + snippet: "The relay reconnect is now bounded.", + query: "reconnect", + }); + expect(item?.description).toBe("T3 Code · #feat/search"); + }); + it("filters archived threads out of thread search items", () => { const items = buildThreadActionItems({ threads: [ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 058322744bb4..7a07cc484813 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,12 +15,19 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export interface CommandPaletteThreadContentMatch { + readonly source: "user" | "assistant"; + readonly snippet: string; + readonly query: string; +} + export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; readonly description?: string; + readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; readonly icon: ReactNode; readonly disabled?: boolean; @@ -114,6 +121,7 @@ export function buildThreadActionItems ReactNode; /** Optional content rendered inline after the title text per-thread. */ renderTrailingContent?: (thread: TThread) => ReactNode; + getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; runThread: (thread: Pick) => Promise; limit?: number; }): CommandPaletteActionItem[] { @@ -140,12 +148,18 @@ export function buildThreadActionItems { await input.runThread(thread); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 072929a3447f..e5a33942a094 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -3,6 +3,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, @@ -66,6 +67,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; +import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -512,6 +514,26 @@ function OpenCommandPaletteDialog(props: { const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; + const environmentIds = useMemo( + () => + environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId), + [environments], + ); + const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : ""; + const threadSearch = useThreadSearch(environmentIds, threadSearchQuery); + const threadContentMatchByKey = useMemo( + () => + new Map( + threadSearch.matches.flatMap((match) => + match.source === "user" || match.source === "assistant" + ? [[threadSearchMatchKey(match), match] as const] + : [], + ), + ), + [threadSearch.matches], + ); const [browseGeneration, setBrowseGeneration] = useState(0); const browseNavigationRef = useRef | null>( null, @@ -918,6 +940,21 @@ function OpenCommandPaletteDialog(props: { icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , + getContentMatch: (thread) => { + const match = threadContentMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ); + return match && (match.source === "user" || match.source === "assistant") + ? { + source: match.source, + snippet: match.snippet, + query: threadSearchQuery, + } + : undefined; + }, runThread: async (thread) => { await navigate({ to: "/$environmentId/$threadId", @@ -925,7 +962,15 @@ function OpenCommandPaletteDialog(props: { }); }, }), - [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], + [ + activeThreadId, + clientSettings.sidebarThreadSortOrder, + navigate, + projectTitleById, + threadContentMatchByKey, + threadSearchQuery, + threads, + ], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -2196,7 +2241,9 @@ function OpenCommandPaletteDialog(props: { emptyStateMessage: "Press Enter to create this folder and add it as a project.", } - : {})} + : threadSearch.isPending + ? { emptyStateMessage: "Searching thread messages…" } + : {})} /> diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 532d546df9a3..2ab4ef8f3f81 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -16,6 +16,69 @@ import { } from "./ui/command"; import { cn } from "~/lib/utils"; +function foldAsciiCase(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function HighlightedSearchText(props: { text: string; query: string }) { + const query = props.query.trim(); + if (query.length === 0) return props.text; + + const normalizedText = foldAsciiCase(props.text); + const normalizedQuery = foldAsciiCase(query); + const parts: Array<{ + readonly text: string; + readonly highlighted: boolean; + readonly start: number; + }> = []; + let cursor = 0; + + while (cursor < props.text.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + if (matchIndex === -1) { + parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); + break; + } + if (matchIndex > cursor) { + parts.push({ + text: props.text.slice(cursor, matchIndex), + highlighted: false, + start: cursor, + }); + } + parts.push({ + text: props.text.slice(matchIndex, matchIndex + query.length), + highlighted: true, + start: matchIndex, + }); + cursor = matchIndex + query.length; + } + + return parts.map((part) => + part.highlighted ? ( + + {part.text} + + ) : ( + part.text + ), + ); +} + +function ThreadContentMatch(props: { + match: NonNullable; +}) { + const isUser = props.match.source === "user"; + return ( + + + {isUser ? "You:" : "Agent:"} + {" "} + + + ); +} + interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; @@ -69,15 +132,20 @@ function DisabledCommandPaletteResultRow(props: { return (
{props.item.icon} - {props.item.description ? ( + {props.item.description || props.item.threadContentMatch ? ( {props.item.titleLeadingContent} {props.item.title} - - {props.item.description} - + {props.item.threadContentMatch ? ( + + ) : null} + {props.item.description ? ( + + {props.item.description} + + ) : null} ) : ( @@ -115,15 +183,20 @@ function CommandPaletteResultRow(props: { }} > {props.item.icon} - {props.item.description ? ( + {props.item.description || props.item.threadContentMatch ? ( {props.item.titleLeadingContent} {props.item.title} - - {props.item.description} - + {props.item.threadContentMatch ? ( + + ) : null} + {props.item.description ? ( + + {props.item.description} + + ) : null} ) : ( diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 745c9e700b38..a9564c2fd647 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -3,6 +3,11 @@ import { type CheckpointDiffTarget, type ComposerPathSearchTarget, } from "@t3tools/client-runtime/state/threads"; +import { + createThreadSearchResultsAtomFamily, + makeThreadSearchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, @@ -26,9 +31,24 @@ import { vcsEnvironment } from "./vcs"; const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; +const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; +const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ + matches: EMPTY_THREAD_SEARCH_MATCHES, + isLoading: false, +}).pipe(Atom.withLabel("web:thread-search:empty")); + +const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId, query) => + orchestrationEnvironment.threadSearch({ + environmentId, + input: { query }, + }), + labelPrefix: "web:thread-search", +}); export interface ThreadDetailView { readonly data: OrchestrationThread | null; @@ -52,6 +72,31 @@ function useDebouncedValue(value: A, delayMs: number): A { return debounced; } +export function useThreadSearch( + environmentIds: ReadonlyArray, + query: string, +): { + readonly matches: ReadonlyArray; + readonly isPending: boolean; +} { + const normalizedQuery = query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); + const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2; + const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; + const searchKey = useMemo( + () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)), + [environmentIds, settledQuery], + ); + const result = useAtomValue( + searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), + ); + const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + return { + matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + isPending: canSearch && (isDebouncing || result.isLoading), + }; +} + export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a05..0746272633f9 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -69,6 +69,11 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) +The command palette searches active thread titles, projects, branches, user messages, and final +agent responses across connected environments. Message matches show one labeled excerpt while +keeping the thread's project, branch, and machine context visible. Message search begins after two +characters and uses SQLite's ASCII case-insensitive matching. + ### Key Syntax Supported modifiers: diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4fa05f850e59..0b7b078a5226 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -131,6 +131,10 @@ "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" }, + "./state/thread-search": { + "types": "./src/state/threadSearch.ts", + "default": "./src/state/threadSearch.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index f8faa49ea385..666b3b94c455 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -16,6 +16,12 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:full-thread-diff", tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, }), + threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-search", + tag: ORCHESTRATION_WS_METHODS.searchThreads, + staleTimeMs: 30_000, + idleTtlMs: 60_000, + }), archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:archived-shell-snapshot", tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, diff --git a/packages/client-runtime/src/state/threadSearch.test.ts b/packages/client-runtime/src/state/threadSearch.test.ts new file mode 100644 index 000000000000..2f1430a51b6b --- /dev/null +++ b/packages/client-runtime/src/state/threadSearch.test.ts @@ -0,0 +1,72 @@ +import { + EnvironmentId, + ProjectId, + ThreadId, + type OrchestrationSearchThreadsResult, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { expect, it } from "vite-plus/test"; + +import { + createThreadSearchResultsAtomFamily, + makeThreadSearchKey, + threadSearchMatchKey, +} from "./threadSearch.ts"; + +const envA = EnvironmentId.make("env-a"); +const envB = EnvironmentId.make("env-b"); + +it("creates stable keys regardless of environment order", () => { + expect(makeThreadSearchKey([envB, envA], "needle")).toBe( + makeThreadSearchKey([envA, envB], "needle"), + ); +}); + +it("encodes scoped thread keys without delimiter collisions", () => { + const first = threadSearchMatchKey({ + environmentId: EnvironmentId.make("env\u0000thread"), + threadId: ThreadId.make("id"), + }); + const second = threadSearchMatchKey({ + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread\u0000id"), + }); + + expect(first).not.toBe(second); +}); + +it("merges successful environments and silently ignores failures", () => { + const result: OrchestrationSearchThreadsResult = { + matches: [ + { + threadId: ThreadId.make("thread-a"), + projectId: ProjectId.make("project-a"), + source: "user", + snippet: "needle", + messageCreatedAt: "2026-07-30T00:00:00.000Z", + }, + ], + }; + const searchAtom = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId) => + environmentId === envA + ? Atom.make(AsyncResult.success(result)) + : Atom.make( + AsyncResult.failure( + Cause.fail(new Error("unsupported rpc")), + ), + ), + labelPrefix: "test:thread-search", + }); + const registry = AtomRegistry.make(); + + const state = registry.get(searchAtom(makeThreadSearchKey([envB, envA], "needle"))); + expect(state).toEqual({ + matches: [{ ...result.matches[0], environmentId: envA }], + isLoading: false, + }); + expect(threadSearchMatchKey(state.matches[0]!)).toBe('["env-a","thread-a"]'); + + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts new file mode 100644 index 000000000000..4011a91cd58c --- /dev/null +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -0,0 +1,81 @@ +import { + EnvironmentId, + OrchestrationSearchThreadsInput, + type OrchestrationSearchThreadsResult, + type OrchestrationThreadSearchMatch, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +export interface EnvironmentThreadSearchMatch extends OrchestrationThreadSearchMatch { + readonly environmentId: EnvironmentId; +} + +export interface ThreadSearchResultsState { + readonly matches: ReadonlyArray; + readonly isLoading: boolean; +} + +const ThreadSearchKey = Schema.Tuple([ + Schema.Array(EnvironmentId), + OrchestrationSearchThreadsInput.fields.query, +]); +const decodeThreadSearchKey = Schema.decodeUnknownSync(ThreadSearchKey); + +export function makeThreadSearchKey( + environmentIds: ReadonlyArray, + query: string, +): string { + return JSON.stringify([ + [...environmentIds].toSorted((left, right) => left.localeCompare(right)), + query, + ]); +} + +function parseThreadSearchKey(key: string) { + return decodeThreadSearchKey(JSON.parse(key)); +} + +export function threadSearchMatchKey( + match: Pick, +): string { + return JSON.stringify([match.environmentId, match.threadId]); +} + +/** + * Combines one search query atom per environment. Failed and disconnected + * environments contribute no content matches, preserving local title search + * as the compatibility fallback. + */ +export function createThreadSearchResultsAtomFamily(options: { + readonly getSearchAtom: ( + environmentId: EnvironmentId, + query: string, + ) => Atom.Atom>; + readonly labelPrefix: string; +}) { + return Atom.family((key: string) => + Atom.make((get): ThreadSearchResultsState => { + const [environmentIds, query] = parseThreadSearchKey(key); + const matches: EnvironmentThreadSearchMatch[] = []; + let isLoading = false; + + for (const environmentId of environmentIds) { + const result = get(options.getSearchAtom(environmentId, query)); + isLoading ||= result.waiting; + const value = Option.getOrNull(AsyncResult.value(result)); + if (value !== null) { + matches.push( + ...value.matches.map((match) => ({ + ...match, + environmentId, + })), + ); + } + } + + return { matches, isLoading }; + }).pipe(Atom.withLabel(`${options.labelPrefix}:${key}`)), + ); +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e54c088ba3f2..2f2a8de491c9 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -18,6 +18,7 @@ import { ProviderItemId, ThreadId, TrimmedNonEmptyString, + TrimmedString, TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -26,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", + searchThreads: "orchestration.searchThreads", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", @@ -1395,6 +1397,31 @@ export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThr export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff; export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type; +export const OrchestrationThreadSearchSource = Schema.Literals(["user", "assistant"]); +export type OrchestrationThreadSearchSource = typeof OrchestrationThreadSearchSource.Type; + +// The server's SQLite client is synchronous and single-connection. Bound both +// scan input and response size so a search cannot monopolize that connection. +export const OrchestrationSearchThreadsInput = Schema.Struct({ + query: TrimmedString.check(Schema.isMinLength(2), Schema.isMaxLength(200)), + limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))), +}); +export type OrchestrationSearchThreadsInput = typeof OrchestrationSearchThreadsInput.Type; + +export const OrchestrationThreadSearchMatch = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + source: OrchestrationThreadSearchSource, + snippet: Schema.String.check(Schema.isMaxLength(240)), + messageCreatedAt: Schema.NullOr(IsoDateTime), +}); +export type OrchestrationThreadSearchMatch = typeof OrchestrationThreadSearchMatch.Type; + +export const OrchestrationSearchThreadsResult = Schema.Struct({ + matches: Schema.Array(OrchestrationThreadSearchMatch), +}); +export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type; + export const OrchestrationRpcSchemas = { dispatchCommand: { input: ClientOrchestrationCommand, @@ -1408,6 +1435,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, }, + searchThreads: { + input: OrchestrationSearchThreadsInput, + output: OrchestrationSearchThreadsResult, + }, getArchivedShellSnapshot: { input: Schema.Struct({}), output: OrchestrationShellSnapshot, @@ -1453,3 +1484,11 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass cause: Schema.optional(Schema.Defect()), }, ) {} + +export class OrchestrationSearchThreadsError extends Schema.TaggedErrorClass()( + "OrchestrationSearchThreadsError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d1a5f2504c72..17fbd57ddad1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -57,6 +57,8 @@ import { OrchestrationGetFullThreadDiffError, OrchestrationGetFullThreadDiffInput, OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, + OrchestrationSearchThreadsInput, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, OrchestrationRpcSchemas, @@ -690,6 +692,12 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( }, ); +export const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, { + payload: OrchestrationSearchThreadsInput, + success: OrchestrationRpcSchemas.searchThreads.output, + error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), +}); + export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, { @@ -840,6 +848,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationSearchThreadsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, From 04ca2e9b445485f45dc6b153a32c7f87e99aca1e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 30 Jul 2026 10:39:15 -0400 Subject: [PATCH 08/15] =?UTF-8?q?Add=20project=20file=20picker=20(?= =?UTF-8?q?=E2=8C=98P)=20and=20project=20content=20search=20(=E2=87=A7?= =?UTF-8?q?=E2=8C=98F)=20(#4855)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge (cherry picked from commit abc409c2d4a072c2de46c9015f5cffff00dcc46b) --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/keybindings.test.ts | 2 + apps/server/src/keybindings.ts | 21 +- .../src/workspace/WorkspaceEntries.test.ts | 344 ++++++++++- apps/server/src/workspace/WorkspaceEntries.ts | 105 +++- .../workspace/WorkspaceSearchIndex.test.ts | 143 ++++- .../src/workspace/WorkspaceSearchIndex.ts | 315 ++++++++-- apps/server/src/ws.ts | 18 + apps/web/src/components/ChatMarkdown.tsx | 52 +- .../components/CommandPalette.logic.test.ts | 72 +++ .../src/components/CommandPalette.logic.ts | 51 +- apps/web/src/components/CommandPalette.tsx | 584 +++++++++--------- .../src/components/CommandPaletteContent.tsx | 77 +++ .../src/components/RenderErrorBoundary.tsx | 16 + .../src/components/files/FileBrowserPanel.tsx | 72 +++ .../src/components/files/FilePreviewPanel.tsx | 188 ++++-- .../files/ProjectFilePicker.logic.test.ts | 83 +++ .../files/ProjectFilePicker.logic.ts | 73 +++ .../components/files/ProjectFilePicker.tsx | 163 +++++ .../components/files/fileLineReveal.test.ts | 32 + .../src/components/files/fileLineReveal.ts | 24 + .../files/projectFilesQueryState.ts | 27 + .../search/HighlightedSearchLine.tsx | 183 ++++++ .../search/ProjectContentSearchDialog.tsx | 315 ++++++++++ apps/web/src/hooks/useActiveProjectTarget.ts | 41 ++ apps/web/src/keybindings.test.ts | 52 ++ apps/web/src/lib/syntaxHighlighting.test.ts | 28 + apps/web/src/lib/syntaxHighlighting.ts | 30 + apps/web/src/state/projects.ts | 13 + apps/web/src/state/queries.test.ts | 25 + apps/web/src/state/queries.ts | 97 ++- docs/user/keybindings.md | 4 + packages/contracts/src/keybindings.test.ts | 12 + packages/contracts/src/keybindings.ts | 2 + packages/contracts/src/project.test.ts | 42 ++ packages/contracts/src/project.ts | 82 ++- packages/contracts/src/rpc.ts | 11 + packages/shared/src/keybindings.ts | 2 + 38 files changed, 2927 insertions(+), 475 deletions(-) create mode 100644 apps/web/src/components/CommandPaletteContent.tsx create mode 100644 apps/web/src/components/RenderErrorBoundary.tsx create mode 100644 apps/web/src/components/files/ProjectFilePicker.logic.test.ts create mode 100644 apps/web/src/components/files/ProjectFilePicker.logic.ts create mode 100644 apps/web/src/components/files/ProjectFilePicker.tsx create mode 100644 apps/web/src/components/files/fileLineReveal.test.ts create mode 100644 apps/web/src/components/files/fileLineReveal.ts create mode 100644 apps/web/src/components/search/HighlightedSearchLine.tsx create mode 100644 apps/web/src/components/search/ProjectContentSearchDialog.tsx create mode 100644 apps/web/src/hooks/useActiveProjectTarget.ts create mode 100644 apps/web/src/lib/syntaxHighlighting.test.ts create mode 100644 apps/web/src/lib/syntaxHighlighting.ts create mode 100644 apps/web/src/state/queries.test.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 80b1cb4aa1fe..fb753b9aa4bd 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, + [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac84167..252819adacf4 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); + assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 304726ecbaf6..0aaf1f491f0b 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -554,19 +554,24 @@ const make = Effect.gen(function* () { }); } - const nextConfig = [...customConfig, ...missingDefaults]; - const cappedConfig = - nextConfig.length > MAX_KEYBINDINGS_COUNT - ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) - : nextConfig; - if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { - yield* Effect.logWarning("truncating keybindings config to max entries", { + // Startup backfill must never evict persisted user rules: append only + // the defaults that fit and skip the rest. + const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); + const defaultsToAppend = missingDefaults.slice(0, availableSlots); + const skippedDefaults = missingDefaults.slice(availableSlots); + if (skippedDefaults.length > 0) { + yield* Effect.logWarning("skipping default keybinding backfill at max entries", { path: keybindingsConfigPath, maxEntries: MAX_KEYBINDINGS_COUNT, + commands: skippedDefaults.map((rule) => rule.command), }); } + if (defaultsToAppend.length === 0) { + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + return; + } - yield* writeConfigAtomically(cappedConfig); + yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); }), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed9591..d47aaaec8264 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) return result.stdout.trim(); }); -const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => +const searchWorkspaceEntries = (input: { + cwd: string; + query: string; + limit: number; + kind?: "file" | "directory"; +}) => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); @@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("applies the file filter before limiting search results", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "src/internal.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 1, + kind: "file", + }); + + expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]); + expect(result.truncated).toBe(true); + }), + ); + + it.effect("answers an empty file-filtered query with a bounded file listing", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "README.md"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "", + limit: 10, + kind: "file", + }); + + const paths = result.entries.map((entry) => entry.path); + expect(paths).toHaveLength(2); + expect(paths).toContain("src/index.ts"); + expect(paths).toContain("README.md"); + expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); + }), + ); + + it.effect("returns only directories for the directory filter", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); + yield* writeTextFile(cwd, "src/index.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 10, + kind: "directory", + }); + + expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); + expect(result.truncated).toBe(false); + }), + ); + it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); + describe("searchContents", () => { + it.effect("returns content matches with file paths, line numbers, and ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" }); + yield* writeTextFile( + cwd, + "src/shapes.ts", + "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n", + ); + yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: false, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([ + ["src/shapes.ts", 1], + ["src/shapes.ts", 2], + ]); + expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("honors case sensitivity and gitignore rules", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true }); + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); + yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n"); + yield* writeTextFile(cwd, "ignored.txt", "Square\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 }); + }), + ); + + it.effect("filters whole-word matches by word boundaries without widening ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "note", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // "notes", "denote", and "footnote" are word-adjacent and excluded; + // ranges cover exactly the query, never boundary characters. + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 0, end: 4 }], + }), + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 2, + matchRanges: [{ start: 9, end: 13 }], + }), + ]); + }), + ); + + it.effect("finds later whole-word matches in a file after rejected raw matches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 11, + matchRanges: [{ start: 0, end: 3 }], + }), + ]); + }), + ); + + it.effect("treats astral-plane letters as whole word characters", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 6, end: 9 }], + }), + ]); + }), + ); + + it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "-foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // Consuming-boundary regex would swallow the separating spaces and + // drop the middle occurrence; boundary post-filtering keeps all three. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + { start: 12, end: 17 }, + ], + }); + }), + ); + + it.effect("matches punctuation-edged regex queries as whole words", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: true, + }); + + // wholeWord + useRegex must not silently drop non-word-edged patterns + // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word + // characters at the match's left edge. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 4 }, + { start: 5, end: 9 }, + ], + }); + }), + ); + + it.effect("caps matches per file so one dense file cannot fill the page", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); + yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300)); + yield* writeTextFile(cwd, "src/other.ts", "needle\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 500, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + const byPath = new Map(); + for (const match of result.matches) { + byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1); + } + expect(byPath.get("src/dense.ts")).toBe(100); + expect(byPath.get("src/other.ts")).toBe(1); + }), + ); + + it.effect("preserves regex escapes during case-insensitive searches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" }); + yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "\\SQUARE", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: true, + }); + + expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]); + }), + ); + + it.effect("preserves invalid regex errors during case-insensitive searches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); + yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo)bar(", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: true, + }); + + expect(result.regexFallbackError).toBeDefined(); + expect(result.matches).toEqual([]); + }), + ); + + it.effect("maps multi-byte lines to string-indexed ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); + yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n'); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "wörld", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + const match = result.matches[0]!; + const range = match.matchRanges[0]!; + expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); + }), + ); + }); + describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7501cbe0eab2..bb2113dac37d 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -14,11 +14,14 @@ import type { FilesystemBrowseResult, ProjectListEntriesInput, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; + readonly searchContents: ( + input: ProjectSearchContentsInput, + ) => Effect.Effect; readonly refresh: (cwd: string) => Effect.Effect; } >()("t3/workspace/WorkspaceEntries") {} @@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.orElseSucceed(() => cwd), ); - if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { - return; - } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - cause, + for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { + const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { + continue; + } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + variant, + cause, + }); + yield* workspaceSearchIndexes.invalidate(indexKey); }); - yield* workspaceSearchIndexes.invalidate(normalizedCwd); - }); - yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - yield* searchIndex.refresh(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.catchTags({ - WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, - WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, - WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, - }), - ); + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(indexKey)), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), + ); + } }, ); @@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - const normalizedQuery = input.query - .trim() - .toLowerCase() - .replace(/^[@./]+/, ""); + const normalizedQuery = normalizeSearchQuery(input.query, { + trimLeadingPattern: /^[@./]+/, + }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); + const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn( + "WorkspaceEntries.searchContents", + )(function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.searchContents(input); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), + ), + ), + ); + }); + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); - return WorkspaceEntries.of({ browse, list, refresh, search }); + return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 9b7ed4e2453f..155728370307 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,4 @@ -import { FileFinder } from "@ff-labs/fff-node"; +import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); +it.effect("waits for the full content index warmup before returning", () => + Effect.gen(function* () { + const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); + const finder = { + destroy: vi.fn(), + waitForIndexReady, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); + + expect(waitForIndexReady).toHaveBeenCalledWith(15_000); + }), +); + +it.effect("preserves a full-index warmup timeout as a structured error", () => + Effect.gen(function* () { + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexScanTimedOut", + cwd: "/workspace/project", + timeout: "15 seconds", + }); + }), +); + it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); @@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => destroy: vi.fn(() => { throw cause; }), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () => Effect.gen(function* () { const searchCause = new Error("native search failed"); const refreshCause = new Error("native scan failed"); + const contentSearchCause = new Error("native grep failed"); const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => { throw searchCause; }), + grep: vi.fn(() => { + throw contentSearchCause; + }), scanFiles: vi.fn(() => { throw refreshCause; }), @@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const contentSearchError = yield* Effect.flip( + searchIndex.searchContents({ + query, + limit: 3, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }), + ); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ @@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(contentSearchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 3, + reason: "FileFinder.grep threw unexpectedly.", + cause: contentSearchCause, + }); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", @@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.gen(function* () { const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), } as unknown as FileFinder; @@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => }), ), ); + +it.effect("continues whole-word searches after a filtered grep page", () => + Effect.scoped( + Effect.gen(function* () { + const nextCursor = { + __brand: "GrepCursor", + _offset: 1, + } as GrepCursor; + const grepResult = ( + lineContent: string, + matchRanges: Array<[number, number]>, + cursor: GrepCursor | null, + ): GrepResult => ({ + items: [ + { + relativePath: "src/words.ts", + fileName: "words.ts", + gitStatus: "unmodified", + size: lineContent.length, + modified: 0, + isBinary: false, + totalFrecencyScore: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + lineNumber: 1, + col: 0, + byteOffset: 0, + lineContent, + matchRanges, + }, + ], + totalMatched: 1, + totalFilesSearched: 1, + totalFiles: 1, + filteredFileCount: 1, + nextCursor: cursor, + }); + const grep = vi.fn((_query: string, options?: GrepOptions) => + options?.cursor + ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } + : { + ok: true as const, + value: grepResult("needleSuffix", [[0, 6]], nextCursor), + }, + ); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + grep, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); + const result = yield* searchIndex.searchContents({ + query: "needle", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result).toEqual({ + matches: [ + { + path: "src/words.ts", + lineNumber: 1, + lineContent: "needle", + matchRanges: [{ start: 0, end: 6 }], + }, + ], + truncated: false, + }); + expect(grep).toHaveBeenCalledTimes(2); + expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7b..8bf36b7a80ac 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,22 +1,36 @@ -import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import { + type DirItem, + type DirSearchResult, + type FileItem, + FileFinder, + type GrepCursor, + type MixedItem, + type MixedSearchResult, + type Result, + type SearchResult, +} from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type { ProjectEntry, + ProjectEntryKind, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; +const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; -const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; +const CONTENT_SEARCH_TIME_BUDGET_MS = 250; +const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< readonly search: ( query: string, limit: number, + kind?: ProjectEntryKind, ) => Effect.Effect; + readonly searchContents: ( + input: Omit, + ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut @@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { }; } +function toFileEntry(item: FileItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "file" } : null; +} + +function toDirectoryEntry(item: DirItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; +} + +function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { + return { + entries: result.items + .flatMap((item) => { + const entry = toFileEntry(item); + return entry ? [entry] : []; + }) + .slice(0, limit), + truncated: result.totalMatched > limit, + }; +} + +function mapDirectorySearchResult( + result: DirSearchResult, + limit: number, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toDirectoryEntry(item); + return entry ? [entry] : []; + }); + const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; + return { + entries: entries.slice(0, limit), + truncated: result.totalMatched - rootDirectoryCount > limit, + }; +} + function mapMixedSearchResult( result: MixedSearchResult, limit: number, @@ -155,6 +210,74 @@ function mapMixedSearchResult( }; } +const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; + +function codePointAt(line: string, index: number): string | undefined { + const codePoint = line.codePointAt(index); + return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); +} + +function codePointBefore(line: string, index: number): string | undefined { + if (index <= 0) return undefined; + const previousCodeUnit = line.charCodeAt(index - 1); + const previousIndex = + previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1; + return codePointAt(line, previousIndex); +} + +function buildContentSearchQuery(input: Omit): { + readonly searchQuery: string; + readonly regexMode: boolean; +} { + if (input.caseSensitive) { + return { searchQuery: input.query, regexMode: input.useRegex }; + } + // Plain mode relies on smart case: an all-lowercase needle matches + // case-insensitively. Regex mode needs an explicit inline flag instead. + return input.useRegex + ? { searchQuery: `(?i)${input.query}`, regexMode: true } + : { searchQuery: input.query.toLowerCase(), regexMode: false }; +} + +function mapContentMatchRanges( + line: string, + byteRanges: ReadonlyArray, +): Array<{ readonly start: number; readonly end: number }> { + const lineBytes = Buffer.from(line); + const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length; + return byteRanges.map(([startByte, endByte]) => ({ + start: toStringIndex(startByte), + end: toStringIndex(endByte), + })); +} + +/** + * Whole-word filtering happens after the grep rather than by wrapping the + * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow + * the separator between adjacent matches and widen the reported ranges, and + * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a + * match edge is a word boundary when it touches the line edge, the + * neighbouring character is not a word character, or the match's own edge + * character is not a word character. + */ +function isWholeWordRange( + line: string, + range: { readonly start: number; readonly end: number }, +): boolean { + if (range.end <= range.start) return false; + const isWord = (character: string | undefined) => + character !== undefined && WORD_CHARACTER.test(character); + const leftIsBoundary = + range.start === 0 || + !isWord(codePointBefore(line, range.start)) || + !isWord(codePointAt(line, range.start)); + const rightIsBoundary = + range.end >= line.length || + !isWord(codePointAt(line, range.end)) || + !isWord(codePointBefore(line, range.end)); + return leftIsBoundary && rightIsBoundary; +} + function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } -const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant, +) { const result = yield* Effect.try({ try: () => FileFinder.create({ basePath: cwd, disableMmapCache: true, - disableContentIndexing: true, + // Content indexing costs scan CPU and memory, so only the on-demand + // content-search index pays for it; path-only consumers (file tree, + // composer path search, file picker) keep the lightweight index. + disableContentIndexing: variant !== "content", aiMode: false, enableFsRootScanning: true, enableHomeDirScanning: true, @@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c }); }); -const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => - Effect.try({ - try: () => finder.isScanning(), - catch: onFailure, - }).pipe( - Effect.repeat({ - while: (scanning) => scanning, - schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), - }), - Effect.timeoutOrElse({ - duration: WORKSPACE_INDEX_SCAN_TIMEOUT, - orElse: () => - new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), - }), - Effect.withSpan("WorkspaceSearchIndex.waitForScan"), - ); +const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( + cwd: string, + finder: FileFinder, + onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, +): Effect.fn.Return { + const result = yield* Effect.tryPromise({ + try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), + catch: (cause) => + onFailure({ + reason: "FileFinder.waitForIndexReady rejected unexpectedly.", + cause, + }), + }); + if (!result.ok) { + return yield* Effect.fail(onFailure({ reason: result.error })); + } + if (!result.value) { + return yield* new WorkspaceSearchIndexScanTimedOut({ + cwd, + timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, + }); + } +}); -export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { - const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant = "paths", +) { + const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => Effect.try({ try: () => finder.destroy(), catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), }).pipe(Effect.orDie), ); - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexCreateFailed({ cwd, - reason: "FileFinder.isScanning threw while creating the index.", + reason, cause, }), ); - const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, - ) { + operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", + execute: () => Result, + ): Effect.fn.Return { const result = yield* Effect.try({ - try: () => finder.mixedSearch(query, { pageSize }), + try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: "FileFinder.mixedSearch threw unexpectedly.", + reason: `FileFinder.${operation} threw unexpectedly.`, cause, }), }); @@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin reason: result.error, }); } - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexRefreshFailed({ cwd, - reason: "FileFinder.isScanning threw while refreshing the index.", + reason, cause, }), ); @@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); + const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => + finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), + ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => left.path.localeCompare(right.path), @@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit) { - const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); + )(function* (query, limit, kind) { + const pageSize = Math.max(1, limit + 1); + if (kind === "file") { + const result = yield* runSearch(query, pageSize, "fileSearch", () => + finder.fileSearch(query, { pageSize }), + ); + return mapFileSearchResult(result, limit); + } + if (kind === "directory") { + const result = yield* runSearch(query, pageSize, "directorySearch", () => + finder.directorySearch(query, { pageSize }), + ); + return mapDirectorySearchResult(result, limit); + } + const result = yield* runSearch(query, pageSize, "mixedSearch", () => + finder.mixedSearch(query, { pageSize }), + ); return mapMixedSearchResult(result, limit); }); - return WorkspaceSearchIndex.of({ list, refresh, search }); + const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( + "WorkspaceSearchIndex.searchContents", + )(function* (input) { + const { searchQuery, regexMode } = buildContentSearchQuery(input); + const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; + // Grep cursors advance by file, so whole-word post-filtering needs enough + // raw candidates from the current file before moving to the next one. + const rawPageSize = input.wholeWord + ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) + : input.limit; + const matches: Array = []; + let nextCursor: GrepCursor | null = null; + let regexFallbackError: string | undefined; + + do { + const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); + const result = yield* runSearch(input.query, input.limit, "grep", () => + finder.grep(searchQuery, { + mode: regexMode ? "regex" : "plain", + smartCase: !input.caseSensitive && !regexMode, + // A single dense file must not consume the whole result page. + maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), + pageSize: rawPageSize, + cursor: nextCursor, + timeBudgetMs: remainingTimeBudgetMs, + }), + ); + + for (const match of result.items) { + const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( + (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), + ); + if (matchRanges.length === 0) continue; + matches.push({ + path: toPosixPath(match.relativePath), + lineNumber: match.lineNumber, + lineContent: match.lineContent, + matchRanges, + }); + } + nextCursor = result.nextCursor; + regexFallbackError ??= result.regexFallbackError; + } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline); + + return { + matches: matches.slice(0, input.limit), + truncated: matches.length > input.limit || nextCursor !== null, + ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), + }; + }); + + return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); }); +export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const; +export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number]; + +/** + * Composite LayerMap key so the lightweight path index and the on-demand + * content-search index of the same workspace are separate resources with + * independent lifecycles. "\n" cannot appear in a filesystem path. + */ +export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) => + `${variant}\n${cwd}`; + +function parseWorkspaceSearchIndexKey(key: string): { + readonly cwd: string; + readonly variant: WorkspaceSearchIndexVariant; +} { + const separatorIndex = key.indexOf("\n"); + return { + variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant, + cwd: key.slice(separatorIndex + 1), + }; +} + /** * A layer factory is required because every index is scoped to a concrete - * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; - * using a default cwd here would mix resources from different workspaces. + * workspace root and variant. WorkspaceSearchIndexMap owns memoization and + * idle cleanup; using a default cwd here would mix resources from different + * workspaces. */ -export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); +export const layer = (key: string) => { + const { cwd, variant } = parseWorkspaceSearchIndexKey(key); + return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); +}; export class WorkspaceSearchIndexMap extends LayerMap.Service()( "t3/workspace/WorkspaceSearchIndexMap", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1a59342bf777..824eb81b3e75 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -37,6 +37,7 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -1611,6 +1612,23 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsSearchContents]: (input) => + observeRpcEffect( + WS_METHODS.projectsSearchContents, + workspaceEntries.searchContents(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchContentsError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( WS_METHODS.projectsListEntries, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d86fe39a77f3..985e943cb39c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, ChevronRightIcon, @@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; +import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { @@ -91,27 +92,6 @@ import { BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; -class CodeHighlightErrorBoundary extends React.Component< - { fallback: ReactNode; children: ReactNode }, - { hasError: boolean } -> { - constructor(props: { fallback: ReactNode; children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - override render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); -const highlighterPromiseCache = new Map>(); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); @@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } -function getHighlighterPromise(language: string): Promise { - const cached = highlighterPromiseCache.get(language); - if (cached) return cached; - - const promise = getSharedHighlighter({ - themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], - langs: [language as SupportedLanguages], - preferredHighlighter: "shiki-js", - }).catch((err) => { - highlighterPromiseCache.delete(language); - if (language === "text") { - // "text" itself failed — Shiki cannot initialize at all, surface the error - throw err; - } - // Language not supported by Shiki — fall back to "text" - return getHighlighterPromise("text"); - }); - highlighterPromiseCache.set(language, promise); - return promise; -} - function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } @@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({ cacheKey, isStreaming, }: UncachedShikiCodeBlockProps) { - const highlighter = use(getHighlighterPromise(language)); + const highlighter = use(getSyntaxHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); @@ -1605,7 +1563,7 @@ function ChatMarkdown({ fenceTitle={fenceTitle} theme={resolvedTheme} > - {children}}> + {children}}> {children}}> - + ); }, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 5b4accc9fc7b..4d591500f5c6 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -6,9 +6,81 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("reduceCommandPaletteUiState", () => { + const closedState = { open: false, mode: "command", openIntent: null } as const; + + it("toggles each overlay mode open and closed", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null }); + + const contentOpen = reduceCommandPaletteUiState(filesOpen, { + _tag: "ToggleMode", + mode: "content", + }); + expect(contentOpen).toEqual({ open: true, mode: "content", openIntent: null }); + + expect( + reduceCommandPaletteUiState(contentOpen, { _tag: "ToggleMode", mode: "content" }), + ).toEqual({ open: false, mode: "command", openIntent: null }); + }); + + it("switches between open modes without closing", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleMode", mode: "command" })).toEqual( + { + open: true, + mode: "command", + openIntent: null, + }, + ); + }); + + it("routes open intents to command mode", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "add-project" }, + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenNewThreadIn" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "new-thread-in" }, + }); + }); + + it("resets to command mode for dialog-driven opens and closes", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: false })).toEqual({ + open: false, + mode: "command", + openIntent: null, + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: true })).toEqual({ + open: true, + mode: "command", + openIntent: null, + }); + }); +}); + describe("enumerateCommandPaletteItems", () => { it("assigns positional jump shortcuts to the first nine displayed items", () => { const items = Array.from({ length: 10 }, (_, index) => ({ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 7a07cc484813..eee6ba5886e6 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,55 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +/** + * The global search overlay hosts three mutually exclusive surfaces: the + * command palette (⌘K), the project file picker (⌘P), and project content + * search (⇧⌘F). One reducer owns open/mode state so the surfaces can never + * stack and re-triggering a mode's shortcut toggles it closed. + */ +export type SearchOverlayMode = "command" | "files" | "content"; + +export interface CommandPaletteOpenIntent { + readonly kind: "add-project" | "new-thread-in"; +} + +export interface CommandPaletteUiState { + readonly open: boolean; + readonly mode: SearchOverlayMode; + readonly openIntent: CommandPaletteOpenIntent | null; +} + +export type CommandPaletteUiAction = + | { readonly _tag: "SetOpen"; readonly open: boolean } + | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } + | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "OpenNewThreadIn" } + | { readonly _tag: "ClearOpenIntent" }; + +export function reduceCommandPaletteUiState( + state: CommandPaletteUiState, + action: CommandPaletteUiAction, +): CommandPaletteUiState { + switch (action._tag) { + case "SetOpen": + return { + open: action.open, + mode: "command", + openIntent: action.open ? state.openIntent : null, + }; + case "ToggleMode": + return state.open && state.mode === action.mode + ? { open: false, mode: "command", openIntent: null } + : { open: true, mode: action.mode, openIntent: null }; + case "OpenAddProject": + return { open: true, mode: "command", openIntent: { kind: "add-project" } }; + case "OpenNewThreadIn": + return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + case "ClearOpenIntent": + return state.openIntent ? { ...state, openIntent: null } : state; + } +} + export interface CommandPaletteThreadContentMatch { readonly source: "user" | "assistant"; readonly snippet: string; @@ -26,7 +75,7 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; - readonly description?: string; + readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; readonly icon: ReactNode; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index e5a33942a094..107554dd87bc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -28,16 +28,16 @@ import { import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { - ArrowDownIcon, ArrowLeftIcon, - ArrowUpIcon, CornerLeftUpIcon, + FileSearchIcon, FolderIcon, FolderPlusIcon, LinkIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, + TextSearchIcon, } from "lucide-react"; import { useCallback, @@ -81,7 +81,9 @@ import { resolveProjectPathForDispatch, } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; +import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -100,6 +102,7 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, type CommandPaletteActionItem, + type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -107,24 +110,22 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + reduceCommandPaletteUiState, + type SearchOverlayMode, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectFilePicker } from "./files/ProjectFilePicker"; +import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; -import { - Command, - CommandDialog, - CommandDialogPopup, - CommandFooter, - CommandInput, - CommandPanel, -} from "./ui/command"; +import { CommandDialog, CommandDialogPopup } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -346,50 +347,30 @@ function errorMessage(error: unknown): string { return "An error occurred."; } -interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} - -interface CommandPaletteUiState { - readonly open: boolean; - readonly openIntent: CommandPaletteOpenIntent | null; -} +const OVERLAY_MODE_BY_COMMAND = { + "commandPalette.toggle": "command", + "filePicker.toggle": "files", + "projectSearch.toggle": "content", +} as const satisfies Partial>; -type CommandPaletteUiAction = - | { readonly _tag: "SetOpen"; readonly open: boolean } - | { readonly _tag: "Toggle" } - | { readonly _tag: "OpenAddProject" } - | { readonly _tag: "OpenNewThreadIn" } - | { readonly _tag: "ClearOpenIntent" }; - -function reduceCommandPaletteUiState( - state: CommandPaletteUiState, - action: CommandPaletteUiAction, -): CommandPaletteUiState { - switch (action._tag) { - case "SetOpen": - return { - open: action.open, - openIntent: action.open ? state.openIntent : null, - }; - case "Toggle": - return { open: !state.open, openIntent: null }; - case "OpenAddProject": - return { open: true, openIntent: { kind: "add-project" } }; - case "OpenNewThreadIn": - return { open: true, openIntent: { kind: "new-thread-in" } }; - case "ClearOpenIntent": - return state.openIntent ? { ...state, openIntent: null } : state; - } +function overlayModeForCommand(command: string | null): SearchOverlayMode | null { + if (command === null) return null; + return command in OVERLAY_MODE_BY_COMMAND + ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] + : null; } export function CommandPalette({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { open: false, + mode: "command", openIntent: null, }); const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); - const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); + const toggleMode = useCallback( + (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), + [], + ); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); @@ -405,28 +386,50 @@ export function CommandPalette({ children }: { children: ReactNode }) { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); + + useEffect(() => { + if (!state.open || state.mode === "command") return; + const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.isComposing || event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + toggleMode("command"); + }; + window.addEventListener("keydown", onEscapeKeyDown, true); + return () => window.removeEventListener("keydown", onEscapeKeyDown, true); + }, [state.mode, state.open, toggleMode]); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented) return; + // Resolve with the complete shortcut context so customized bindings + // using any documented `when` condition (e.g. previewFocus) work. const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, }, }); - if (command !== "commandPalette.toggle") { + const mode = overlayModeForCommand(command); + if (mode === null) { return; } event.preventDefault(); event.stopPropagation(); - toggleOpen(); + toggleMode(mode); }; // Capture phase so the shortcut fires even when a descendant (e.g. // xterm, a contenteditable composer) would otherwise swallow the event. window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); - }, [keybindings, terminalOpen, toggleOpen]); + }, [keybindings, previewOpen, terminalOpen, toggleMode]); useEffect( () => @@ -444,12 +447,24 @@ export function CommandPalette({ children }: { children: ReactNode }) { return ( - + { + if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { + eventDetails.cancel(); + toggleMode("command"); + return; + } + setOpen(open); + }} + > {children} @@ -459,31 +474,63 @@ export function CommandPalette({ children }: { children: ReactNode }) { function CommandPaletteDialog(props: { readonly open: boolean; + readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; + readonly openOverlayMode: (mode: SearchOverlayMode) => void; readonly clearOpenIntent: () => void; }) { + const composerHandleRef = useComposerHandleContext(); + if (!props.open) { return null; } return ( - + { + composerHandleRef?.current?.focusAtEnd(); + return false; + }} + onBackdropPointerDown={() => { + props.setOpen(false); + }} + > + {props.mode === "files" ? ( + + ) : props.mode === "content" ? ( + + ) : ( + + )} + ); } function OpenCommandPaletteDialog(props: { readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; + readonly openOverlayMode: (mode: SearchOverlayMode) => void; readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); - const { clearOpenIntent, openIntent, setOpen } = props; - const composerHandleRef = useComposerHandleContext(); + const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); @@ -1348,6 +1395,32 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:open-file-picker", + searchTerms: ["go to file", "open file", "file picker", "find file", "quick open"], + title: "Go to file", + icon: , + keepOpen: true, + shortcutCommand: "filePicker.toggle", + run: async () => { + openOverlayMode("files"); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:search-project-contents", + searchTerms: ["search project", "find in files", "grep", "content search", "text search"], + title: "Search project contents", + icon: , + keepOpen: true, + shortcutCommand: "projectSearch.toggle", + run: async () => { + openOverlayMode("content"); + }, + }); + actionItems.push({ kind: "action", value: "action:add-project", @@ -2064,236 +2137,189 @@ function OpenCommandPaletteDialog(props: { primaryEnvironmentId, ]); + const inputAccessory = + addProjectCloneFlow?.step === "repository" ? ( + + { + event.preventDefault(); + }} + onClick={() => { + void submitAddProjectCloneFlow(); + }} + /> + } + > + {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} + + Enter + + + {remoteProjectButtonLabel ?? "Continue"} (Enter) + + ) : isBrowsing ? ( + + { + event.preventDefault(); + }} + onClick={() => { + if (relativePathNeedsActiveProject) { + return; + } + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } + }} + /> + } + > + + {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} + + + {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} + + + + {submitActionLabel} ({addShortcutLabel}) + + + ) : null; + + const footerActionLabel = + addProjectCloneFlow?.step === "repository" + ? (remoteProjectButtonLabel ?? "Continue") + : !canSubmitBrowsePath || hasHighlightedBrowseItem + ? "Select" + : undefined; + + const footerTrailing = canOpenProjectFromFileManager ? ( + + ) : null; + return ( - { - composerHandleRef?.current?.focusAtEnd(); - return false; + autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} + footerActionLabel={footerActionLabel} + footerTrailing={footerTrailing} + inputAccessory={inputAccessory} + inputProps={{ + className: + addProjectCloneFlow?.step === "repository" + ? "pe-32" + : isBrowsing + ? willCreateProjectPath + ? "pe-36" + : "pe-16" + : undefined, + placeholder: inputPlaceholder, + wrapperClassName: isSubmenu + ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" + : undefined, + ...(isSubmenu + ? { + startAddon: ( + + ), + } + : isBrowsing + ? { startAddon: } + : {}), + onKeyDown: handleKeyDown, }} - onBackdropPointerDown={() => { - setOpen(false); + mode="none" + onItemHighlighted={(value) => { + setHighlightedItemValue(typeof value === "string" ? value : null); }} + onValueChange={handleQueryChange} + panelClassName="max-h-[min(28rem,70vh)]" + showBackHint={isSubmenu} + value={query} > - { - setHighlightedItemValue(typeof value === "string" ? value : null); - }} - onValueChange={handleQueryChange} - value={query} - > -
- +
Repository
+
+ {remoteProjectContext.icon} + + {remoteProjectContext.title} + + {remoteProjectContext.description} + + +
+
+ ) : null} + - - - ), - } - : isBrowsing && !isSubmenu + : addProjectCloneFlow?.step === "confirm" + ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } + : relativePathNeedsActiveProject + ? { emptyStateMessage: "Relative paths require an active project." } + : willCreateProjectPath ? { - startAddon: , + emptyStateMessage: "Press Enter to create this folder and add it as a project.", } - : {})} - onKeyDown={handleKeyDown} - /> - {addProjectCloneFlow?.step === "repository" ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitAddProjectCloneFlow(); - }} - /> - } - > - {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - - Enter - - - - {remoteProjectButtonLabel ?? "Continue"} (Enter) - - - ) : isBrowsing ? ( - - { - event.preventDefault(); - }} - onClick={() => { - if (relativePathNeedsActiveProject) { - return; - } - if (isCloneDestinationStep) { - void submitAddProjectCloneFlow(resolvedAddProjectPath); - } else { - void handleAddProject(resolvedAddProjectPath); - } - }} - /> - } - > - - {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} - - - {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} - - - - {submitActionLabel} ({addShortcutLabel}) - - - ) : null} -
- - {remoteProjectContext ? ( -
-
- Repository -
-
- {remoteProjectContext.icon} - - - {remoteProjectContext.title} - - - {remoteProjectContext.description} - - -
-
- ) : null} - -
- -
- - - - - - - - Navigate - - {addProjectCloneFlow?.step === "repository" ? ( - - Enter - {remoteProjectButtonLabel ?? "Continue"} - - ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( - - Enter - Select - - ) : null} - {isSubmenu ? ( - - Backspace - Back - - ) : null} - - Esc - Close - -
- {canOpenProjectFromFileManager ? ( - - ) : null} -
- - + : threadSearch.isPending + ? { emptyStateMessage: "Searching thread messages…" } + : {})} + /> + ); } diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx new file mode 100644 index 000000000000..af3c1b671704 --- /dev/null +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -0,0 +1,77 @@ +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; + +import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; +import { Kbd, KbdGroup } from "./ui/kbd"; + +type CommandPaletteContentProps = Omit, "children"> & { + readonly children: ReactNode; + readonly escapeLabel?: ReactNode; + readonly footerActionLabel?: ReactNode; + readonly footerTrailing?: ReactNode; + readonly inputAccessory?: ReactNode; + readonly inputProps: ComponentProps; + readonly panelClassName?: string; + readonly showBackHint?: boolean; + readonly testId?: string; +}; + +/** + * Shared command palette chrome. Palette modes provide their query behavior, + * results, and optional input accessory while retaining one input, panel, and + * keyboard-hint gutter. + */ +export function CommandPaletteContent({ + children, + escapeLabel = "Close", + footerActionLabel, + footerTrailing, + inputAccessory, + inputProps, + panelClassName, + showBackHint, + testId, + ...commandProps +}: CommandPaletteContentProps) { + return ( +
+ +
+ + {inputAccessory} +
+ {children} + +
+ + + + + + + + Navigate + + {footerActionLabel !== undefined ? ( + + Enter + {footerActionLabel} + + ) : null} + {showBackHint ? ( + + Backspace + Back + + ) : null} + + Esc + {escapeLabel} + +
+ {footerTrailing} +
+
+
+ ); +} diff --git a/apps/web/src/components/RenderErrorBoundary.tsx b/apps/web/src/components/RenderErrorBoundary.tsx new file mode 100644 index 000000000000..29f7d612d945 --- /dev/null +++ b/apps/web/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,16 @@ +import { Component, type ReactNode } from "react"; + +export class RenderErrorBoundary extends Component< + { readonly children: ReactNode; readonly fallback: ReactNode }, + { readonly failed: boolean } +> { + override state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + override render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 307d4413751d..ff658693a70c 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -26,6 +26,10 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; + /** File currently open in the preview pane; revealed and selected in the tree. */ + selectedPath: string | null; + /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ + selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; } @@ -98,6 +102,8 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, + selectedPath, + selectedPathRevealId, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -111,6 +117,9 @@ export default function FileBrowserPanel({ const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); + const syncingSelectionRef = useRef(false); + const treeSelectionPathRef = useRef(null); + const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so // capture the right-click position ourselves; contextmenu is a composed @@ -216,7 +225,12 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + // The drag controller's selection cache must track every change, + // including reveal-driven ones, or drags act on a stale selection. dragMention.handleSelectionChange(selectedPaths); + // Selection changes driven by the reveal sync below are echoes of an + // already-open file, not a request to open it again. + if (syncingSelectionRef.current) return; // Starting a drag selects the dragged row; that selection is a side // effect of the gesture, not a request to open the file. if (dragMention.isDragInProgress()) { @@ -224,6 +238,7 @@ export default function FileBrowserPanel({ } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { + treeSelectionPathRef.current = selectedPath; onOpenFile(selectedPath); } }, @@ -247,6 +262,63 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); }, [entryKinds, model, treePaths]); + useEffect(() => { + if (!selectedPath) { + handledRevealRef.current = null; + return; + } + const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; + const handledReveal = handledRevealRef.current; + // Entry refreshes rebuild treePaths while the same preview stays open. + // Replaying a handled reveal would close an active tree search and steal focus. + if ( + handledReveal?.path === revealRequest.path && + handledReveal.revealId === revealRequest.revealId + ) { + return; + } + if (entryKinds.get(selectedPath) !== "file") return; + const selectedItem = model.getItem(selectedPath); + if (!selectedItem) return; + + // A selection that originated inside the tree (clicking a row, possibly + // in an active tree search) is already visible; re-revealing it would + // close the search and clobber the user's context. Only sync external + // opens (file picker, content search, chat links). + const selectedInTree = model + .getSelectedPaths() + .some((path) => path.replace(/\/$/, "") === selectedPath); + if (selectedInTree && treeSelectionPathRef.current === selectedPath) { + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + return; + } + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + + syncingSelectionRef.current = true; + model.closeSearch(); + for (const path of model.getSelectedPaths()) { + model.getItem(path)?.deselect(); + } + + // Directory rows are registered with a trailing slash (see treePath), so + // ancestor lookups must use the same form to expand them. + const segments = selectedPath.split("/"); + let ancestorPath = ""; + for (const segment of segments.slice(0, -1)) { + ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; + const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); + if (item && "expand" in item) item.expand(); + } + + selectedItem.select(); + model.scrollToPath(selectedPath, { focus: true, offset: "center" }); + queueMicrotask(() => { + syncingSelectionRef.current = false; + }); + }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); + // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does // not depend on running after the tree's own dragstart handler; the drag diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 24e63a6d8eaf..a736cf96cd3e 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -51,6 +51,7 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; @@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); } +/** + * Frames to keep retrying while the file contents or line metrics are not + * available yet (fresh mounts hydrate asynchronously). + */ +const REVEAL_MAX_ATTEMPTS = 30; +/** + * After scrolling to the target, hold it for a short window so late + * programmatic scroll resets (editable-editor focus and state restoration) + * cannot silently snap the file back to the top. Real user input cancels the + * guard immediately. + */ +const REVEAL_GUARD_FRAMES = 20; +const REVEAL_GUARD_TOLERANCE_PX = 2; + +interface FileRevealState { + frameId: number | null; + cancelGuard: (() => void) | null; + handledRequestId: number | null; + latestRequestId: number | null; +} + function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number, ): FilePostRender { - const [handledRequestIdsByPath] = useState(() => new Map()); - const [latestRequestIdsByPath] = useState(() => new Map()); - const [pendingFramesByPath] = useState(() => new Map()); + const [revealStatesByPath] = useState(() => new Map()); return useCallback( (fileContainer, instance, phase) => { if (relativePath === null) return; + const existingState = revealStatesByPath.get(relativePath); + const state: FileRevealState = existingState ?? { + frameId: null, + cancelGuard: null, + handledRequestId: null, + latestRequestId: null, + }; + if (!existingState) revealStatesByPath.set(relativePath, state); + const cancelPendingReveal = () => { - const frameId = pendingFramesByPath.get(relativePath); - if (frameId !== undefined) { - cancelAnimationFrame(frameId); - pendingFramesByPath.delete(relativePath); + if (state.frameId !== null) { + cancelAnimationFrame(state.frameId); + state.frameId = null; } + state.cancelGuard?.(); }; if (phase === "unmount") { @@ -208,18 +237,20 @@ function useFileLineReveal( return; } + const contents = instance.file?.contents; const targetLine = - revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); + revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; - if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { + if (state.latestRequestId !== revealRequestId) { cancelPendingReveal(); - latestRequestIdsByPath.set(relativePath, revealRequestId); + state.latestRequestId = revealRequestId; + state.handledRequestId = null; } - if (targetLine === null) { + if (revealLine === null) { fileContainer.style.minHeight = ""; return; } @@ -230,54 +261,113 @@ function useFileLineReveal( Math.max(instance.height, scrollContainer.clientHeight), )}px`; - if ( - handledRequestIdsByPath.get(relativePath) === revealRequestId || - pendingFramesByPath.has(relativePath) - ) { + if (state.handledRequestId === revealRequestId || state.frameId !== null) { return; } - const reveal = () => { - pendingFramesByPath.delete(relativePath); - if ( - latestRequestIdsByPath.get(relativePath) !== revealRequestId || - !fileContainer.isConnected - ) { - return; - } - - const linePosition = instance.getLinePosition(targetLine); - if (!linePosition) return; + const resolveScrollTarget = (line: number): number | null => { + const linePosition = instance.getLinePosition(line); + if (!linePosition) return null; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - - scrollContainer.getBoundingClientRect().top; - const centeredTop = Math.max( - 0, - fileTop + - linePosition.top - - Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), - ); - const maxScrollTop = Math.max( - 0, - scrollContainer.scrollHeight - scrollContainer.clientHeight, - ); + scrollContainerRect.top; + const root = fileContainer.shadowRoot ?? fileContainer; + const renderedLineElement = root.querySelector(`[data-line="${line}"]`); + const renderedLineRect = renderedLineElement?.getBoundingClientRect(); - scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); - handledRequestIdsByPath.set(relativePath, revealRequestId); + return resolveCenteredFileLineScrollTop({ + scrollTop: scrollContainer.scrollTop, + scrollHeight: scrollContainer.scrollHeight, + viewportTop: scrollContainerRect.top, + viewportHeight: scrollContainer.clientHeight, + fileTop, + estimatedLine: linePosition, + ...(renderedLineRect && renderedLineRect.height > 0 + ? { + renderedLine: { + top: renderedLineRect.top, + height: renderedLineRect.height, + }, + } + : {}), + }); }; - pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); + const guardScrollTarget = (line: number) => { + let framesLeft = REVEAL_GUARD_FRAMES; + let guardFrameId: number | null = null; + const cancelGuard = () => { + if (guardFrameId !== null) { + cancelAnimationFrame(guardFrameId); + guardFrameId = null; + } + scrollContainer.removeEventListener("wheel", cancelGuard); + scrollContainer.removeEventListener("touchstart", cancelGuard); + scrollContainer.removeEventListener("pointerdown", cancelGuard, true); + window.removeEventListener("keydown", cancelGuard, true); + if (state.cancelGuard === cancelGuard) state.cancelGuard = null; + }; + scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); + scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); + // Pierre stops gutter pointer events from bubbling. Listen in capture + // so starting a comment cancels the reveal guard before the row expands. + scrollContainer.addEventListener("pointerdown", cancelGuard, { + passive: true, + capture: true, + }); + window.addEventListener("keydown", cancelGuard, true); + const holdTarget = () => { + guardFrameId = null; + framesLeft -= 1; + if (framesLeft <= 0 || !scrollContainer.isConnected) { + cancelGuard(); + return; + } + const targetTop = resolveScrollTarget(line); + if ( + targetTop !== null && + Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX + ) { + scrollContainer.scrollTop = targetTop; + } + guardFrameId = requestAnimationFrame(holdTarget); + }; + guardFrameId = requestAnimationFrame(holdTarget); + state.cancelGuard = cancelGuard; + }; + + const scheduleReveal = (attempt: number) => { + state.frameId = requestAnimationFrame(() => { + state.frameId = null; + if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { + return; + } + + // Contents and line metrics can lag the first post-render on fresh + // mounts; clamping against missing contents would scroll to line 1 + // and wrongly mark the request handled. + const currentContents = instance.file?.contents; + const line = + currentContents === undefined ? null : clampFileLine(currentContents, revealLine); + const targetTop = line === null ? null : resolveScrollTarget(line); + if (line === null || targetTop === null) { + if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); + return; + } + updateFileLinkReveal(fileContainer, line); + + scrollContainer.scrollTop = targetTop; + state.handledRequestId = revealRequestId; + guardScrollTarget(line); + }); + }; + + scheduleReveal(0); }, - [ - handledRequestIdsByPath, - latestRequestIdsByPath, - pendingFramesByPath, - relativePath, - revealLine, - revealRequestId, - ], + [revealStatesByPath, relativePath, revealLine, revealRequestId], ); } @@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} + selectedPath={relativePath} + selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} /> diff --git a/apps/web/src/components/files/ProjectFilePicker.logic.test.ts b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts new file mode 100644 index 000000000000..2693ac9a0729 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { getProjectFilePickerMatches } from "./ProjectFilePicker.logic"; + +function pathsForQuery(entries: Parameters[0], query: string) { + return getProjectFilePickerMatches(entries, query).map(({ name, path }) => ({ name, path })); +} + +const entries = [ + { kind: "directory", path: "apps/web/src" }, + { kind: "file", path: "apps/web/src/index.ts" }, + { kind: "file", path: "packages/shared/src/index.ts" }, + { kind: "file", path: "README.md" }, + { kind: "file", path: ".gitignore" }, +] as const; + +describe("getProjectFilePickerMatches", () => { + it("returns files only and preserves index order for an empty query", () => { + assert.deepEqual(pathsForQuery(entries, ""), [ + { name: "index.ts", path: "apps/web/src/index.ts" }, + { name: "index.ts", path: "packages/shared/src/index.ts" }, + { name: "README.md", path: "README.md" }, + { name: ".gitignore", path: ".gitignore" }, + ]); + }); + + it("preserves the server result order", () => { + assert.deepEqual(pathsForQuery(entries, "index"), [ + { name: "index.ts", path: "apps/web/src/index.ts" }, + { name: "index.ts", path: "packages/shared/src/index.ts" }, + { name: "README.md", path: "README.md" }, + { name: ".gitignore", path: ".gitignore" }, + ]); + }); + + it("supports space-separated path tokens and a result limit", () => { + assert.deepEqual( + getProjectFilePickerMatches(entries, "src index", 1).map(({ name, path }) => ({ + name, + path, + })), + [{ name: "index.ts", path: "apps/web/src/index.ts" }], + ); + }); + + it("matches ordered characters while allowing skipped characters", () => { + const fuzzyEntries = [ + { kind: "file", path: "src/TestFlags.tsx" }, + { kind: "file", path: "src/SubtestFlow.tsx" }, + { kind: "file", path: "src/useSubtestFlags.ts" }, + { + kind: "file", + path: "src/useSubtestFlags/useTabActivity.ts", + }, + ] as const; + + assert.deepEqual( + pathsForQuery(fuzzyEntries, "testf").map(({ name }) => name), + ["TestFlags.tsx", "SubtestFlow.tsx", "useSubtestFlags.ts", "useTabActivity.ts"], + ); + assert.deepEqual(getProjectFilePickerMatches(fuzzyEntries, "tsfl")[0], { + name: "TestFlags.tsx", + nameMatchIndices: [0, 2, 4, 5], + path: "src/TestFlags.tsx", + pathMatchIndices: [4, 6, 8, 9], + }); + }); + + it("uses the first ordered subsequence for highlighting", () => { + assert.deepEqual( + getProjectFilePickerMatches([{ kind: "file", path: "aabba" }], "aba")[0]?.nameMatchIndices, + [0, 2, 4], + ); + }); + + it("normalizes path prefixes consistently with server search", () => { + assert.deepEqual( + getProjectFilePickerMatches([{ kind: "file", path: "src/index.ts" }], "@/src")[0] + ?.pathMatchIndices, + [0, 1, 2], + ); + }); +}); diff --git a/apps/web/src/components/files/ProjectFilePicker.logic.ts b/apps/web/src/components/files/ProjectFilePicker.logic.ts new file mode 100644 index 000000000000..048c5373425d --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.logic.ts @@ -0,0 +1,73 @@ +import type { ProjectEntry } from "@t3tools/contracts"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; + +export const PROJECT_FILE_PICKER_RESULT_LIMIT = 200; + +export interface ProjectFilePickerMatch { + readonly name: string; + readonly nameMatchIndices: ReadonlyArray; + readonly path: string; + readonly pathMatchIndices: ReadonlyArray; +} + +function fileName(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +/** + * First ordered subsequence of `query` inside `value`, as highlight indices. + * Returns null when `value` does not contain the subsequence at all — the + * entry still renders (the server matched it), just without highlights. + */ +function findMatchIndices(value: string, query: string): number[] | null { + if (!query) return []; + + const normalizedValue = value.toLowerCase(); + const indices: number[] = []; + let queryIndex = 0; + + for (let valueIndex = 0; valueIndex < normalizedValue.length; valueIndex += 1) { + if (normalizedValue[valueIndex] !== query[queryIndex]) continue; + indices.push(valueIndex); + queryIndex += 1; + if (queryIndex === query.length) return indices; + } + + return null; +} + +/** + * Maps server search results to picker rows. Server ordering is preserved — + * ranking already happened there; this pass only filters to files and + * computes highlight positions with the same query normalization the server + * applied. + */ +export function getProjectFilePickerMatches( + entries: ReadonlyArray, + rawQuery: string, + limit = PROJECT_FILE_PICKER_RESULT_LIMIT, +): ProjectFilePickerMatch[] { + if (limit <= 0) return []; + + const query = normalizeSearchQuery(rawQuery, { + trimLeadingPattern: /^[@./]+/, + }).replaceAll(/\s/g, ""); + const matches: ProjectFilePickerMatch[] = []; + + for (const entry of entries) { + if (entry.kind !== "file") continue; + + const name = fileName(entry.path); + const nameMatchIndices = findMatchIndices(name, query); + const pathMatchIndices = findMatchIndices(entry.path, query); + matches.push({ + name, + nameMatchIndices: nameMatchIndices ?? [], + path: entry.path, + pathMatchIndices: pathMatchIndices ?? [], + }); + if (matches.length >= limit) break; + } + + return matches; +} diff --git a/apps/web/src/components/files/ProjectFilePicker.tsx b/apps/web/src/components/files/ProjectFilePicker.tsx new file mode 100644 index 000000000000..a59e8dd77e47 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -0,0 +1,163 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo, useState, type ReactNode } from "react"; + +import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; +import { useTheme } from "~/hooks/useTheme"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { primaryServerKeybindingsAtom } from "~/state/server"; + +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { CommandPaletteContent } from "../CommandPaletteContent"; +import { type CommandPaletteActionItem } from "../CommandPalette.logic"; +import { CommandPaletteResults } from "../CommandPaletteResults"; +import { + getProjectFilePickerMatches, + PROJECT_FILE_PICKER_RESULT_LIMIT, +} from "./ProjectFilePicker.logic"; +import { useProjectFilePickerQuery } from "./projectFilesQueryState"; + +interface ProjectFilePickerProps { + readonly setOpen: (open: boolean) => void; +} + +function HighlightedFuzzyText(props: { + readonly active: boolean; + readonly indices: ReadonlyArray; + readonly value: string; +}) { + if (!props.active) return props.value; + + const parts: ReactNode[] = []; + let start = 0; + for (const index of props.indices) { + if (start < index) parts.push(props.value.slice(start, index)); + parts.push( + + {props.value[index]} + , + ); + start = index + 1; + } + if (start < props.value.length) parts.push(props.value.slice(start)); + + return {parts}; +} + +function getEmptyStateMessage(query: string, error: string | null, isPending: boolean): string { + if (error) return error; + const isSearching = query.trim().length > 0; + if (isPending) return isSearching ? "Searching workspace files…" : "Indexing workspace files…"; + return isSearching ? "No matching files." : "No files found."; +} + +function EmptyProjectFilePicker() { + return ( + +
+ Open a project to search its files. +
+
+ ); +} + +function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveProjectTarget }) { + const { target } = props; + const [query, setQuery] = useState(""); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const result = useProjectFilePickerQuery( + target.environmentId, + target.cwd, + query, + PROJECT_FILE_PICKER_RESULT_LIMIT, + ); + const { resolvedTheme } = useTheme(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const matches = useMemo( + () => getProjectFilePickerMatches(result.entries, result.matchedQuery), + [result.entries, result.matchedQuery], + ); + const hasMatchedQuery = /\S/.test(result.matchedQuery); + const items = useMemo( + () => + matches.map((match) => ({ + kind: "action", + value: `file:${match.path}`, + searchTerms: [match.name, match.path], + title: ( + + ), + description: ( + + ), + icon: , + run: async () => { + useRightPanelStore.getState().openFile(target.threadRef, match.path); + }, + })), + [hasMatchedQuery, matches, resolvedTheme, target.threadRef], + ); + + const emptyStateMessage = getEmptyStateMessage(query, result.error, result.isPending); + + return ( + { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} + onValueChange={(value) => { + setHighlightedItemValue(null); + setQuery(value); + }} + panelClassName="max-h-[min(34rem,76vh)]" + testId="project-file-picker" + value={query} + > + 0 ? [{ value: "project-files", label: target.projectName, items }] : [] + } + highlightedItemValue={highlightedItemValue} + isActionsOnly={false} + keybindings={keybindings} + onExecuteItem={(item) => { + if (item.kind !== "action") return; + props.setOpen(false); + void item.run(); + }} + emptyStateMessage={emptyStateMessage} + /> + + ); +} + +export function ProjectFilePicker(props: ProjectFilePickerProps) { + const target = useActiveProjectTarget(); + + if (!target) { + return ; + } + + return ; +} diff --git a/apps/web/src/components/files/fileLineReveal.test.ts b/apps/web/src/components/files/fileLineReveal.test.ts new file mode 100644 index 000000000000..3a63168d5d3b --- /dev/null +++ b/apps/web/src/components/files/fileLineReveal.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; + +describe("resolveCenteredFileLineScrollTop", () => { + it("centers an estimated virtualized line position", () => { + expect( + resolveCenteredFileLineScrollTop({ + scrollTop: 0, + scrollHeight: 2_000, + viewportTop: 100, + viewportHeight: 400, + fileTop: 20, + estimatedLine: { top: 1_000, height: 20 }, + }), + ).toBe(830); + }); + + it("corrects a stale estimate from the rendered line geometry", () => { + expect( + resolveCenteredFileLineScrollTop({ + scrollTop: 830, + scrollHeight: 2_000, + viewportTop: 100, + viewportHeight: 400, + fileTop: 20, + estimatedLine: { top: 1_000, height: 20 }, + renderedLine: { top: 620, height: 20 }, + }), + ).toBe(1_160); + }); +}); diff --git a/apps/web/src/components/files/fileLineReveal.ts b/apps/web/src/components/files/fileLineReveal.ts new file mode 100644 index 000000000000..fd066ae205bf --- /dev/null +++ b/apps/web/src/components/files/fileLineReveal.ts @@ -0,0 +1,24 @@ +interface LineGeometry { + readonly top: number; + readonly height: number; +} + +interface CenteredFileLineScrollInput { + readonly scrollTop: number; + readonly scrollHeight: number; + readonly viewportTop: number; + readonly viewportHeight: number; + readonly fileTop: number; + readonly estimatedLine: LineGeometry; + readonly renderedLine?: LineGeometry; +} + +export function resolveCenteredFileLineScrollTop(input: CenteredFileLineScrollInput): number { + const lineTop = + input.renderedLine === undefined + ? input.fileTop + input.estimatedLine.top + : input.scrollTop + input.renderedLine.top - input.viewportTop; + const lineHeight = input.renderedLine?.height ?? input.estimatedLine.height; + const centeredTop = Math.max(0, lineTop - Math.max(0, (input.viewportHeight - lineHeight) / 2)); + return Math.min(centeredTop, Math.max(0, input.scrollHeight - input.viewportHeight)); +} diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 0d3fb8dd9413..d165c1d1a7a7 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -11,6 +11,7 @@ import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; +import { useProjectPathSearch } from "~/state/queries"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; @@ -136,6 +137,32 @@ export function useProjectEntriesQuery( }; } +/** + * Backing query for the project file picker: a debounced, bounded, file-only + * server search. An empty query is a valid request — the index answers it + * with frecency-ordered files, so the picker's initial view is recent files + * without transferring the full workspace listing. `matchedQuery` is the + * query the returned entries were computed for, so the caller can highlight + * against results instead of half-typed input. + */ +export function useProjectFilePickerQuery( + environmentId: EnvironmentId, + cwd: string, + query: string, + limit: number, +) { + const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { + allowEmptyQuery: true, + }); + + return { + entries: search.isPending ? [] : search.entries, + error: search.error, + isPending: search.isPending, + matchedQuery: search.searchedQuery, + }; +} + export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/components/search/HighlightedSearchLine.tsx b/apps/web/src/components/search/HighlightedSearchLine.tsx new file mode 100644 index 000000000000..9d5296227765 --- /dev/null +++ b/apps/web/src/components/search/HighlightedSearchLine.tsx @@ -0,0 +1,183 @@ +import { getFiletypeFromFileName } from "@pierre/diffs"; +import type { ProjectContentMatch } from "@t3tools/contracts"; +import { memo, Suspense, use, useMemo, type CSSProperties } from "react"; + +import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +import { RenderErrorBoundary } from "../RenderErrorBoundary"; + +interface Range { + readonly start: number; + readonly end: number; +} + +interface CodeToken { + readonly content: string; + readonly offset: number; + readonly color?: string; + readonly fontStyle?: number; +} + +interface Segment { + readonly content: string; + readonly isMatch: boolean; + readonly start: number; + readonly end: number; + readonly token: CodeToken; +} + +function normalizeRanges(match: ProjectContentMatch): Range[] { + const ranges = match.matchRanges + .map((range) => ({ + start: Math.max(0, Math.min(match.lineContent.length, range.start)), + end: Math.max(0, Math.min(match.lineContent.length, range.end)), + })) + .filter((range) => range.end > range.start) + .toSorted((left, right) => left.start - right.start); + + const merged: Array<{ start: number; end: number }> = []; + for (const range of ranges) { + const previous = merged.at(-1); + if (previous && range.start <= previous.end) { + previous.end = Math.max(previous.end, range.end); + } else { + merged.push({ ...range }); + } + } + return merged; +} + +function splitToken(line: string, token: CodeToken, ranges: ReadonlyArray): Segment[] { + const segments: Segment[] = []; + const tokenEnd = token.offset + token.content.length; + let cursor = token.offset; + + for (const range of ranges) { + if (range.end <= cursor) continue; + if (range.start >= tokenEnd) break; + + const matchStart = Math.max(cursor, range.start); + if (matchStart > cursor) { + segments.push({ + content: line.slice(cursor, matchStart), + isMatch: false, + start: cursor, + end: matchStart, + token, + }); + } + + const matchEnd = Math.min(tokenEnd, range.end); + segments.push({ + content: line.slice(matchStart, matchEnd), + isMatch: true, + start: matchStart, + end: matchEnd, + token, + }); + cursor = matchEnd; + } + + if (cursor < tokenEnd) { + segments.push({ + content: line.slice(cursor, tokenEnd), + isMatch: false, + start: cursor, + end: tokenEnd, + token, + }); + } + return segments; +} + +function tokenStyle(token: CodeToken): CSSProperties { + const fontStyle = token.fontStyle ?? 0; + return { + ...(token.color ? { color: token.color } : {}), + ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), + ...(fontStyle & 2 ? { fontWeight: 700 } : {}), + ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), + }; +} + +function HighlightedTokens(props: { + readonly line: string; + readonly ranges: ReadonlyArray; + readonly tokens: ReadonlyArray; +}) { + return props.tokens + .flatMap((token) => splitToken(props.line, token, props.ranges)) + .map((segment) => + segment.isMatch ? ( + + {segment.content} + + ) : ( + + {segment.content} + + ), + ); +} + +function SyntaxHighlightedTokens(props: { + readonly line: string; + readonly language: string; + readonly ranges: ReadonlyArray; + readonly theme: "light" | "dark"; +}) { + const highlighter = use(getSyntaxHighlighterPromise(props.language)); + const tokens = useMemo(() => { + try { + return highlighter.codeToTokens(props.line, { + lang: props.language, + theme: resolveDiffThemeName(props.theme), + }).tokens[0]; + } catch { + return undefined; + } + }, [highlighter, props.language, props.line, props.theme]); + + return tokens ? ( + + ) : ( + + ); +} + +export const HighlightedSearchLine = memo(function HighlightedSearchLine(props: { + readonly match: ProjectContentMatch; + readonly path: string; + readonly theme: "light" | "dark"; +}) { + const ranges = useMemo(() => normalizeRanges(props.match), [props.match]); + const fallback = ( + + ); + + return ( + + + + + + ); +}); diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx new file mode 100644 index 000000000000..1890aab7fa79 --- /dev/null +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -0,0 +1,315 @@ +import type { ProjectContentMatch } from "@t3tools/contracts"; +import { LoaderCircle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; + +import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; +import { useTheme } from "~/hooks/useTheme"; +import { cn } from "~/lib/utils"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useProjectContentSearch } from "~/state/queries"; + +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { CommandPaletteContent } from "../CommandPaletteContent"; +import { ScrollArea } from "../ui/scroll-area"; +import { HighlightedSearchLine } from "./HighlightedSearchLine"; + +interface ProjectContentSearchDialogProps { + readonly onOpenChange: (open: boolean) => void; +} + +/** + * Result rows are syntax highlighted, so mounting all 500 possible rows at + * once stalls the UI. Rows render in windows that grow as the sentinel at the + * bottom of the list scrolls into view (or keyboard navigation moves past + * the rendered window). + */ +const VISIBLE_MATCH_WINDOW = 100; + +interface MatchGroup { + readonly path: string; + readonly matches: ReadonlyArray; +} + +function splitPath(path: string): { readonly name: string; readonly directory: string } { + const separator = path.lastIndexOf("/"); + return separator === -1 + ? { name: path, directory: "" } + : { name: path.slice(separator + 1), directory: path.slice(0, separator) }; +} + +function groupMatches(matches: ReadonlyArray): MatchGroup[] { + const groups = new Map>(); + matches.forEach((match, resultIndex) => { + const group = groups.get(match.path); + const indexedMatch = { ...match, resultIndex }; + if (group) { + group.push(indexedMatch); + } else { + groups.set(match.path, [indexedMatch]); + } + }); + return [...groups].map(([path, groupedMatches]) => ({ path, matches: groupedMatches })); +} + +function SearchOptionButton(props: { + readonly active: boolean; + readonly label: string; + readonly onClick: () => void; + readonly children: ReactNode; +}) { + return ( + + ); +} + +function EmptyContentSearchDialog() { + return ( + + Open a project to search its files. + + ); +} + +function OpenContentSearchDialog(props: { + readonly onOpenChange: (open: boolean) => void; + readonly target: ActiveProjectTarget; +}) { + const { target } = props; + const { resolvedTheme } = useTheme(); + const [query, setQuery] = useState(""); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [useRegex, setUseRegex] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + const [visibleCount, setVisibleCount] = useState(VISIBLE_MATCH_WINDOW); + + const search = useProjectContentSearch({ + environmentId: target.environmentId, + cwd: target.cwd, + query, + caseSensitive, + wholeWord, + useRegex, + }); + const canOpenMatches = !search.isPending; + const matches = search.matches; + const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]); + const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); + + useEffect(() => { + setSelectedIndex(0); + setVisibleCount(VISIBLE_MATCH_WINDOW); + }, [matches]); + + useEffect(() => { + if (selectedIndex >= visibleCount) { + setVisibleCount(selectedIndex + VISIBLE_MATCH_WINDOW); + return; + } + document + .querySelector(`[data-content-search-result="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex, visibleCount]); + + const observeLoadMoreSentinel = useCallback((sentinel: HTMLElement | null) => { + if (!sentinel) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setVisibleCount((current) => current + VISIBLE_MATCH_WINDOW); + } + }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, []); + + const openMatch = (match: ProjectContentMatch) => { + if (!canOpenMatches) return; + props.onOpenChange(false); + useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); + }; + const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); + const showSearchStatus = + search.hasQuery || search.isPending || search.error !== null || search.invalidRegex; + + return ( + + setCaseSensitive((current) => !current)} + > + Aa + + setWholeWord((current) => !current)} + > + ab + + setUseRegex((current) => !current)} + > + .* + +
+ } + inputProps={{ + className: "pe-30", + placeholder: `Search in ${target.projectName}`, + onKeyDown: (event) => { + if (event.key === "ArrowDown" && matches.length > 0) { + event.preventDefault(); + setSelectedIndex((current) => (current + 1) % matches.length); + } else if (event.key === "ArrowUp" && matches.length > 0) { + event.preventDefault(); + setSelectedIndex((current) => (current - 1 + matches.length) % matches.length); + } else if (event.key === "Enter") { + // While a newer query is debouncing or in flight, the visible + // matches belong to the previous query; opening one would jump + // to a result the user did not ask for. + if (!canOpenMatches) { + event.preventDefault(); + return; + } + const match = matches[selectedIndex]; + if (match) { + event.preventDefault(); + openMatch(match); + } + } + }, + }} + mode="none" + onValueChange={setQuery} + panelClassName="flex min-h-0 flex-1 flex-col" + testId="project-content-search" + value={query} + > + {showSearchStatus ? ( +
+ {search.isPending ? ( + + Searching… + + ) : search.error ? ( + {search.error} + ) : search.invalidRegex ? ( + Invalid regular expression + ) : ( + `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` + )} +
+ ) : null} + + {matches.length === 0 ? ( +
+ {search.hasQuery && !search.isPending && !search.error + ? "No results found." + : "Type to search across your project."} +
+ ) : ( + +
+ {groups.map((group) => { + const path = splitPath(group.path); + return ( +
+
+ + {path.name} + {path.directory ? ( + + {path.directory} + + ) : null} + + {group.matches.length} + +
+ {group.matches.map((match) => ( + + ))} +
+ ); + })} + {matches.length > visibleCount ? ( + + + )} + + ); +} + +export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) { + const target = useActiveProjectTarget(); + + return target ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/hooks/useActiveProjectTarget.ts b/apps/web/src/hooks/useActiveProjectTarget.ts new file mode 100644 index 000000000000..560bba54f73c --- /dev/null +++ b/apps/web/src/hooks/useActiveProjectTarget.ts @@ -0,0 +1,41 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; + +import { useProjects } from "~/state/entities"; + +import { useHandleNewThread } from "./useHandleNewThread"; + +export interface ActiveProjectTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly projectName: string; + readonly threadRef: ScopedThreadRef; +} + +/** + * Resolves the project workspace behind the active thread (or draft) so + * project-scoped surfaces like the file picker and content search know which + * workspace to query and which thread's right panel opens their results. + */ +export function useActiveProjectTarget(): ActiveProjectTarget | null { + const { activeDraftThread, activeThread } = useHandleNewThread(); + const projects = useProjects(); + const thread = activeThread ?? activeDraftThread; + const threadId = activeThread?.id ?? activeDraftThread?.threadId; + const project = thread + ? projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, + ) + : null; + const cwd = thread?.worktreePath ?? project?.workspaceRoot; + + if (!thread || !threadId || !project || !cwd) return null; + + return { + environmentId: project.environmentId, + cwd, + projectName: project.title, + threadRef: scopeThreadRef(thread.environmentId, threadId), + }; +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d277207b0698..20428bd6491f 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -119,6 +119,16 @@ const DEFAULT_BINDINGS = compile([ command: "commandPalette.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("p"), + command: "filePicker.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("f", { shiftKey: true }), + command: "projectSearch.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("m", { shiftKey: true }), command: "modelPicker.toggle", @@ -325,6 +335,14 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"), + "⌘P", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "projectSearch.toggle", "MacIntel"), + "⇧⌘F", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -525,6 +543,40 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches filePicker.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "filePicker.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "filePicker.toggle", + ); + }); + + it("matches projectSearch.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "projectSearch.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "projectSearch.toggle", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/lib/syntaxHighlighting.test.ts b/apps/web/src/lib/syntaxHighlighting.test.ts new file mode 100644 index 000000000000..1f7ce756b60c --- /dev/null +++ b/apps/web/src/lib/syntaxHighlighting.test.ts @@ -0,0 +1,28 @@ +import type { DiffsHighlighter } from "@pierre/diffs"; +import { expect, it, vi } from "vite-plus/test"; + +const { getSharedHighlighter } = vi.hoisted(() => ({ + getSharedHighlighter: vi.fn(), +})); + +vi.mock("@pierre/diffs", () => ({ + getSharedHighlighter, +})); + +import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; + +it("caches the recovered text highlighter for unsupported languages", async () => { + const textHighlighter = {} as DiffsHighlighter; + getSharedHighlighter.mockImplementation(({ langs }: { langs: string[] }) => + langs[0] === "text" + ? Promise.resolve(textHighlighter) + : Promise.reject(new Error("unsupported language")), + ); + + const first = getSyntaxHighlighterPromise("unsupported-test-language"); + await expect(first).resolves.toBe(textHighlighter); + const second = getSyntaxHighlighterPromise("unsupported-test-language"); + + expect(second).toBe(first); + expect(getSharedHighlighter).toHaveBeenCalledTimes(2); +}); diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts new file mode 100644 index 000000000000..171725617e80 --- /dev/null +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -0,0 +1,30 @@ +import { + getSharedHighlighter, + type DiffsHighlighter, + type SupportedLanguages, +} from "@pierre/diffs"; + +import { resolveDiffThemeName } from "./diffRendering"; + +const highlighterPromiseCache = new Map>(); + +export function getSyntaxHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); + if (cached) return cached; + + const promise = getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", + }).catch((error) => { + if (language === "text") { + highlighterPromiseCache.delete(language); + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw error; + } + // Language not supported by Shiki — fall back to "text" + return getSyntaxHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index 7a8799883281..d4e1098a364e 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -1,11 +1,24 @@ import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; +import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; +import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +/** + * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile + * surface, so the atom family lives here instead of the shared client-runtime + * project atoms consumed by the mobile app. + */ +export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:projects:search-contents", + tag: WS_METHODS.projectsSearchContents, + staleTimeMs: 5_000, + idleTtlMs: 60_000, +}); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, diff --git a/apps/web/src/state/queries.test.ts b/apps/web/src/state/queries.test.ts new file mode 100644 index 000000000000..f8887dfb8ef5 --- /dev/null +++ b/apps/web/src/state/queries.test.ts @@ -0,0 +1,25 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { areProjectPathSearchTargetsEqual } from "./queries"; + +describe("areProjectPathSearchTargetsEqual", () => { + const target = { + environmentId: EnvironmentId.make("environment-a"), + cwd: "/project-a", + query: "index", + }; + + it("requires the environment, workspace, query, and entry kind to match", () => { + expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true); + expect( + areProjectPathSearchTargetsEqual(target, { + ...target, + environmentId: EnvironmentId.make("environment-b"), + }), + ).toBe(false); + expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false); + expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false); + expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index a9564c2fd647..2a095b8f5846 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -12,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProjectContentMatch, + ProjectEntryKind, ThreadId, VcsListRefsResult, VcsRef, @@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; -import { projectEnvironment } from "./projects"; +import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; -const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; +const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; +const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ @@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -export function useComposerPathSearch(target: ComposerPathSearchTarget) { +type ProjectPathSearchTarget = ComposerPathSearchTarget & { + readonly kind?: ProjectEntryKind | undefined; +}; + +export function areProjectPathSearchTargetsEqual( + left: ProjectPathSearchTarget, + right: ProjectPathSearchTarget, +): boolean { + return ( + left.environmentId === right.environmentId && + left.cwd === right.cwd && + left.query === right.query && + left.kind === right.kind + ); +} + +export function useProjectPathSearch( + target: ProjectPathSearchTarget, + limit: number, + options?: { readonly allowEmptyQuery?: boolean }, +) { + const allowEmptyQuery = options?.allowEmptyQuery === true; const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, - query: target.query?.trim() ?? "", + query: target.query == null ? null : target.query.trim(), + kind: target.kind, }), - [target.cwd, target.environmentId, target.query], + [target.cwd, target.environmentId, target.kind, target.query], ); - const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query.length > 0 + debouncedTarget.query !== null && + (allowEmptyQuery || debouncedTarget.query.length > 0) ? projectEnvironment.searchEntries({ environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit: COMPOSER_PATH_SEARCH_LIMIT, + limit, + ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), }, }) : null, @@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return { entries: result.data?.entries ?? [], error: result.error, - isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + isPending: + !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, + searchedQuery: debouncedTarget.query ?? "", refresh: result.refresh, }; } +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); +} + +interface ProjectContentSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string; + readonly caseSensitive: boolean; + readonly wholeWord: boolean; + readonly useRegex: boolean; +} + +export function useProjectContentSearch(target: ProjectContentSearchTarget) { + // Whitespace is significant in content queries; trimming is only used to + // decide whether the input is blank. + const query = target.query; + const hasQuery = query.trim().length > 0; + const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + target.environmentId !== null && + target.cwd !== null && + hasQuery && + debouncedQuery.trim().length > 0 + ? projectContentSearch({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + query: debouncedQuery, + limit: PROJECT_CONTENT_SEARCH_LIMIT, + caseSensitive: target.caseSensitive, + wholeWord: target.wholeWord, + useRegex: target.useRegex, + }, + }) + : null, + ); + + return { + matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, + error: result.error, + isPending: hasQuery && (query !== debouncedQuery || result.isPending), + hasQuery, + truncated: result.data?.truncated ?? false, + invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, + }; +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 0746272633f9..5ef47b38fb91 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -69,6 +69,10 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) +`filePicker.toggle` opens file search for the active project and defaults to `mod+p`. +`projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. +Repeating either shortcut closes that search, and switching shortcuts replaces the open search. + The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while keeping the thread's project, branch, and machine context visible. Message search begins after two diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index dca661911c2a..8465da79445f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -65,6 +65,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedFilePicker = yield* decode(KeybindingRule, { + key: "mod+p", + command: "filePicker.toggle", + }); + assert.strictEqual(parsedFilePicker.command, "filePicker.toggle"); + + const parsedProjectSearch = yield* decode(KeybindingRule, { + key: "mod+shift+f", + command: "projectSearch.toggle", + }); + assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d966c1e7e61a..14a2a9a4b300 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "filePicker.toggle", + "projectSearch.toggle", "composer.stash", "chat.new", "chat.newLocal", diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index ea9d5a90e7c6..8e6771cba88f 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectSearchContentsError, + ProjectSearchContentsInput, ProjectSearchEntriesError, + ProjectSearchEntriesInput, ProjectWriteFileError, } from "./project.ts"; +const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); +const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); + +describe("project search inputs", () => { + it("allows an empty entries query for bounded frecency browsing", () => { + const decoded = decodeSearchEntriesInput({ + cwd: "/workspace", + query: " ", + limit: 10, + kind: "file", + }); + expect(decoded.query).toBe(""); + }); + + it("preserves whitespace in content search queries", () => { + const decoded = decodeSearchContentsInput({ + cwd: "/workspace", + query: " foo ", + limit: 10, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }); + expect(decoded.query).toBe(" foo "); + }); +}); + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); @@ -39,6 +69,18 @@ describe("project RPC errors", () => { expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); expect(readError.message).not.toContain(cause.message); expect(readError.cause).toBe(cause); + + const contentSearchError = new ProjectSearchContentsError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 100, + failure: "search_index_search_failed", + cause, + }); + expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); + expect(contentSearchError.message).not.toContain(cause.message); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.cause).toBe(cause); }); it("decodes legacy message-only errors during rolling upgrades", () => { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d59b9770ad32..a1b11df73b21 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,19 +1,29 @@ import * as Schema from "effect/Schema"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; +export const ProjectEntryKind = Schema.Literals(["file", "directory"]); +export type ProjectEntryKind = typeof ProjectEntryKind.Type; + export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + // An empty query is a bounded browse: the index returns frecency-ordered + // entries, which the file picker uses for its initial results. + query: TrimmedString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), + kind: Schema.optional(ProjectEntryKind), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; -const ProjectEntryKind = Schema.Literals(["file", "directory"]); - export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, @@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export const ProjectSearchContentsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + // Whitespace is significant in content queries (" foo", regex trailing + // spaces), so the query is deliberately not trimmed on the wire. + query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)), + caseSensitive: Schema.Boolean, + wholeWord: Schema.Boolean, + useRegex: Schema.Boolean, +}); +export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type; + +export const ProjectContentMatchRange = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, +}); +export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type; + +export const ProjectContentMatch = Schema.Struct({ + path: TrimmedNonEmptyString, + lineNumber: PositiveInt, + lineContent: Schema.String, + matchRanges: Schema.Array(ProjectContentMatchRange), +}); +export type ProjectContentMatch = typeof ProjectContentMatch.Type; + +export const ProjectSearchContentsResult = Schema.Struct({ + matches: Schema.Array(ProjectContentMatch), + truncated: Schema.Boolean, + regexFallbackError: Schema.optional(Schema.String), +}); +export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type; + export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, }); @@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( + "ProjectSearchContentsError", + { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace contents in '${props.cwd}'.`, + } as any); + } +} + export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 17fbd57ddad1..400011f88435 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -76,6 +76,9 @@ import { ProjectReadFileError, ProjectReadFileInput, ProjectReadFileResult, + ProjectSearchContentsError, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -166,6 +169,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", projectsReadFile: "projects.readFile", + projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", @@ -438,6 +442,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); +export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { + payload: ProjectSearchContentsInput, + success: ProjectSearchContentsResult, + error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), +}); + export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, @@ -801,6 +811,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, + WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 0688cf072546..6add9f478444 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, + { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, + { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, From 0dd7568ef0b89410e21a6c3452351f50b99fbbf8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 07:31:53 -0700 Subject: [PATCH 09/15] fix(connect): reboots no longer strand the relay link, 403s now say why (#4988) Co-authored-by: Claude Fable 5 (cherry picked from commit 1877237c7ae99ecf2163b4c5bb7d3e14bebcca4d) --- .../src/features/cloud/linkEnvironment.ts | 9 +++++++- apps/server/src/server.ts | 22 ++++++++++++++++++- apps/web/src/cloud/linkEnvironment.ts | 9 +++++++- .../src/environments/EnvironmentConnector.ts | 18 +++------------ infra/relay/src/http/Api.ts | 6 +++-- packages/contracts/src/relay.ts | 20 ++++++++++++++++- 6 files changed, 63 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 5d619c688f1c..958827ee492b 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -134,7 +134,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; + // "Not authorized" covers non-auth causes too; surface the reason so a + // missing link doesn't read as a credential problem. + if (error.reason === "environment_link_not_found") { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; + } + return error.reason + ? `Relay rejected the environment connection request (${error.reason}).` + : "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0d36e99bc96..853bb0b11013 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,6 +1,8 @@ import { EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -535,7 +537,25 @@ export const makeServerLayer = Layer.unwrap( yield* Effect.forkScoped( Effect.sleep("250 millis").pipe( Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), - Effect.retry({ times: 4 }), + // On reboot this races NIC/DNS bring-up, so back off exponentially + // (capped at 30s) instead of burning all retries in a second. + // Bounded overall so a permanently broken setup still surfaces the + // warning below. Bad-request/unauthorized/conflict are + // deterministic failures (malformed origin, not linked yet, linked + // to a different cloud account) that no amount of retrying + // converges. + Effect.retry({ + while: (error) => + error._tag !== "EnvironmentHttpBadRequestError" && + error._tag !== "EnvironmentHttpUnauthorizedError" && + error._tag !== "EnvironmentHttpConflictError", + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.upTo({ duration: "10 minutes" }), + ), + }), Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), Effect.catch((cause) => Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 22ff986afbd1..a245cbc54db2 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -145,7 +145,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; + // "Not authorized" covers non-auth causes too; surface the reason so a + // missing link doesn't read as a credential problem. + if (error.reason === "environment_link_not_found") { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; + } + return error.reason + ? `Relay rejected the environment connection request (${error.reason}).` + : "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index db662aee94dc..d840f809e5af 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -13,6 +13,7 @@ import { RelayEnvironmentMintResponse, RelayEnvironmentMintResponseProofPayload, RelayCloudMintCredentialProofPayload, + RelayEnvironmentConnectNotAuthorizedReason, type RelayEnvironmentConnectResponse, type RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; @@ -44,21 +45,8 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; import * as RelayConfiguration from "../Config.ts"; import { isManagedEndpointHostname } from "../deploymentConfig.ts"; -export const EnvironmentConnectNotAuthorizedReason = Schema.Literals([ - "client_proof_key_thumbprint_missing", - "environment_link_not_found", - "endpoint_provider_not_managed", - "managed_endpoint_allocation_not_found", - "managed_endpoint_base_domain_not_configured", - "managed_endpoint_allocation_not_ready", - "managed_endpoint_hostname_invalid", - "managed_endpoint_mismatch", -]); -export type EnvironmentConnectNotAuthorizedReason = - typeof EnvironmentConnectNotAuthorizedReason.Type; - function environmentConnectNotAuthorizedReasonMessage( - reason: EnvironmentConnectNotAuthorizedReason, + reason: RelayEnvironmentConnectNotAuthorizedReason, ): string { switch (reason) { case "client_proof_key_thumbprint_missing": @@ -85,7 +73,7 @@ export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass + EnvironmentConnectNotAuthorized: (error, traceId) => new RelayEnvironmentConnectNotAuthorizedError({ code: "environment_connect_not_authorized", + reason: error.reason, traceId, }), EnvironmentMintRequestFailed: (_error, traceId) => @@ -820,9 +821,10 @@ export const dpopClientApi = HttpApiBuilder.group( }, mapRelayCommonApiErrors("invalid_dpop"), mapErrorTags({ - EnvironmentConnectNotAuthorized: (_error, traceId) => + EnvironmentConnectNotAuthorized: (error, traceId) => new RelayEnvironmentConnectNotAuthorizedError({ code: "environment_connect_not_authorized", + reason: error.reason, traceId, }), EnvironmentMintRequestFailed: (_error, traceId) => diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index ff9a9e3ac613..52f7d7d43550 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -371,16 +371,34 @@ export class RelayEnvironmentLinkProofInvalidError extends Schema.TaggedErrorCla } } +export const RelayEnvironmentConnectNotAuthorizedReason = Schema.Literals([ + "client_proof_key_thumbprint_missing", + "environment_link_not_found", + "endpoint_provider_not_managed", + "managed_endpoint_allocation_not_found", + "managed_endpoint_base_domain_not_configured", + "managed_endpoint_allocation_not_ready", + "managed_endpoint_hostname_invalid", + "managed_endpoint_mismatch", +]); +export type RelayEnvironmentConnectNotAuthorizedReason = + typeof RelayEnvironmentConnectNotAuthorizedReason.Type; + export class RelayEnvironmentConnectNotAuthorizedError extends Schema.TaggedErrorClass()( "RelayEnvironmentConnectNotAuthorizedError", { code: Schema.Literal("environment_connect_not_authorized"), + // Optional so responses from relays deployed before the reason was + // threaded through still decode. + reason: Schema.optional(RelayEnvironmentConnectNotAuthorizedReason), traceId: TrimmedNonEmptyString, }, { httpApiStatus: 403 }, ) { override get message(): string { - return "Relay environment connection is not authorized"; + return this.reason + ? `Relay environment connection is not authorized: ${this.reason}` + : "Relay environment connection is not authorized"; } } From e728a40fee68659281c484044118271719f63a31 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 29 Jul 2026 21:54:08 -0700 Subject: [PATCH 10/15] docs: link iOS and Android app store downloads (#4902) Co-authored-by: Claude Fable 5 (cherry picked from commit 748203ea19fb68741709614b33b305079f0f4c05) --- apps/marketing/src/layouts/Layout.astro | 9 ++++++- apps/marketing/src/lib/site.ts | 6 +++++ apps/marketing/src/pages/download.astro | 22 +++++++++++++++- apps/marketing/src/pages/index.astro | 35 ++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 9c454c4b78bd..f5e34d0e0485 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,5 +1,10 @@ --- -import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; +import { + ANDROID_PLAY_STORE_URL, + GITHUB_REPOSITORY_URL, + IOS_APP_STORE_URL, + MARKETING_STATS, +} from "../lib/site"; interface Props { title?: string; @@ -69,6 +74,8 @@ const {
GitHub Discord Download + iOS app + Android app Terms Privacy Security diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 92e491a077a7..0bf89db8c0f2 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -1,5 +1,11 @@ export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; +export const IOS_APP_STORE_URL = + "https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824"; + +export const ANDROID_PLAY_STORE_URL = + "https://play.google.com/store/apps/details?id=com.t3tools.t3code"; + export const MARKETING_STATS = { githubStars: "14k+", users: "100,000", diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 76977ad4ce25..111482208cfa 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -1,6 +1,7 @@ --- import Layout from "../layouts/Layout.astro"; import { RELEASES_URL } from "../lib/releases"; +import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site"; --- @@ -57,6 +58,24 @@ import { RELEASES_URL } from "../lib/releases";
+ + +
+
+ +

Mobile

+
+ +

+ Also on your phone: + iOS + + Android +

@@ -578,6 +589,28 @@ const mobileEndorsementRows = [ opacity: 1; } + .hero-mobile-line { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--fg-dim); + font-size: 13px; + letter-spacing: -0.005em; + } + + .hero-mobile-line a { + color: var(--fg-muted); + text-decoration: underline; + text-decoration-color: rgba(161, 161, 170, 0.4); + text-underline-offset: 3px; + transition: color 0.18s ease, text-decoration-color 0.18s ease; + } + + .hero-mobile-line a:hover { + color: var(--fg); + text-decoration-color: var(--fg); + } + /* Download button icons (platform-aware) */ .dl-icon { display: none; From 1f3a50c4bd7b21f7fa9b88efb6b82094e329fba7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 06:10:47 -0700 Subject: [PATCH 11/15] docs: split user and maintainer docs, fix 100+ stale claims (#4807) Co-authored-by: Claude Fable 5 (cherry picked from commit 9dd425b2234c062b4767583e42d4b2c1aabab15d) --- docs/README.md | 60 ++- docs/architecture/connection-runtime.md | 140 ------ docs/architecture/overview.md | 141 ------- docs/architecture/providers.md | 30 -- docs/architecture/remote.md | 397 ------------------ docs/architecture/runtime-modes.md | 6 - docs/getting-started/codex-prerequisites.md | 5 - docs/getting-started/quick-start.md | 22 - docs/internals/ci.md | 24 ++ docs/internals/connection-runtime.md | 183 ++++++++ docs/{cloud => internals}/environment-auth.md | 46 +- .../encyclopedia.md => internals/glossary.md} | 81 ++-- docs/internals/overview.md | 152 +++++++ docs/internals/providers.md | 102 +++++ docs/internals/remote.md | 235 +++++++++++ .../resource-telemetry.md | 2 + docs/internals/scripts.md | 119 ++++++ .../server-updates.md | 19 +- .../t3-code-connect-auth-flow.html | 0 .../t3-connect.md} | 90 ++-- docs/internals/workspace-layout.md | 63 +++ docs/operations/ci.md | 6 - docs/operations/effect-fn-checklist.md | 194 --------- .../mobile-app-store-screenshots.md | 74 ++-- docs/operations/observability.md | 61 ++- docs/operations/relay-observability.md | 33 +- docs/operations/release.md | 59 ++- docs/project/todo.md | 13 - docs/reference/scripts.md | 56 --- docs/reference/workspace-layout.md | 7 - docs/user/install.md | 84 ++++ docs/user/keybindings.md | 121 ++---- docs/user/permission-modes.md | 48 +++ .../claude.md => user/providers-claude.md} | 74 ++-- .../codex.md => user/providers-codex.md} | 3 +- docs/user/remote-access.md | 37 +- .../source-control.md} | 40 +- docs/user/{server-updates.md => updating.md} | 12 +- infra/relay/README.md | 14 +- 39 files changed, 1473 insertions(+), 1380 deletions(-) delete mode 100644 docs/architecture/connection-runtime.md delete mode 100644 docs/architecture/overview.md delete mode 100644 docs/architecture/providers.md delete mode 100644 docs/architecture/remote.md delete mode 100644 docs/architecture/runtime-modes.md delete mode 100644 docs/getting-started/codex-prerequisites.md delete mode 100644 docs/getting-started/quick-start.md create mode 100644 docs/internals/ci.md create mode 100644 docs/internals/connection-runtime.md rename docs/{cloud => internals}/environment-auth.md (70%) rename docs/{reference/encyclopedia.md => internals/glossary.md} (60%) create mode 100644 docs/internals/overview.md create mode 100644 docs/internals/providers.md create mode 100644 docs/internals/remote.md rename docs/{architecture => internals}/resource-telemetry.md (99%) create mode 100644 docs/internals/scripts.md rename docs/{architecture => internals}/server-updates.md (89%) rename docs/{cloud => internals}/t3-code-connect-auth-flow.html (100%) rename docs/{cloud/t3-connect-clerk.md => internals/t3-connect.md} (75%) create mode 100644 docs/internals/workspace-layout.md delete mode 100644 docs/operations/ci.md delete mode 100644 docs/operations/effect-fn-checklist.md delete mode 100644 docs/project/todo.md delete mode 100644 docs/reference/scripts.md delete mode 100644 docs/reference/workspace-layout.md create mode 100644 docs/user/install.md create mode 100644 docs/user/permission-modes.md rename docs/{providers/claude.md => user/providers-claude.md} (72%) rename docs/{providers/codex.md => user/providers-codex.md} (96%) rename docs/{integrations/source-control-providers.md => user/source-control.md} (74%) rename docs/user/{server-updates.md => updating.md} (84%) diff --git a/docs/README.md b/docs/README.md index fe473094bbcf..bc359826a04a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,19 +1,41 @@ -# Documentation - -- [Getting started](./getting-started/quick-start.md) -- Architecture - - [Overview](./architecture/overview.md) - - [Connection runtime](./architecture/connection-runtime.md) - - [Remote environments](./architecture/remote.md) - - [Server updates](./architecture/server-updates.md) -- User guides - - [Background service](./user/background-service.md) - - [Remote access](./user/remote-access.md) - - [Keeping T3 Code in sync](./user/server-updates.md) - - [Keybindings](./user/keybindings.md) -- [T3 Connect](./cloud/t3-connect-clerk.md) -- [Integrations](./integrations/source-control-providers.md) -- [Mobile](./mobile/app.md) -- [Operations](./operations/ci.md) -- [Providers](./providers/codex.md) -- [Reference](./reference/encyclopedia.md) +# T3 Code docs + +## Using T3 Code + +- [Install and first run](./user/install.md) +- [Permission modes](./user/permission-modes.md) +- [Keyboard shortcuts](./user/keybindings.md) +- [Remote access](./user/remote-access.md) +- [Keeping app and server in sync](./user/updating.md) +- [Source control integrations](./user/source-control.md) +- [Background service (Linux)](./user/background-service.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) + +Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) + +--- + +## Working on T3 Code + +Everything below is for maintainers. Setup lives in the [root README](../README.md); +policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../AGENTS.md). + +- [Architecture overview](./internals/overview.md) +- [Workspace layout](./internals/workspace-layout.md) +- [Glossary](./internals/glossary.md) +- [Scripts](./internals/scripts.md) +- [Connection runtime](./internals/connection-runtime.md) +- [Providers](./internals/providers.md) +- [Remote environments](./internals/remote.md) +- [Server updates](./internals/server-updates.md) +- [Resource telemetry](./internals/resource-telemetry.md) +- [Environment auth](./internals/environment-auth.md) +- [T3 Connect](./internals/t3-connect.md) +- [CI gates](./internals/ci.md) + +### Runbooks + +- [Release](./operations/release.md) +- [Observability](./operations/observability.md) +- [Relay observability](./operations/relay-observability.md) +- [Mobile app store screenshots](./operations/mobile-app-store-screenshots.md) diff --git a/docs/architecture/connection-runtime.md b/docs/architecture/connection-runtime.md deleted file mode 100644 index acfd2ab5983a..000000000000 --- a/docs/architecture/connection-runtime.md +++ /dev/null @@ -1,140 +0,0 @@ -# Connection Runtime - -The connection runtime is shared by web and mobile. It owns connectivity, -authentication, retries, transport lifetime, cached environment data, and -environment-scoped operations. - -Web and mobile mount this runtime once at the application root. There is no -legacy connection owner or supported mixed mode. - -## Ownership - -Each registered environment has one scoped Effect `Context` containing focused -services: - -- `EnvironmentSupervisor` owns desired state, retry scheduling, and the active - session scope. -- `ConnectionBroker` prepares credentials and endpoints for primary, bearer, - relay, and SSH targets. -- `RpcSessionFactory` performs one transport attempt. It does not retry. -- `EnvironmentRpc` exposes the active session without leaking the transport. -- `EnvironmentProjectCommands` and `EnvironmentThreadCommands` construct - orchestration commands, IDs, and timestamps. -- `EnvironmentShell` and `EnvironmentThreads` own live subscriptions and cached - snapshots. - -`EnvironmentServicesFactory` assembles that context, and `EnvironmentRegistry` -owns its scope. There is no aggregate environment runtime facade. React -components do not create connections, transports, retry loops, or RPC clients. - -## Connection State - -The supervisor is the only retry owner. - -1. A persisted or platform registration marks an environment as desired. -2. If the device is offline, the supervisor releases the active session and - waits without consuming retry attempts. -3. When online, the supervisor asks the broker for one prepared connection and - asks the session factory for one RPC session. -4. Transient failures retry forever with exponential backoff capped at 16 - seconds. -5. Connectivity changes, application activation, credential changes, and - explicit user retry interrupt the current wait and trigger a fresh attempt. - Application activation also resets the backoff ladder. Mobile briefly probes - a session after short interruptions and replaces it immediately after a - meaningful background suspension. -6. Authentication or configuration failures remain blocked until an external - wakeup changes the relevant input. -7. An involuntary session close keeps the registration and cache, then retries. -8. Explicit removal closes the session and deletes the registration, - credentials, shell cache, and thread cache. - -The UI derives `available`, `offline`, `connecting`, `reconnecting`, -`connected`, and `error` from supervisor state plus explicit data-sync state. -It does not infer connection health from cached data or the existence of a -transport object. An environment becomes `connected` after the socket opens and -the initial config RPC succeeds, proving that the server is responsive. Shell -and thread synchronization are independent data states. A healthy RPC -transport with a failed shell subscription is shown as connected with a -synchronization error, not as a reconnect that is not actually scheduled. - -## Data Boundary - -Finite requests, durable subscriptions, and commands are separate APIs: - -- Query atoms revalidate when the RPC generation changes. -- Subscription atoms switch to replacement sessions. -- Expected subscription failures update domain sync state and wait for a - replacement session; they do not take down a healthy transport. -- Mutations resolve the current environment runtime at execution time. -- Shell and thread snapshots are available while offline. -- A connected transport may have `empty`, `cached`, `synchronizing`, `live`, or - failed shell and thread data independently. -- Cached shell and thread projections are never allowed to overwrite newer live - data during a fast reconnect. -- Domain atom factories route effects through the environment registry and - resolve the current scoped service at execution time. -- Web and mobile own their Atom runtimes, React hooks, and feature composition. - -The Promise bridge exists only at the React/Atom boundary. Runtime and business -logic remain Effect-native. - -## Platform Layers - -Web and mobile provide: - -- network status and network-change streams; -- application lifecycle wakeups; -- cloud session credentials; -- device identity; -- platform registrations; -- persistent catalog, credential, shell, and thread stores; -- HTTP, crypto, and telemetry layers. - -Platform layers adapt operating-system capabilities. They do not implement -connection policy. - -## Source Boundaries - -The public package subpaths mirror the runtime layers: - -- `connection/core` contains state, catalog, retry policy, and connectivity. -- `connection/transport` contains brokerage, authorization, attempts, and RPC - sessions. -- `connection/platform` declares capabilities and persistence contracts. -- `connection/services` contains environment-scoped data services. -- `connection/application` assembles registries, discovery, and startup. -- `connection/atoms` adapts shared services to application-owned Atom runtimes. -- `connection/presentation` contains pure UI projections. - -Other reusable state lives in domain subpaths such as `shell`, `threads`, -`terminal`, and `vcs`. Applications must import explicit package subpaths; the -package intentionally has no root export. - -## Application Boundary - -The application root mounts the shared connection application layer, creates -its own Atom runtime, and selects the domain atom factories required by that -platform. Web and mobile may expose different hooks and features without -changing connection ownership. - -Application code must not construct `WsTransport`, RPC clients, retry loops, or -raw orchestration commands. Persistence paths belong to the platform -registration and cache stores, with explicit migration or invalidation policy. - -## Verification - -Core state-machine tests use `@effect/vitest` and deterministic service layers. -Required coverage includes: - -- offline startup and online wakeup; -- forever retry with the 16-second cap; -- explicit retry interrupting backoff; -- authentication wakeups; -- involuntary close and reconnect; -- explicit removal clearing all owned state; -- relay token reuse and refresh; -- progressive relay discovery; -- shell and thread cache hydration; -- durable subscriptions switching sessions; -- command metadata and idempotent queued-command metadata. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md deleted file mode 100644 index fa8833d9db2b..000000000000 --- a/docs/architecture/overview.md +++ /dev/null @@ -1,141 +0,0 @@ -# Architecture - -T3 Code runs as a **Node.js WebSocket server** that serves a React web app and coordinates one of several provider runtimes/adapters (e.g. Codex, OpenCode, Copilot, Amp, GeminiCli, Claude) over provider-specific transport boundaries. - -``` -┌─────────────────────────────────┐ -│ Browser (React + Vite) │ -│ wsTransport (state machine) │ -│ Typed push decode at boundary │ -└──────────┬──────────────────────┘ - │ ws://localhost:3773 -┌──────────▼──────────────────────┐ -│ apps/server (Node.js) │ -│ WebSocket + HTTP static server │ -│ ServerPushBus (ordered pushes) │ -│ ServerReadiness (startup gate) │ -│ OrchestrationEngine │ -│ ProviderService │ -│ CheckpointReactor │ -│ RuntimeReceiptBus │ -└──────────┬──────────────────────┘ - │ Provider-specific transport -┌──────────▼──────────────────────┐ -│ Provider runtime / adapter │ -│ (e.g. Codex via JSON-RPC) │ -└─────────────────────────────────┘ -``` - -## Components - -- **Browser app**: The React app renders session state, owns the client-side WebSocket transport, and treats typed push events as the boundary between server runtime details and UI state. - -- **Server**: `apps/server` is the main coordinator. It serves the web app, accepts WebSocket requests, waits for startup readiness before welcoming clients, and sends all outbound pushes through a single ordered push path. - -- **Provider runtime**: A provider-specific runtime/adapter does the actual provider/session work. The server talks to it through the appropriate transport boundary (e.g. JSON-RPC over stdio for Codex) and translates those runtime events into the app's orchestration model. - -- **Background workers**: Long-running async flows such as runtime ingestion, command reaction, and checkpoint processing run as queue-backed workers. This keeps work ordered, reduces timing races, and gives tests a deterministic way to wait for the system to go idle. - -- **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state. - -- **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). - -Related design: - -- [Resource telemetry architecture](./resource-telemetry.md) - -## Event Lifecycle - -### Startup and client connect - -```mermaid -sequenceDiagram - participant Browser - participant Transport as WsTransport - participant Server as wsServer - participant Layers as serverLayers - participant Ready as ServerReadiness - participant Push as ServerPushBus - - Browser->>Transport: Load app and open WebSocket - Transport->>Server: Connect - Server->>Layers: Start runtime services - Server->>Ready: Wait for startup barriers - Ready-->>Server: Ready - Server->>Push: Publish server.welcome - Push-->>Transport: Ordered welcome push - Transport-->>Browser: Hydrate initial state -``` - -1. The browser boots `WsTransport` and registers typed listeners in `wsNativeApi`. -2. The server accepts the connection in `wsServer` and brings up the runtime graph defined in `serverLayers`. -3. `ServerReadiness` waits until the key startup barriers are complete. -4. Once the server is ready, `wsServer` sends `server.welcome` from the contracts in `ws.ts` through `ServerPushBus`. -5. The browser receives that ordered push through `WsTransport`, and `wsNativeApi` uses it to seed local client state. - -### User turn flow - -```mermaid -sequenceDiagram - participant Browser - participant Transport as WsTransport - participant Server as wsServer - participant Provider as ProviderService - participant Codex as codex app-server - participant Ingest as ProviderRuntimeIngestion - participant Engine as OrchestrationEngine - participant Push as ServerPushBus - - Browser->>Transport: Send user action - Transport->>Server: Typed WebSocket request - Server->>Provider: Route request - Provider->>Codex: JSON-RPC over stdio - Codex-->>Ingest: Provider runtime events - Ingest->>Engine: Normalize into orchestration events - Engine-->>Server: Domain events - Server->>Push: Publish on terminal.event / server.configUpdated - Push-->>Browser: Typed push -``` - -1. A user action in the browser becomes a typed request through `WsTransport` and the browser API layer in `wsNativeApi`. -2. `wsServer` decodes that request using the shared WebSocket contracts in `ws.ts` and routes it to the right service. -3. [`ProviderService`][8] starts or resumes a session and talks to the selected provider runtime/adapter using its transport boundary (e.g. JSON-RPC over stdio for Codex). -4. Provider-native events are pulled back into the server by [`ProviderRuntimeIngestion`][9], which converts them into orchestration events. -5. [`OrchestrationEngine`][10] persists those events, updates the read model, and exposes them as domain events. -6. `wsServer` pushes those updates to the browser through `ServerPushBus` on channels defined in `ws.ts` (`terminal.event`, `server.welcome`, `server.configUpdated`). - -### Async completion flow - -```mermaid -sequenceDiagram - participant Server as wsServer - participant Worker as Queue-backed workers - participant Cmd as ProviderCommandReactor - participant Checkpoint as CheckpointReactor - participant Receipt as RuntimeReceiptBus - participant Push as ServerPushBus - participant Browser - - Server->>Worker: Enqueue follow-up work - Worker->>Cmd: Process provider commands - Worker->>Checkpoint: Process checkpoint tasks - Checkpoint->>Receipt: Publish completion receipt - Cmd-->>Server: Produce orchestration changes - Checkpoint-->>Server: Produce orchestration changes - Server->>Push: Publish resulting state updates - Push-->>Browser: User-visible push -``` - -1. Some work continues after the initial request returns, especially in [`ProviderRuntimeIngestion`][9], [`ProviderCommandReactor`][13], and [`CheckpointReactor`][14]. -2. These flows run as queue-backed workers using [`DrainableWorker`][16], which helps keep side effects ordered and test synchronization deterministic. -3. When a milestone completes, the server emits a typed receipt on [`RuntimeReceiptBus`][15], such as checkpoint completion or turn quiescence. -4. Tests and orchestration code wait on those receipts instead of polling git state, projections, or timers. -5. Any user-visible state changes produced by that async work still go back through `wsServer` and `ServerPushBus`. - -[8]: ../../apps/server/src/provider/Layers/ProviderService.ts -[9]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts -[10]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts -[13]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts -[14]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts -[15]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts -[16]: ../../packages/shared/src/DrainableWorker.ts diff --git a/docs/architecture/providers.md b/docs/architecture/providers.md deleted file mode 100644 index 9c140b2f4d02..000000000000 --- a/docs/architecture/providers.md +++ /dev/null @@ -1,30 +0,0 @@ -# Provider architecture - -The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: - -- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` -- **Push events**: typed envelopes with `channel`, `sequence` (monotonic per connection), and channel-specific `data` - -Push channels: `server.welcome`, `server.configUpdated`, `terminal.event`, `orchestration.domainEvent`. Payloads are schema-validated at the transport boundary (`wsTransport.ts`). Decode failures produce structured `WsDecodeDiagnostic` with `code`, `reason`, and path info. - -Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: - -- `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` -- `providers.respondToRequest`, `providers.stopSession` -- `shell.openInEditor`, `server.getConfig` - -Codex is the only implemented provider. `claudeAgent` is reserved in contracts/UI. - -## Client transport - -`wsTransport.ts` manages connection state: `connecting` → `open` → `reconnecting` → `closed` → `disposed`. Outbound requests are queued while disconnected and flushed on reconnect. Inbound pushes are decoded and validated at the boundary, then cached per channel. Subscribers can opt into `replayLatest` to receive the last push on subscribe. - -## Server-side orchestration layers - -Provider runtime events flow through queue-based workers: - -1. **ProviderRuntimeIngestion** — consumes provider runtime streams, emits orchestration commands -2. **ProviderCommandReactor** — reacts to orchestration intent events, dispatches provider calls -3. **CheckpointReactor** — captures git checkpoints on turn start/complete, publishes runtime receipts - -All three use `DrainableWorker` internally and expose `drain()` for deterministic test synchronization. diff --git a/docs/architecture/remote.md b/docs/architecture/remote.md deleted file mode 100644 index fc4fc0b4065b..000000000000 --- a/docs/architecture/remote.md +++ /dev/null @@ -1,397 +0,0 @@ -# Remote Architecture - -This document describes the target architecture for first-class remote environments in T3 Code. - -It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. - -## Goals - -- Treat remote environments as first-class product primitives, not special cases. -- Support multiple ways to reach the same environment. -- Keep the T3 server as the execution boundary. -- Let desktop, mobile, and web all share the same conceptual model. -- Avoid introducing a local control plane unless product pressure proves it is necessary. - -## Non-goals - -- Replacing the existing WebSocket server boundary with a custom transport protocol. -- Making SSH the only remote story. -- Syncing provider auth across machines. -- Shipping every access method in the first iteration. - -## High-level architecture - -T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. - -Remote support should preserve that boundary. - -```text -┌──────────────────────────────────────────────┐ -│ Client (desktop / mobile / web) │ -│ │ -│ - known environments │ -│ - connection manager │ -│ - environment-aware routing │ -└───────────────┬──────────────────────────────┘ - │ - │ resolves one access endpoint - │ -┌───────────────▼──────────────────────────────┐ -│ Access method │ -│ │ -│ - direct ws / wss │ -│ - tunneled ws / wss │ -│ - desktop-managed ssh bootstrap + forward │ -└───────────────┬──────────────────────────────┘ - │ - │ connects to one T3 server - │ -┌───────────────▼──────────────────────────────┐ -│ Execution environment = one T3 server │ -│ │ -│ - environment identity │ -│ - provider state │ -│ - projects / threads / terminals │ -│ - git / filesystem / process runtime │ -└──────────────────────────────────────────────┘ -``` - -The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself. - -## Domain model - -### ExecutionEnvironment - -An `ExecutionEnvironment` is one running T3 server instance. - -It is the unit that owns: - -- provider availability and auth state -- model availability -- projects and threads -- terminal processes -- filesystem access -- git operations -- server settings - -It is identified by a stable `environmentId`. - -This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here. - -### KnownEnvironment - -A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. - -It is not server-authored. It is local to a device or client profile. - -Examples: - -- a saved LAN URL -- a saved public `wss://` endpoint -- a desktop-managed SSH host entry -- a saved tunneled environment - -A known environment may or may not know the target `environmentId` before first successful connect. - -In the hosted web app, known environments are browser-local. A hosted pairing URL can create the saved entry, but it does not give the hosted app a server-side control plane or a copy of the session state. - -### AccessEndpoint - -An `AccessEndpoint` is one concrete way to reach a known environment. - -This is the key abstraction that keeps SSH from taking over the model. - -A single environment may have many endpoints: - -- `wss://t3.example.com` -- `ws://10.0.0.25:3773` -- a tunneled relay URL -- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL - -The environment stays the same. Only the access path changes. - -### AdvertisedEndpoint - -An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for an environment. It is how the backend tells the client which URLs may be useful for pairing and reconnecting. - -`AdvertisedEndpoint` is deliberately narrower than the full access model: - -- it describes a concrete HTTP and WebSocket base URL pair -- it can mark the endpoint as default, available, or unavailable -- it includes reachability hints such as loopback, LAN, private, public, or tunnel -- it includes compatibility hints such as whether the endpoint can be used from the hosted HTTPS app - -Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable. - -The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration. - -Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity. - -When no user default is saved, endpoint selection should prefer: - -1. endpoints compatible with the hosted HTTPS app -2. explicitly default endpoints -3. non-loopback endpoints -4. loopback endpoints only for same-machine clients - -This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model. - -### Endpoint providers - -Endpoint providers are add-ons that contribute advertised endpoints for the current environment. - -The provider boundary is intentionally outside the core environment model: - -- core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle -- providers discover or synthesize endpoints -- providers return normalized `AdvertisedEndpoint` records -- the UI and pairing logic select from those records without knowing provider-specific commands - -The first provider is Tailscale. It can discover Tailnet IP and MagicDNS addresses from the local machine and publish them as additional endpoint candidates. Future providers, such as a hosted tunnel service, should plug into the same shape rather than adding a separate remote environment path. - -Provider-specific confidence should remain a hint. A Tailscale endpoint still needs a successful browser or desktop connection before the client treats it as connected. - -### Hosted pairing request - -A hosted pairing request is a bootstrap URL for the static web app, not a transport. - -Example: - -```text -https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE -``` - -The hosted app reads the `host` parameter and pairing token, exchanges the token directly with that backend, then saves the resulting environment record in browser local storage. - -Important constraints: - -- the hosted app does not proxy HTTP or WebSocket traffic -- the backend must still be reachable directly from the browser -- HTTPS pages can only connect to HTTPS/WSS backends -- HTTP LAN endpoints should keep using direct desktop or CLI pairing URLs -- the token belongs in the URL hash so it is not sent to the hosted app origin - -### RepositoryIdentity - -`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. - -It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. - -### Workspace / Project - -The current `Project` model remains environment-local. - -That means: - -- a local clone and a remote clone are different projects -- they may share a `RepositoryIdentity` -- threads still bind to one project in one environment - -## Access methods - -Access methods answer one question: - -How does the client speak WebSocket to a T3 server? - -They do not answer: - -- how the server got started -- who manages the server process -- whether the environment is local or remote - -### 1. Direct WebSocket access - -Examples: - -- `ws://10.0.0.15:3773` -- `wss://t3.example.com` - -This is the base model and should be the first-class default. - -Benefits: - -- works for desktop, mobile, and web -- no client-specific process management required -- best fit for hosted or self-managed remote T3 deployments - -Browser security rules are part of this access method. A hosted HTTPS web client can connect to `wss://` backends, but it cannot connect to plain `ws://` or `http://` LAN backends because that would be mixed content. - -### 2. Tunneled WebSocket access - -Examples: - -- public relay URLs -- private network relay URLs -- local tunnel products such as pipenet - -This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. - -For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. - -This is especially useful when: - -- the host is behind NAT -- inbound ports are unavailable -- mobile must reach a desktop-hosted environment -- a machine should be reachable without exposing raw LAN or public ports - -Tailscale-backed access sits here architecturally even though the current implementation is endpoint discovery rather than a T3-managed tunnel. It contributes private-network endpoints and lets the existing HTTP/WebSocket client path do the actual connection. - -### 3. Desktop-managed SSH access - -SSH is an access and launch helper, not a separate environment type. - -The desktop main process can use SSH to: - -- reach a machine -- probe it -- launch or reuse a remote T3 server -- establish a local port forward - -After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. - -This keeps the renderer transport model consistent with every other access method. - -The desktop main process owns the SSH bridge because it can spawn local SSH processes, manage askpass prompts, write temporary launch scripts, and clean up forwards. The renderer receives a saved environment record and connects through the forwarded URL; it should not need SSH-specific RPC paths for normal environment traffic. - -## Launch methods - -Launch methods answer a different question: - -How does a T3 server come to exist on the target machine? - -Launch and access should stay separate in the design. - -### 1. Pre-existing server - -The simplest launch method is no launch at all. - -The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. - -This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. - -### 2. Desktop-managed remote launch over SSH - -This is the main place where Zed is a useful reference. - -Useful ideas to borrow from Zed: - -- remote probing -- platform detection -- session directories with pid/log metadata -- reconnect-friendly launcher behavior -- desktop-owned connection UX - -What should be different in T3: - -- no custom stdio/socket proxy protocol between renderer and remote runtime -- no attempt to make the remote runtime look like an editor transport -- keep the final client-to-server connection as WebSocket - -The recommended T3 flow is: - -1. Desktop connects over SSH. -2. Desktop probes the remote machine and verifies T3 availability. -3. Desktop launches or reuses a remote T3 server. -4. Desktop establishes local port forwarding. -5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. - -The saved environment should remember that it was created by desktop SSH launch only for reconnect and lifecycle UX. That metadata should not change the server protocol or the environment identity model. - -Failure handling should be explicit: - -- SSH authentication failure should surface before any environment is saved -- remote launch failure should include remote logs or the launcher command output when available -- forwarded-port failure should leave the saved environment disconnected rather than falling back to an unrelated endpoint -- reconnect should attempt to restore the SSH bridge before reconnecting the normal WebSocket client - -### 3. Client-managed local publish - -This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. - -This is useful for: - -- exposing a desktop-hosted environment to mobile -- temporary remote access without changing router or firewall settings - -This is still a launch concern, not a new environment kind. - -## Why access and launch must stay separate - -These concerns are easy to conflate, but separating them prevents architectural drift. - -Examples: - -- A manually hosted T3 server might be reached through direct `wss`. -- The same server might also be reachable through a tunnel. -- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. -- A local desktop server might be published through a tunnel for mobile. - -In all of those cases, the `ExecutionEnvironment` is the same kind of thing. - -Only the launch and access paths differ. - -## Version Coordination - -Remote environments may stay online while web, desktop, or mobile clients move to a newer release. -The environment descriptor therefore carries the running server version and may advertise a safe -replacement path. The web and desktop UI use that information to show the appropriate action -without making the connection transport responsible for process management. - -Published CLI servers on supported hosts can install and hand off to the client's exact version. A -desktop-managed backend instead points the user to the desktop app on that machine, while older or -unsupported servers fall back to a manual relaunch. The existing connection supervisor owns the -disconnect and reconnect just as it would for any other involuntary socket close. - -See [Server Update Architecture](./server-updates.md) for capability detection, installation safety, -and restart sequencing. - -## Security model - -Remote support must assume that some environments will be reachable over untrusted networks. - -That means: - -- remote-capable environments should require explicit authentication -- tunnel exposure should not rely on obscurity -- client-saved endpoints should carry enough auth metadata to reconnect safely - -T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. - -For publicly reachable environments, authenticated access should be treated as required. - -Hosted pairing should be treated as a client-side convenience only. The hosted app must not receive pairing tokens through query parameters, must not store pairing state server-side, and must not imply that an HTTP backend is safe or reachable from an HTTPS browser context. - -## Relationship to Zed - -Zed is a useful reference implementation for managed remote launch and reconnect behavior. - -The relevant lessons are: - -- remote bootstrap should be explicit -- reconnect should be first-class -- connection UX belongs in the client shell -- runtime ownership should stay clearly on the remote host - -The important mismatch is transport shape. - -Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. - -T3 should not copy that part. - -T3 already has the right runtime boundary: - -- one T3 server per environment -- ordinary HTTP/WebSocket between client and environment - -So T3 should borrow Zed's launch discipline, not its transport protocol. - -## Recommended rollout - -1. First-class known environments and access endpoints. -2. Direct `ws` / `wss` remote environments. -3. Authenticated tunnel-backed environments. -4. Desktop-managed SSH launch and forwarding. -5. Multi-environment UI improvements after the base runtime path is proven. - -This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. diff --git a/docs/architecture/runtime-modes.md b/docs/architecture/runtime-modes.md deleted file mode 100644 index 956b242e1c14..000000000000 --- a/docs/architecture/runtime-modes.md +++ /dev/null @@ -1,6 +0,0 @@ -# Runtime modes - -T3 Code has a global runtime mode switch in the chat toolbar: - -- **Full access** (default): starts sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. -- **Supervised**: starts sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. diff --git a/docs/getting-started/codex-prerequisites.md b/docs/getting-started/codex-prerequisites.md deleted file mode 100644 index 608374d5db95..000000000000 --- a/docs/getting-started/codex-prerequisites.md +++ /dev/null @@ -1,5 +0,0 @@ -# Codex prerequisites - -- Install Codex CLI so `codex` is on your PATH. -- Authenticate Codex before running T3 Code (for example via API key or ChatGPT auth supported by Codex). -- T3 Code starts the server via `codex app-server` per session. diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md deleted file mode 100644 index 2206d53ee580..000000000000 --- a/docs/getting-started/quick-start.md +++ /dev/null @@ -1,22 +0,0 @@ -# Quick start - -```bash -# Development (with hot reload) -bun run dev - -# Desktop development -bun run dev:desktop - -# Desktop development on an isolated port set -T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop - -# Production -bun run build -bun run start - -# Build a shareable macOS .dmg (arm64 by default) -bun run dist:desktop:dmg - -# Or from any project directory after publishing: -npx t3 -``` diff --git a/docs/internals/ci.md b/docs/internals/ci.md new file mode 100644 index 000000000000..e88b9a9e4b55 --- /dev/null +++ b/docs/internals/ci.md @@ -0,0 +1,24 @@ +# CI quality gates + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) runs four jobs on pull requests and +pushes to `main`: + +- **Check**: `vp check` (format and lint; this repo sets `typeCheck: false` in its lint options), + then `vpr typecheck` for the workspace type check. The same job + builds the desktop pipeline (`vp run build:desktop`) and verifies the preload bundle exists and + still exports its expected symbols. +- **Test**: `vp run test` across the workspace. +- **Mobile Native Static Analysis**: `vp run lint:mobile` on macOS, wrapping + `scripts/mobile-native-static-check.ts`. +- **Release Smoke**: exercises release-only workflow steps through `scripts/release-smoke.ts`, so + release breakage surfaces on PRs rather than at tag time. + +`.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) +desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. It auto-enables +signing only when platform credentials are present. macOS passkey builds additionally require +`APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. +Without the core signing credentials, it still releases unsigned artifacts. + +See [Release Checklist](../operations/release.md) for the full release/signing setup checklist. diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md new file mode 100644 index 000000000000..46fe0c82716a --- /dev/null +++ b/docs/internals/connection-runtime.md @@ -0,0 +1,183 @@ +# Connection Runtime + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +The connection runtime is shared by web and mobile. It owns connectivity, +authentication, retries, transport lifetime, cached environment data, and +environment-scoped operations. + +Web and mobile mount this runtime once at the application root and compose it +identically: `apps/web/src/connection/runtime.ts` and +`apps/mobile/src/connection/runtime.ts` differ only in the platform layer they +supply. There is no legacy connection owner or supported mixed mode. + +## Composition + +[`connection/layer.ts`][layer] assembles the runtime: + +- `ConnectionResolver` ([resolver.ts][resolver]) resolves a catalog entry into a + prepared, authenticated endpoint for primary, bearer, relay, or SSH targets. +- `ConnectionDriver` ([driver.ts][driver]) prepares through the resolver, opens + one RPC session, and reports `preparing`, `opening`, and `synchronizing`. +- `RpcSessionFactory` ([rpc/session.ts][session]) performs one transport + attempt. It does not retry. `RpcSession` is the interface it returns, + exposing `client`, `initialConfig`, `ready`, `probe`, and `closed`. +- `EnvironmentRegistry` ([registry.ts][registry]) owns the catalog and the + per-environment scopes. +- `ConnectionOnboarding` and `RelayEnvironmentDiscovery` sit alongside the + registry. Startup calls `EnvironmentRegistry.start` and streams platform + registrations into `reconcilePlatform`. + +The registry creates one environment-scoped supervisor per environment. +`acquireSupervisor` serializes access per environment, reuses an existing +supervisor when the catalog entry is unchanged, and closes and recreates the +scope when it changed. `createServiceScope` builds an `EnvironmentSupervisor` +bound to a closeable scope and connects it; `run` and `runStream` execute caller +effects with that supervisor provided. + +`EnvironmentSupervisor` owns desired state, retry scheduling, and the active +session scope. React components do not create connections, transports, retry +loops, or RPC clients. + +## Connection State + +The supervisor is the only retry owner. + +1. A persisted or platform registration marks an environment as desired. +2. If the device is offline, the supervisor releases the active session and + waits for a signal without consuming retry attempts or running a timer. +3. When online, it asks the driver for one prepared connection and one RPC + session. +4. Transient failures retry forever with exponential backoff capped at 16 + seconds (`RETRY_DELAYS_MS`). A connection stable for 30 seconds resets + accumulated backoff. +5. Authentication or configuration failures remain blocked until an external + wakeup changes the relevant input. +6. An involuntary session close keeps the registration and cache, then retries. +7. Explicit removal closes the session and deletes the registration, + credentials, shell cache, and thread cache. + +### Wakeups + +Wakeup handling differs by phase, in [supervisor.ts][supervisor]: + +- During establishment, `waitForEstablishmentInterrupt` consumes and **ignores** + plain application activation. Restarting an in-flight attempt because the app + came to the foreground would only delay it. The exception is + `application-active-reconnect`, which mobile emits after a meaningful + background suspension; it interrupts establishment and resets the retry + ladder, because the OS may have silently killed the socket underneath the + attempt. +- Credential changes interrupt establishment only for relay targets, where a new + credential changes what is being established. +- Explicit disconnect, explicit retry, and going offline interrupt establishment + in every case. +- While waiting out backoff, application activation resets the retry ladder so a + foregrounded app reconnects immediately instead of serving the remaining + delay. +- Once connected, `monitorConnectedLease` handles plain activation by probing + the existing session (`lease.session.probe`, with a shorter timeout for + mobile's `application-active-probe`) rather than reconnecting; a healthy + session survives foregrounding. `application-active-reconnect` skips the probe + and replaces the lease outright. + +The UI derives `available`, `offline`, `connecting`, `reconnecting`, +`connected`, and `error` from supervisor state plus explicit data-sync state. +It does not infer connection health from cached data or the existence of a +transport object. An environment becomes `connected` after the socket opens and +the initial config RPC succeeds, proving that the server is responsive. Shell +and thread synchronization are independent data states. A healthy RPC transport +with a failed shell subscription is shown as connected with a synchronization +error, not as a reconnect that is not actually scheduled. + +## Data Boundary + +Finite requests, durable subscriptions, and commands are separate APIs: + +- Query atoms revalidate when the RPC generation changes. +- Subscription atoms switch to replacement sessions. +- Subscription failure handling in [rpc/client.ts][client] distinguishes two + cases. A transport failure (`isTransportFailure`: every failure is an RPC + client error) ends the inner subscription without resubscribing, so the outer + stream waits for the supervisor to supply a replacement session. A handled + domain failure runs `onExpectedFailure` and, when + `retryExpectedFailureAfter` is set, sleeps and resubscribes on the **same** + session. A healthy transport is never torn down for a domain failure. +- Mutations resolve the current environment runtime at execution time. +- Shell and thread snapshots are available while offline. +- Sync status is explicit and independent per domain. Shell status is `empty`, + `cached`, `synchronizing`, or `live`, with a separate `error` field; there is + no `failed` status. Thread status adds `deleted`. +- Cached shell and thread projections are never allowed to overwrite newer live + data during a fast reconnect. +- Domain atom factories route effects through the environment registry and + resolve the current scoped service at execution time. Project and thread + commands are Atom factories under `src/state` + (`createProjectEnvironmentAtoms`, `createThreadEnvironmentAtoms`), as are the + shell and thread state factories (`createEnvironmentShellAtoms`, + `createEnvironmentThreadStateAtoms`). +- Web and mobile own their Atom runtimes, React hooks, and feature composition. + +The Promise bridge exists only at the React/Atom boundary. Runtime and business +logic remain Effect-native. + +## Platform Layers + +Web and mobile provide: + +- network status and network-change streams; +- application lifecycle wakeups; +- cloud session credentials; +- device identity; +- platform registrations; +- persistent catalog, credential, shell, and thread stores; +- HTTP, crypto, and telemetry layers. + +Platform layers adapt operating-system capabilities. They do not implement +connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on +removal the registry clears its cache and calls the platform implementation, so +web clears composer drafts and mobile clears drafts plus the thread outbox. + +## Source Boundaries + +Applications must import explicit package subpaths; the package intentionally +has no root export. The subpaths are documented in +[packages/client-runtime/README.md](../../packages/client-runtime/README.md), +with the `exports` map in that package's `package.json` as the authoritative +list. Files that are not exported are implementation details. + +## Application Boundary + +The application root mounts the shared connection layer, creates its own Atom +runtime, and selects the domain atom factories required by that platform. Web +and mobile may expose different hooks and features without changing connection +ownership. + +Application code must not construct RPC clients, retry loops, or raw +orchestration commands. Persistence paths belong to the platform registration +and cache stores, with explicit migration or invalidation policy. + +## Verification + +Core state-machine tests use `@effect/vitest` and deterministic service layers. +Required coverage includes: + +- offline startup and online wakeup; +- forever retry with the 16-second cap; +- explicit retry interrupting backoff; +- authentication wakeups; +- involuntary close and reconnect; +- explicit removal clearing all owned state; +- relay token reuse and refresh; +- progressive relay discovery; +- shell and thread cache hydration; +- durable subscriptions switching sessions; +- command metadata and idempotent queued-command metadata. + +[layer]: ../../packages/client-runtime/src/connection/layer.ts +[resolver]: ../../packages/client-runtime/src/connection/resolver.ts +[driver]: ../../packages/client-runtime/src/connection/driver.ts +[registry]: ../../packages/client-runtime/src/connection/registry.ts +[supervisor]: ../../packages/client-runtime/src/connection/supervisor.ts +[session]: ../../packages/client-runtime/src/rpc/session.ts +[client]: ../../packages/client-runtime/src/rpc/client.ts diff --git a/docs/cloud/environment-auth.md b/docs/internals/environment-auth.md similarity index 70% rename from docs/cloud/environment-auth.md rename to docs/internals/environment-auth.md index af92bcb474dc..5f4f5b6e9607 100644 --- a/docs/cloud/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -1,5 +1,7 @@ # Environment Authentication Profile +> For maintainers. Using T3 Code? See [docs/user](../user/). + The environment server and the relay use separate credentials, issuers, and trust boundaries. They intentionally use a similar OAuth-shaped model so that permission checks and token exchange behavior can be audited against established concepts. @@ -61,26 +63,52 @@ The response has the token-exchange shape: "access_token": "", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "token_type": "Bearer", - "expires_in": 3600, + "expires_in": 2592000, "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read" } ``` +Sessions issued from a plain bearer exchange use the store's +`DEFAULT_SESSION_TTL` of 30 days. The shorter one-hour `expires_in: 3600` applies +only to DPoP-bound exchanges, where the token is additionally constrained by a +proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. + Requested scopes must be a subset of the one-time bootstrap credential grant. An ordinary paired client therefore cannot exchange its grant for `access:read`, `access:write`, or `relay:write`. +### DPoP-Bound Access Token + +The same `/oauth/token` exchange supports proof-of-possession tokens. A client +that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`; +the resulting JWK thumbprint is stored on the session, which is then issued with +method `dpop-access-token` and a one-hour TTL instead of the bearer default. An +invalid proof gets a DPoP challenge header and a credential error rather than a +bearer token. + +`dpop-access-token` is advertised alongside `browser-session-cookie` and +`bearer-access-token` in the descriptor's `sessionMethods` +(`EnvironmentAuthPolicy.ts`), so clients can discover support rather than +assume it. Relay-brokered clients use this mode so that a leaked token cannot be +replayed without the corresponding key. + ### WebSocket Ticket `POST /api/auth/websocket-ticket` accepts any authenticated session and returns -a short-lived, single-purpose WebSocket ticket. This keeps bearer tokens and -browser cookies out of WebSocket URLs while allowing the socket handshake to -authenticate. The ticket carries its session's scopes; each RPC method then -enforces `orchestration:read`, `orchestration:operate`, `terminal:operate`, -`review:write`, or `access:read` as appropriate. Review feedback submission -currently dispatches an orchestration operation, so clients performing it also -need `orchestration:operate`. Creating a ticket is not -authorization to call every RPC method. +a short-lived, single-purpose WebSocket ticket, issued through +`EnvironmentAuth.issueWebSocketTicket` with a five-minute default TTL. The +client presents its bearer or DPoP credential in headers to get the ticket, then +appends only that ticket to the socket URL as `wsTicket`. This keeps long-lived +tokens and browser cookies out of WebSocket URLs while letting the handshake +authenticate. + +The ticket carries its session's scopes; each RPC method then enforces +`orchestration:read`, `orchestration:operate`, `terminal:operate`, +`review:write`, `relay:write`, or `access:read` as appropriate, through +`RPC_REQUIRED_SCOPES` in `apps/server/src/auth/RpcAuthorization.ts`. Review feedback submission currently dispatches +an orchestration operation, so clients performing it also need +`orchestration:operate`. Creating a ticket is not authorization to call every +RPC method. ## Standards Alignment diff --git a/docs/reference/encyclopedia.md b/docs/internals/glossary.md similarity index 60% rename from docs/reference/encyclopedia.md rename to docs/internals/glossary.md index 82a58fd959c9..da16f74d339f 100644 --- a/docs/reference/encyclopedia.md +++ b/docs/internals/glossary.md @@ -1,4 +1,6 @@ -# Encyclopedia +# Glossary + +> For maintainers. Using T3 Code? See [docs/user](../user/). This is a living glossary for T3 Code. It explains what common terms mean in this codebase. @@ -16,7 +18,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi #### Project -The top-level workspace record in the app. In [the orchestration contracts][1], a project has a `workspaceRoot`, a title, and one or more threads. See [workspace-layout.md][2]. +The top-level workspace record in the app. In [the orchestration contracts][1], a project has a `workspaceRoot` and a title. It does not contain threads: `OrchestrationProject` and `OrchestrationThread` are separate arrays on the read model, and a project can have zero threads. See [workspace-layout.md][2]. #### Workspace root @@ -24,7 +26,7 @@ The root filesystem path for a project. In [the orchestration model][1], it is t #### Worktree -A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live in [GitCore.ts][3]. +A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live behind the VCS driver contract in `apps/server/src/vcs/VcsDriver.ts`, implemented by [GitVcsDriverCore.ts][3]. ### Thread timeline @@ -34,7 +36,7 @@ The main durable unit of conversation and workspace history. In [the orchestrati #### Turn -A single user-to-assistant work cycle inside a thread. It starts with user input and ends when follow-up work like checkpointing settles. See [the contracts][1], [ProviderRuntimeIngestion.ts][5], and [CheckpointReactor.ts][6]. +A single user-to-assistant work cycle inside a thread. It starts with user input and ends when the session leaves `running` status, which [projector.ts][4] treats as the authoritative completion signal (`settledTurnStateForSessionStatus`). Checkpoint and diff work may settle afterward without changing when the turn ended. See [the contracts][1] and [ProviderRuntimeIngestion.ts][5]. #### Activity @@ -80,20 +82,19 @@ A side-effecting service that handles follow-up work after events or runtime sig #### Receipt -A lightweight typed runtime signal emitted when an async milestone completes. See [RuntimeReceiptBus.ts][13]. -Examples include `checkpoint.baseline.captured`, `checkpoint.diff.finalized`, and `turn.processing.quiesced`, which are emitted by flows such as [CheckpointReactor.ts][6]. +A typed signal emitted when an async milestone completes, such as `checkpoint.baseline.captured`, `checkpoint.diff.finalized`, or `turn.processing.quiesced`. Receipts are a test-only mechanism: the production `RuntimeReceiptBusLive` publish is a no-op and only the test layer is PubSub-backed. Do not build production behavior on them. See [RuntimeReceiptBus.ts][13] and [CheckpointReactor.ts][6]. #### Quiesced -"Quiesced" means a turn has gone quiet and stable. In [the receipt schema][13], it means the follow-up work has settled, including work in [CheckpointReactor.ts][6]. +"Quiesced" means a turn has gone quiet and stable: follow-up work such as [CheckpointReactor.ts][6] has settled. It appears in [the receipt schema][13], so in practice it is something tests wait on rather than a production signal. ### Provider runtime -The live backend agent implementation and its event stream. The main service is [ProviderService.ts][14], the adapter contract is [ProviderAdapter.ts][15], and the overview is in [provider-architecture.md][16]. +The live backend agent implementation and its event stream. The main service is [ProviderService.ts][14], the adapter contract is [ProviderAdapter.ts][15], and the overview is in [providers.md][16]. #### Provider -The backend agent runtime that actually performs work. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17]. +The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session @@ -101,15 +102,15 @@ The live provider-backed runtime attached to a thread. Session shape is in [the #### Runtime mode -The safety/access mode for a thread or session. In [the contracts][1], the main values are `approval-required` and `full-access`. See [runtime-modes.md][18]. +The safety/access mode for a thread or session. [The contracts][1] define four values: `approval-required`, `auto-accept-edits`, `auto`, and `full-access`. See [permission modes][18]. #### Interaction mode -The agent interaction style for a thread. In [the contracts][1], the main values are `default` and `plan`. See [runtime-modes.md][18]. +The agent interaction style for a thread. In [the contracts][1], the values are `default` and `plan`. #### Assistant delivery mode -Controls how assistant text reaches the thread timeline. In [the contracts][1], `streaming` updates incrementally and `buffered` delivers a completed result. See [ProviderService.ts][14]. +Controls how assistant text reaches the thread timeline. In [the contracts][1], `streaming` updates incrementally and `buffered` accumulates text. Buffered delivery is not held until the turn completes: it spills once accumulated text would exceed 24,000 characters, and flushes at approval and user-input boundaries. See [ProviderRuntimeIngestion.ts][5]. #### Snapshot @@ -143,38 +144,38 @@ The file patch and changed-file summary for one turn. It is usually computed in - If you see `requested`, think "intent recorded". - If you see `completed`, think "result applied". -- If you see `receipt`, think "async milestone signal". +- If you see `receipt`, think "async milestone signal, for tests". - If you see `checkpoint`, think "workspace snapshot for diff/restore". - If you see `quiesced`, think "all relevant follow-up work has gone idle". ## Related Docs -- [architecture.md][24] -- [provider-architecture.md][16] -- [runtime-modes.md][18] -- [workspace-layout.md][2] +- [Architecture overview][24] +- [Provider architecture][16] +- [Permission modes][18] +- [Workspace layout][2] -[1]: ../packages/contracts/src/orchestration.ts +[1]: ../../packages/contracts/src/orchestration.ts [2]: ./workspace-layout.md -[3]: ../apps/server/src/git/Layers/GitCore.ts -[4]: ../apps/server/src/orchestration/projector.ts -[5]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts -[6]: ../apps/server/src/orchestration/Layers/CheckpointReactor.ts -[7]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts -[8]: ../apps/server/src/orchestration/decider.ts -[9]: ../apps/server/src/orchestration/commandInvariants.ts -[10]: ../apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts -[11]: ../apps/server/src/orchestration/Layers/ProjectionPipeline.ts -[12]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts -[13]: ../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts -[14]: ../apps/server/src/provider/Layers/ProviderService.ts -[15]: ../apps/server/src/provider/Services/ProviderAdapter.ts -[16]: ./provider-architecture.md -[17]: ../apps/server/src/provider/Layers/CodexAdapter.ts -[18]: ./runtime-modes.md -[19]: ../apps/server/src/checkpointing/CheckpointStore.ts -[20]: ../apps/server/src/checkpointing/CheckpointDiffQuery.ts -[21]: ../apps/server/src/persistence/Services/ProjectionCheckpoints.ts -[22]: ../apps/server/src/checkpointing/Utils.ts -[23]: ../apps/server/src/checkpointing/Diffs.ts -[24]: ./architecture.md +[3]: ../../apps/server/src/vcs/GitVcsDriverCore.ts +[4]: ../../apps/server/src/orchestration/projector.ts +[5]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +[6]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts +[7]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts +[8]: ../../apps/server/src/orchestration/decider.ts +[9]: ../../apps/server/src/orchestration/commandInvariants.ts +[10]: ../../apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +[11]: ../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts +[12]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +[13]: ../../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts +[14]: ../../apps/server/src/provider/Layers/ProviderService.ts +[15]: ../../apps/server/src/provider/Services/ProviderAdapter.ts +[16]: ./providers.md +[17]: ../../apps/server/src/provider/Layers/CodexAdapter.ts +[18]: ../user/permission-modes.md +[19]: ../../apps/server/src/checkpointing/CheckpointStore.ts +[20]: ../../apps/server/src/checkpointing/CheckpointDiffQuery.ts +[21]: ../../apps/server/src/persistence/Services/ProjectionCheckpoints.ts +[22]: ../../apps/server/src/checkpointing/Utils.ts +[23]: ../../apps/server/src/checkpointing/Diffs.ts +[24]: ./overview.md diff --git a/docs/internals/overview.md b/docs/internals/overview.md new file mode 100644 index 000000000000..b9454f7b58d0 --- /dev/null +++ b/docs/internals/overview.md @@ -0,0 +1,152 @@ +# Architecture + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +T3 Code is a server runtime that owns agent sessions, workspaces, and version control, plus clients +(web, desktop, mobile) that talk to it over one authenticated Effect RPC WebSocket. The server is the +execution boundary: every provider process, terminal, git operation, and filesystem read happens +there, never in the client. + +``` +┌────────────────────────────────────────────────┐ +│ Clients: apps/web, apps/desktop, apps/mobile │ +│ shared runtime: packages/client-runtime │ +│ connection supervisor, RPC session, Atom state│ +└──────────────────┬─────────────────────────────┘ + │ Effect RPC over WebSocket (/ws) + │ contract: packages/contracts +┌──────────────────▼─────────────────────────────┐ +│ apps/server │ +│ orchestration engine (event-sourced) │ +│ provider driver registry (5 built-in drivers) │ +│ checkpointing, VCS, terminals, filesystem │ +└──────────────────┬─────────────────────────────┘ + │ per-driver transport +┌──────────────────▼─────────────────────────────┐ +│ Agent CLIs: Codex, Claude, Cursor, Grok, │ +│ OpenCode │ +└────────────────────────────────────────────────┘ +``` + +## The RPC boundary + +The client/server contract is an Effect RPC group, not a hand-rolled push protocol. [`rpc.ts`][rpc] +declares `WS_METHODS` and assembles `WsRpcGroup`; each member is either unary or a server stream +(`stream: true`). Streaming members such as `orchestration.subscribeShell`, +`orchestration.subscribeThread`, `subscribeServerConfig`, and `terminal.attach` replace what used to +be a broadcast push bus: a client subscribes to what it needs and the server pushes only on that +subscription. + +[`ws.ts`][ws] serves the group. `websocketRpcRouteLayer` mounts `GET /ws`, authenticates the upgrade +through `EnvironmentAuth.authenticateWebSocketUpgrade`, then hands the socket to +`RpcServer.toHttpEffectWebsocket`. Authorization is per method: `RPC_REQUIRED_SCOPE` maps each method +to a scope, and `authorizeEffect`/`authorizeStream` enforce it. Holding a valid socket is not +authorization to call everything on it. See [environment-auth.md](./environment-auth.md). + +On the client, [`session.ts`][session] opens the socket and builds the typed client. +`RpcSessionFactory` is the service; a session exposes `client`, `initialConfig`, `ready`, `probe`, +and `closed`. It performs one attempt and does not retry. Retry, backoff, and offline policy belong +to the connection supervisor. + +## Shared client runtime + +`packages/client-runtime` holds every non-visual client concern: connection lifecycle, +authentication, RPC, cached environment data, and domain state as Atom factories. Web and mobile +compose it the same way (`apps/web/src/connection/runtime.ts` and +`apps/mobile/src/connection/runtime.ts` mirror each other, differing only in platform-specific +background-activity layers) and differ beyond that only in the platform layer they supply and the +UI they build on top. React components never construct transports, retry loops, +or RPC clients. See [connection-runtime.md](./connection-runtime.md). + +## Orchestration is event-sourced + +The server does not mutate app state directly. Clients dispatch typed commands; the engine turns them +into persisted events; projections derive the read model. + +[`OrchestrationEngine.ts`][engine] serializes this. `dispatch` offers a `CommandEnvelope` onto +`commandQueue` and awaits its result; a single worker fiber takes envelopes one at a time, so command +processing is totally ordered. For each envelope `processEnvelope`: + +1. checks the durable command receipt, making retries idempotent; +2. runs `decideOrchestrationCommand` ([`decider.ts`][decider]) to produce events from command plus + current state, pure and side-effect free; +3. inside one SQL transaction, appends events to the event store, applies them to the in-memory read + model via [`projector.ts`][projector], projects them into persisted tables, and writes the + accepted receipt; +4. after commit, swaps in the new read model and publishes committed events to subscribers. + +Because persistence and projection share a transaction, the read model cannot durably disagree with +the event log. On dispatch failure the engine rereads persisted events past the starting sequence and +reconciles. + +Command and event names live in [`orchestration.ts`][contracts]. Some commands are client +dispatchable (`thread.create`, `thread.turn.start`, `thread.approval.respond`); others are internal +and produced only by server-side reactors (`thread.message.assistant.delta`, +`thread.turn.diff.complete`). + +A turn is complete when its session leaves `running` status, projected by +`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later +does not define turn end. + +## Drainable workers + +Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]: +[`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands, +[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, and +[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints. + +`DrainableWorker` pairs a transactional queue with a transactional count of outstanding items. +`enqueue` atomically offers and increments; processing always decrements. `drain` retries until the +count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. +Each of the three services exposes `drain` for exactly this. + +Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in +[`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not +build production behavior on receipts. + +## Provider drivers + +Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves +an instance to its adapter, so `ProviderService` routes session and turn operations without knowing +which agent is behind them. See [providers.md](./providers.md). + +## Checkpointing + +Each turn is bracketed by workspace checkpoints so diffs and reverts are exact. `CheckpointStore` +captures state as hidden Git refs through the VCS driver's checkpoint operations; +`CheckpointDiffQuery` answers turn and full-thread diff requests; `CheckpointReactor` coordinates +baseline capture, completed-turn capture, diff projection, and reverting both the workspace and the +provider conversation. The storage contract is `VcsCheckpointOps` in +[`VcsDriver.ts`](../../apps/server/src/vcs/VcsDriver.ts), implemented for Git in the same directory. + +## Startup + +[`serverRuntimeStartup.ts`][startup] runs a fixed lifecycle: start keybindings, settings, and +reactors; publish welcome; signal command readiness (logged as `Accepting commands`); wait for the +HTTP listener via `markHttpListening`; publish ready; fork the heartbeat; then either print headless +output or open the browser. Command readiness precedes the listener, so a socket that opens can +already dispatch. + +## Related + +- [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md) +- [Remote environments](./remote.md), [Server updates](./server-updates.md) +- [Resource telemetry](./resource-telemetry.md) +- [Scripts](./scripts.md), [CI gates](./ci.md) + +[rpc]: ../../packages/contracts/src/rpc.ts +[contracts]: ../../packages/contracts/src/orchestration.ts +[ws]: ../../apps/server/src/ws.ts +[session]: ../../packages/client-runtime/src/rpc/session.ts +[startup]: ../../apps/server/src/serverRuntimeStartup.ts +[engine]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts +[decider]: ../../apps/server/src/orchestration/decider.ts +[projector]: ../../apps/server/src/orchestration/projector.ts +[worker]: ../../packages/shared/src/DrainableWorker.ts +[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts +[receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts +[drivers]: ../../apps/server/src/provider/builtInDrivers.ts diff --git a/docs/internals/providers.md b/docs/internals/providers.md new file mode 100644 index 000000000000..3b7a6fbd9597 --- /dev/null +++ b/docs/internals/providers.md @@ -0,0 +1,102 @@ +# Provider architecture + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +A provider is the agent runtime that does the actual work. T3 Code supports several, and the +orchestration layer does not know which one is behind a thread. + +## Built-in drivers + +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with ten entries, in registration order: + +| Driver kind | Driver source | +| ------------- | ----------------------------------------- | +| `codex` | [`Drivers/CodexDriver.ts`][codex] | +| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | +| `droid` | [`Drivers/DroidDriver.ts`][droid] | +| `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `amp` | [`Drivers/AmpDriver.ts`][amp] | +| `copilot` | [`Drivers/CopilotDriver.ts`][copilot] | +| `geminiCli` | [`Drivers/GeminiCliDriver.ts`][geminicli] | +| `kilo` | [`Drivers/KiloDriver.ts`][kilo] | + +Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an +adapter in a child scope. Adapter implementations live beside them in +`apps/server/src/provider/Layers/` (`CodexAdapter.ts`, `ClaudeAdapter.ts`, and so on) and conform to +[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's +transport, config, and event shapes are mapped. + +## Registry and routing + +Two registries separate configuration from live processes: + +- [`ProviderInstanceRegistry`][instances] keys configured instances by `ProviderInstanceId`. Creating + one looks up the driver by `driverKind`, decodes `entry.config` with that driver's schema, opens a + child scope, and calls `driver.create`. +- [`ProviderAdapterRegistry`][registry] resolves an instance ID to its live adapter via + `getByInstance`. + +[`ProviderService`][service] sits on top. It combines the adapter registry with the provider session +directory to route session and turn operations for a thread, so callers name a thread, not an agent. + +Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No +orchestration, contract, or client change is required for the common case. + +## How provider work is requested + +Clients never call a provider directly. They dispatch orchestration commands over the RPC method +`orchestration.dispatchCommand`, defined with the rest of the orchestration surface in +[`orchestration.ts`][contracts]. The client-dispatchable provider-facing commands are +`thread.turn.start`, `thread.turn.interrupt`, `thread.approval.respond`, +`thread.user-input.respond`, `thread.checkpoint.revert`, and `thread.session.stop`, plus the mode +setters `thread.runtime-mode.set` and `thread.interaction-mode.set`. + +The engine persists an event for the command, and a server-side reactor performs the provider call. +Provider output comes back as internal commands such as `thread.message.assistant.delta` and +`thread.session.set`, which clients observe through `orchestration.subscribeThread`. See +[overview.md](./overview.md) for the command/event loop. + +## Server-side workers + +Provider work flows through three queue-backed workers. All three are built with +`makeDrainableWorker` from [`DrainableWorker.ts`][worker] and expose `drain` for deterministic test +synchronization. + +1. [`ProviderRuntimeIngestion`][ingest] consumes provider runtime streams and emits orchestration + commands. +2. [`ProviderCommandReactor`][cmd] reacts to orchestration intent events and dispatches provider + calls. +3. [`CheckpointReactor`][checkpoint] captures workspace checkpoints on turn start and completion, and + performs reverts. + +### Buffered assistant delivery + +A thread in `buffered` assistant delivery mode accumulates assistant text instead of streaming each +delta. The buffer is not held until turn completion. In [`ProviderRuntimeIngestion`][ingest], +`MAX_BUFFERED_ASSISTANT_CHARS` is 24,000: the append that would exceed it invalidates the buffer and +spills the whole accumulated text as one delta. The buffer also flushes at interaction boundaries, +when a request opens (approval) or user input is requested, via +`flushBufferedAssistantMessagesForTurn`. + +[drivers]: ../../apps/server/src/provider/builtInDrivers.ts +[codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts +[claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts +[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts +[droid]: ../../apps/server/src/provider/Drivers/DroidDriver.ts +[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts +[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[amp]: ../../apps/server/src/provider/Drivers/AmpDriver.ts +[copilot]: ../../apps/server/src/provider/Drivers/CopilotDriver.ts +[geminicli]: ../../apps/server/src/provider/Drivers/GeminiCliDriver.ts +[kilo]: ../../apps/server/src/provider/Drivers/KiloDriver.ts +[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts +[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts +[registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts +[service]: ../../apps/server/src/provider/Layers/ProviderService.ts +[contracts]: ../../packages/contracts/src/orchestration.ts +[worker]: ../../packages/shared/src/DrainableWorker.ts +[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts diff --git a/docs/internals/remote.md b/docs/internals/remote.md new file mode 100644 index 000000000000..afce95f725bc --- /dev/null +++ b/docs/internals/remote.md @@ -0,0 +1,235 @@ +# Remote Architecture + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +Remote environments are shipped, not planned. Direct, bearer-paired, relay-tunneled, Tailscale, and +desktop-managed SSH access all exist today. This document describes the model they share and where +each piece lives. For the user-facing setup guide see +[remote access](../user/remote-access.md). + +## The model + +T3 has one runtime boundary: a client talks to a T3 server over HTTP and WebSocket, and the server +owns orchestration, providers, terminals, git, and filesystem operations. Remoteness is expressed at +the connection layer, never by splitting the runtime. + +```text +┌──────────────────────────────────────────────┐ +│ Client (desktop / mobile / web) │ +│ known environments, connection supervisor │ +└───────────────┬──────────────────────────────┘ + │ resolves one access endpoint +┌───────────────▼──────────────────────────────┐ +│ Access method │ +│ direct ws/wss, relay tunnel, │ +│ Tailscale serve, desktop-managed ssh │ +└───────────────┬──────────────────────────────┘ + │ connects to one T3 server +┌───────────────▼──────────────────────────────┐ +│ Execution environment = one T3 server │ +│ identity, providers, projects/threads, │ +│ terminals, git, filesystem │ +└──────────────────────────────────────────────┘ +``` + +### ExecutionEnvironment + +One running T3 server instance. It owns provider availability and auth, model availability, projects +and threads, terminal processes, filesystem access, git operations, and server settings. + +It is identified by a stable `environmentId`, persisted by the server at `/environment-id` +and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile, +and web all reason about the same concept. + +### Known environments and connection targets + +A saved client-side entry for an environment the client knows how to reach. It is not +server-authored; it is local to a device or client profile. In the hosted web app these entries are +browser-local. A hosted pairing URL can create one, but it does not give the hosted app a server-side +control plane or a copy of session state. + +[`connection/model.ts`][model] defines four target tags, which are the real access taxonomy: + +| Target | Used for | +| ------------------------- | ------------------------------------------------------------------------ | +| `PrimaryConnectionTarget` | The platform-managed local server (desktop backend, CLI-served web app). | +| `BearerConnectionTarget` | Any manually paired endpoint reached over direct HTTP/WebSocket. | +| `RelayConnectionTarget` | Managed T3 Connect relay tunnels. | +| `SshConnectionTarget` | Desktop-managed SSH environments. | + +Bearer, relay, and SSH are persisted; primary is platform-managed. Note that Tailscale is not a +separate target kind. A Tailscale URL is paired through the ordinary bearer path in +[`onboarding.ts`][onboarding] (`preparePairingRegistration`), which accepts either a pairing URL or a +host plus pairing code. Tailscale is an endpoint provider and transport, not a distinct runtime +concept. + +### AdvertisedEndpoint + +A server- or desktop-authored candidate endpoint for an environment: a concrete HTTP and WebSocket +base URL pair, a default/available/unavailable marker, reachability hints (loopback, LAN, private, +public, tunnel), and compatibility hints such as whether the hosted HTTPS app can use it. + +Clients treat advertised endpoints as hints, not proof that a route works from the current device. +The connection attempt decides. + +The UI shows one default endpoint in the network-access summary and keeps the rest behind an advanced +list. `selectPairingEndpoint` in +[`ConnectionsSettings.tsx`](../../apps/web/src/components/settings/ConnectionsSettings.tsx) excludes +unavailable endpoints and then picks, in order: + +1. the saved `defaultEndpointKey` override; +2. the first endpoint marked `isDefault`; +3. the first endpoint whose reachability is not `loopback`; +4. the first endpoint compatible with the hosted HTTPS app; +5. otherwise nothing. + +There is no unconditional loopback fallback. A loopback endpoint only wins through an explicit saved +override or `isDefault`. Persist the override by stable endpoint kind rather than raw URL where +possible, since LAN addresses change with networks; Tailscale endpoints use provider-specific stable +keys (`tailscale-ip:`, `tailscale-magicdns:`). + +### Endpoint providers + +Endpoint providers contribute advertised endpoints without becoming part of the core environment +model: core owns environments, pairing, and connection lifecycle, and providers return normalized +`AdvertisedEndpoint` records. + +Tailscale is the first provider, and T3 manages more than discovery. When `tailscaleServeEnabled` is +set, the server acquires a Tailscale serve mapping for its actual listening port at startup with +`ensureTailscaleServe` and releases it with `disableTailscaleServe` on scope close +(`apps/server/src/server.ts`, using [`@t3tools/tailscale`](../../packages/tailscale/src/tailscale.ts)). +Endpoint identifiers are synthesized in `apps/desktop/src/backend/tailscaleEndpointProvider.ts` with +`private-network` reachability. + +### Hosted pairing request + +A hosted pairing request is a bootstrap URL for the static web app, not a transport: + +```text +https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE +``` + +The hosted app reads `host`, takes the token from the URL hash, exchanges it directly with that +backend, strips the token from browser history, and saves the environment record locally. Helpers +live in [`shared/remote.ts`](../../packages/shared/src/remote.ts) (`setPairingTokenOnUrl`, +`getPairingTokenFromUrl`, `stripPairingTokenFromUrl`) and `apps/web/src/hostedPairing.ts`. + +Constraints: + +- the hosted app does not proxy HTTP or WebSocket traffic; +- the backend must be directly reachable from the browser; +- HTTPS pages can only reach HTTPS/WSS backends; +- HTTP LAN endpoints keep using direct desktop or CLI pairing URLs; +- the token belongs in the hash so it is never sent to the hosted app origin. + +### RepositoryIdentity and Project + +`RepositoryIdentity` is a best-effort logical repo grouping across environments, used for UI grouping +and correlation only, never for routing. `Project` remains environment-local: a local clone and a +remote clone are different projects that may share a `RepositoryIdentity`, and threads bind to one +project in one environment. + +## Access methods + +Access answers one question: how does the client speak WebSocket to a T3 server? It does not answer +how the server got started or who manages the process. + +### Direct WebSocket access + +`wss://t3.example.com` or `ws://10.0.0.15:3773`, paired as a bearer target. This is the base model. +It works for desktop, mobile, and web with no client-side process management. Browser security rules +are part of it: a hosted HTTPS client cannot connect to plain `ws://` or `http://` LAN backends. + +### Relay-tunneled access + +Managed T3 Connect relay tunnels use `RelayConnectionTarget` and are the answer when the host is +behind NAT, inbound ports are unavailable, or mobile must reach a desktop-hosted environment. From +the client's perspective this is still an ordinary WebSocket connection; the route is mediated. The +relay Worker only brokers credentials and a managed endpoint; application traffic then flows over +the provisioned Cloudflare tunnel hostname for the life of the connection, not through the relay +Worker itself. See [t3-connect.md](./t3-connect.md). + +### Tailscale access + +A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HTTPS, and the +resulting private-network endpoints are advertised for pairing. Connection then follows the ordinary +bearer path. + +### Desktop-managed SSH access + +SSH is an access and launch helper, not a separate environment type. `DesktopSshEnvironment` +([apps/desktop/src/ssh/DesktopSshEnvironment.ts][sshenv]) exposes `discoverHosts`, +`ensureEnvironment`, and `disconnectEnvironment`. It discovers targets from SSH config and known +hosts, owns password/askpass prompts, and delegates lifecycle to `SshEnvironmentManager` in +[packages/ssh/src/tunnel.ts][sshtunnel], which resolves the target, launches or reuses the remote T3 +server, opens a local tunnel, checks HTTP readiness, optionally issues a remote pairing token, and +returns local HTTP/WS endpoints. Disconnect closes the tunnel and stops the remote server if the +launcher started it; a server that was already running (marked `external`) is left running. + +The desktop main process owns this because it can spawn SSH, manage prompts, write launch scripts, +and clean up forwards. The renderer connects through the forwarded URL like any other environment and +needs no SSH-specific RPC path. + +Failure handling is explicit: SSH auth failure surfaces before an environment is saved, remote launch +failure includes launcher output where available, forwarded-port failure leaves the environment +disconnected rather than falling back to an unrelated endpoint, and reconnect restores the SSH bridge +before reconnecting the WebSocket client. + +## Launch methods + +Launch answers a different question: how does a T3 server come to exist on the target machine? Keep +it separate from access. + +- **Pre-existing server.** The operator already runs T3 and the client connects directly or through a + tunnel. +- **Desktop-managed remote launch over SSH.** Desktop probes the machine, launches or reuses a remote + server, forwards a port, and the renderer connects normally. The saved environment records that it + came from SSH launch for reconnect and lifecycle UX only; that metadata never changes the protocol + or the identity model. +- **Client-managed local publish.** A local server is published through the relay with + `t3 connect link`, exposing a desktop-hosted environment to mobile without router or firewall + changes. + +The same `ExecutionEnvironment` can be reached several of these ways. Only the launch and access +paths differ. + +## Security model + +Some environments are reachable over untrusted networks, so remote-capable environments require +explicit authentication, tunnel exposure never relies on obscurity, and saved endpoints carry enough +auth metadata to reconnect safely. + +WebSocket authentication is a dedicated short-lived ticket, not a token in a query string. The client +presents its long-lived bearer or DPoP credential in HTTP headers to +`POST /api/auth/websocket-ticket` ([authorization/remote.ts][authremote]), and appends only the +returned ticket as `wsTicket` on the socket URL. The server issues it through +`EnvironmentAuth.issueWebSocketTicket`; tickets are tagged `kind: "websocket"` and default to a +five-minute TTL (`DEFAULT_WEBSOCKET_TOKEN_TTL` in `apps/server/src/auth/SessionStore.ts`). The +handshake verifies the ticket, and each RPC method still enforces its own scope. See +[environment-auth.md](./environment-auth.md). + +Hosted pairing is a client-side convenience only. The hosted app must not receive pairing tokens +through query parameters, must not store pairing state server-side, and must not imply that an HTTP +backend is reachable from an HTTPS browser context. + +## Version coordination + +Remote environments stay online while clients move to newer releases. The environment descriptor +carries the running server version and may advertise a safe replacement path, so the UI can show the +right action without making the transport responsible for process management. The connection +supervisor owns the resulting disconnect and reconnect like any other involuntary close. See +[server-updates.md](./server-updates.md). + +## Future work + +These remain unbuilt and are listed to keep the model honest: + +- third-party tunnel products as additional endpoint providers; +- a relay-hosted OAuth callback broker (see [t3-connect.md](./t3-connect.md)); +- richer multi-environment UI beyond the current connections list. + +[model]: ../../packages/client-runtime/src/connection/model.ts +[onboarding]: ../../packages/client-runtime/src/connection/onboarding.ts +[authremote]: ../../packages/client-runtime/src/authorization/remote.ts +[sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts +[sshtunnel]: ../../packages/ssh/src/tunnel.ts diff --git a/docs/architecture/resource-telemetry.md b/docs/internals/resource-telemetry.md similarity index 99% rename from docs/architecture/resource-telemetry.md rename to docs/internals/resource-telemetry.md index 504aa3f8b646..0d07f31f8ac5 100644 --- a/docs/architecture/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -1,5 +1,7 @@ # Resource telemetry architecture +> For maintainers. Using T3 Code? See [docs/user](../user/). + Status: implemented ## Purpose diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md new file mode 100644 index 000000000000..2a0207010643 --- /dev/null +++ b/docs/internals/scripts.md @@ -0,0 +1,119 @@ +# Scripts + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +## First checkout + +T3 Code uses [Vite+](https://viteplus.dev/guide/). Install the global `vp` command, install +dependencies, then start the dev stack: + +```bash +curl -fsSL https://vite.plus | bash # Windows: irm https://vite.plus/ps1 | iex +vp i +vp run dev +``` + +Node 24 is required. Bun is not: the server picks Bun adapters when it detects Bun and falls back to +Node otherwise, and nothing in contributor setup needs it. + +`vp run dev` prints a one-time pairing URL. Open it so the first browser navigation is +authenticated. + +## Dev + +- `vp run dev`: Starts contracts, server, and web in watch mode. +- `vp run dev --share`: Also publishes the web port over HTTPS on this machine's tailnet. The + startup pairing URL is built against the shared origin, and the mapping is removed on exit. +- `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes + `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has + no effect; use `--browser`. +- `vp run dev:server`: Starts just the server. It runs on Node (`node --watch src/bin.ts`), so + without Bun present it selects `NodePtyAdapter` and `NodeHttpServer`. +- `vp run dev:web`: Starts just the Vite dev server for the web app. +- `vp run dev:desktop`: Starts the Electron shell against the dev server. +- `vp run dev:marketing`: Starts the Astro marketing site. +- Pass dev-runner flags directly after the root task name, for example: + `vp run dev --home-dir /tmp/t3code-dev` + +### Dev state directories + +- Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even + when `T3CODE_HOME` is set, storing state in `/.t3/userdata`. Pass `--home-dir ` to + choose another isolated directory explicitly. Submodules are not worktrees and keep the normal + precedence. +- From the **main checkout**, dev commands implicitly use `~/.t3/dev`, keeping development state + separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under + `/userdata`; the base directory remains available for caches, worktrees, and other shared + data. + +## Build, check, test + +- `vp run build`: Fans out over `apps/*`, `packages/*`, `oxlint-plugin-t3code`, and `scripts`. + Workspaces that define a build task run one: desktop, marketing, server (which depends on web), and + web. Shared packages are consumed and bundled transitively rather than built separately. +- `vp run build:desktop`: Builds the desktop pipeline (desktop plus server). +- `vp run start`: Runs the production server (serves the built web app as static files). +- `vp check`: Vite+ format, lint, and type checks. This repo sets `typeCheck: false` in its lint + options, so workspace type checking runs separately. +- `vp run typecheck`: Strict TypeScript checks for all packages. +- `vp run test`: Runs workspace tests. +- `vp run lint:mobile`: Mobile native static analysis (`scripts/mobile-native-static-check.ts`). +- `node apps/server/scripts/t3-sqlite-state.ts --base-dir ...`: Inspects or seeds + an isolated T3 SQLite database; writes create a private backup first. + +## Desktop artifacts + +- `vp run dist:desktop:artifact --platform --target --arch `: Builds a desktop artifact for a specific platform/target/arch. +- `vp run dist:desktop:dmg`: Builds a shareable macOS `.dmg` into `./release`. Architecture defaults + to the host, so this produces an arm64 DMG on Apple Silicon. Use `dist:desktop:dmg:arm64` or + `dist:desktop:dmg:x64`, or pass `--arch `, to force one. +- `vp run dist:desktop:linux`: Builds a Linux AppImage into `./release`. +- `vp run dist:desktop:win`: Builds a Windows NSIS installer into `./release`. `:arm64` and `:x64` + variants exist. + +### Desktop `.dmg` packaging notes + +- Default build is unsigned/not notarized for local sharing. +- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source. +- Desktop production windows load the bundled UI from the `t3code://app/` root URL (not a + `127.0.0.1` document URL, and not an explicit `index.html` path). +- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an + auth token for WebSocket/API traffic. +- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first + launch. +- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg --keep-stage` +- To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. +- Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and + `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from + `T3CODE_CLERK_PUBLISHABLE_KEY` unless `T3CODE_CLERK_PASSKEY_RP_DOMAINS` overrides it. +- Windows `--signed` uses Azure Trusted Signing and expects: + `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, + `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. +- Azure authentication env vars are also required (for example service principal with secret): + `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. + +## Browser development + +`dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend +from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the +server, allowing the same bundle to work from localhost or a tailnet hostname. + +## Running multiple dev instances + +Worktrees derive a preferred port offset from their path. + +- Default ports: server `13773`, web `5733` +- Shifted ports: `base + offset` +- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` + +Offset resolution, in order: + +1. `T3CODE_PORT_OFFSET`, which must be a non-negative integer. Negative values are rejected. +2. `T3CODE_DEV_INSTANCE`. An all-digit value is used directly as the offset; any other non-empty + value is hashed into one. +3. The worktree path hash. + +Collision scanning depends on the mode. `dev:web` scans only the web port and shifts only the web +offset. `dev:server` scans only the server port. `dev` and `dev:desktop` scan both and shift them +together as one shared offset. Explicit server or dev-URL overrides remove the corresponding port +from the availability check. Treat the `[dev-runner]` output as authoritative. diff --git a/docs/architecture/server-updates.md b/docs/internals/server-updates.md similarity index 89% rename from docs/architecture/server-updates.md rename to docs/internals/server-updates.md index 981f4e1eb420..1e0177371485 100644 --- a/docs/architecture/server-updates.md +++ b/docs/internals/server-updates.md @@ -1,5 +1,7 @@ # Server Update Architecture +> For maintainers. Using T3 Code? See [docs/user](../user/). + T3 Code can update a connected server to the exact version of the client that detected version drift. This path exists primarily for remote environments, where the user may not have a terminal open on the server machine. @@ -59,10 +61,11 @@ flowchart TD B -->|boot-service or respawn| E{Progress capability} E -->|present| F[server.updateServerWithProgress] E -->|missing| G[server.updateServer fallback] - F --> H[Download exact t3 version] + F --> H[Install exact t3 version in pinned runtime] G --> H - H --> I[Install and run version preflight] - I -->|fails| J[Remove failed runtime and keep current server] + H --> I[Run version preflight] + I -->|bad code or version| J[Remove candidate runtime and keep current server] + I -->|cannot run preflight| J2[Keep candidate and current server] I -->|passes| K{Handoff method} K -->|boot-service| L[Rewrite and restart T3 systemd unit] K -->|respawn| M[Start delayed replacement and exit current process] @@ -82,8 +85,14 @@ successfully. Boot-service setup and self-update share the same process-wide ins they cannot mutate a pinned runtime concurrently. Before any restart, the current Node executable runs the replacement with `--version`. A failed -install, failed preflight, or wrong reported version leaves the current server running. A failed -preflight also removes the candidate runtime so retrying the same version performs a clean install. +install, failed preflight, or wrong reported version leaves the current server running. + +Candidate cleanup is narrower than "any failed preflight". The candidate runtime is removed only when +the preflight process actually completes and reports a bad exit code or the wrong version: that is +the case where a completed npm install produced an unusable tree, so retrying the same version must +perform a clean install rather than reuse it. If the preflight cannot run at all, for example a spawn +error or the `PREFLIGHT_TIMEOUT` elapsing, the update fails before reaching cleanup and the candidate +directory is left in place. ## Host Service Lifecycle diff --git a/docs/cloud/t3-code-connect-auth-flow.html b/docs/internals/t3-code-connect-auth-flow.html similarity index 100% rename from docs/cloud/t3-code-connect-auth-flow.html rename to docs/internals/t3-code-connect-auth-flow.html diff --git a/docs/cloud/t3-connect-clerk.md b/docs/internals/t3-connect.md similarity index 75% rename from docs/cloud/t3-connect-clerk.md rename to docs/internals/t3-connect.md index 2fe48243a59d..c8a0217919f7 100644 --- a/docs/cloud/t3-connect-clerk.md +++ b/docs/internals/t3-connect.md @@ -1,8 +1,16 @@ -# T3 Connect Clerk Setup +# T3 Connect -T3 Connect uses one Clerk application for web, desktop, and mobile authentication. The relay accepts -Clerk JWTs only when they are generated from the `t3-relay` template with the shared -`t3-code-relay` audience. +> For maintainers. Using T3 Code? See [docs/user](../user/). + +T3 Connect uses one Clerk application for web, desktop, and mobile authentication. The relay verifies +two kinds of bearer credential: template JWTs generated from the `t3-relay` template with the shared +`t3-code-relay` audience, and Clerk OAuth tokens issued to the CLI. `verifyRelayClientBearerToken` in +`infra/relay/src/http/Api.ts` tries the template/session path first and falls back to OAuth +verification (`acceptsToken: "oauth_token"`), so the CLI's OAuth credential works without a JWT +template. + +For the wider system diagram, see +[t3-code-connect-auth-flow.html](./t3-code-connect-auth-flow.html). ## Application Keys @@ -35,10 +43,11 @@ should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS environment. -When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are -absent, the `t3 connect` CLI command group is omitted. The bundled server still accepts runtime -overrides for self-hosted or operator-managed -deployments. +When any client-facing public value is absent, cloud UI is omitted. The `t3 connect` command group is +always registered: when the CLI public values are absent, `makeCli` in `apps/server/src/bin.ts` +registers a hidden fallback `connect` command that reports the missing configuration instead of +silently vanishing from help. The bundled server still accepts runtime overrides for self-hosted or +operator-managed deployments. For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`, @@ -63,7 +72,12 @@ In **Clerk Dashboard > OAuth applications**: 1. Create an OAuth application for the T3 CLI. 2. Enable the **Public** option so authorization-code exchange uses PKCE. -3. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI. +3. Add **both** allowed redirect URIs: + - `http://127.0.0.1:34338/callback` for the loopback listener; + - `https://app.t3.codes/connect/callback` for the hosted out-of-band flow. This is + `connectCallbackUrl(DEFAULT_HOSTED_APP_URL)` from `packages/shared/src/connectAuth.ts`, so a + custom `T3CODE_HOSTED_APP_URL` means `$T3CODE_HOSTED_APP_URL/connect/callback` instead. + Omitting it breaks headless and SSH authorization. 4. Enable the `openid`, `profile`, and `email` scopes. 5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build environment to the generated public client ID. @@ -72,17 +86,20 @@ The CLI derives Clerk's frontend API URL from the publishable key and calls Cler `/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. -The CLI supports these headless operations: +The connect command group is: ```sh +t3 connect # default: onboarding t3 connect login -t3 connect link -t3 connect status +t3 connect link # --publish-only +t3 connect status # --json +t3 connect publish # --disable t3 connect unlink t3 connect logout -t3 serve ``` +`t3 serve` is a separate top-level command, not a connect subcommand. + `t3 connect login` opens the Clerk authorization flow and stores the CLI credential without enabling cloud exposure. `t3 connect link` installs the pinned managed `cloudflared` binary when needed, authorizes when needed, and records durable intent to expose the environment. It works without a @@ -95,16 +112,21 @@ logout` performs the same cleanup and removes the stored CLI authorization. The background service has an independent lifecycle. Connect setup may offer to install it, but logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`. -The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, -forward that port before running `t3 connect login` or `t3 connect link`: +### Headless and SSH authorization + +The loopback OAuth callback listener binds to port `34338`. That path only works when a browser on +the same machine can reach it, so `authorizeCli` in `apps/server/src/cli/connect.ts` automatically +selects the out-of-band flow when `--headless` is passed or when it detects SSH through +`SSH_CONNECTION` or `SSH_TTY`. The out-of-band flow prints the hosted `/connect` authorization URL +and accepts a pasted authorization code, so no port is involved. + +Port forwarding is therefore optional, not required. Forward the port only if you specifically want +the loopback flow over SSH: ```sh ssh -L 34338:127.0.0.1:34338 ``` -A relay-hosted callback broker can remove this port-forward requirement later without changing the -stored PKCE token model. - ## JWT Template In **Clerk Dashboard > JWT templates**, create a template with: @@ -207,34 +229,26 @@ codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app" The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth flow uses a custom redirect URI, add that exact URI to the same allowlist. -## Enable Waitlist Access - -For a private beta where people should request access, use **Clerk Dashboard > Waitlist**: - -1. Toggle on **Enable waitlist** and save. -2. Review requests on the same page and select **Invite** or **Deny**. - -Approved signed-in users manage T3 Connect under **Connections**. The web and desktop sidebars do -not expose a dedicated account or waitlist control. Signed-out users reach Clerk's waitlist and -sign-in flow contextually from the T3 Connect controls on the Connections page. - -On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the -Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt -`` component is web-only in the Expo SDK. Approved users can use **Sign in** from that -screen. +## Sign-in Surfaces -## Alternative: Known-User Allowlist +Signed-in users manage T3 Connect under **Connections**. The settings sidebar also has dedicated +controls, rendered by `SettingsSidebarNav.tsx`: `T3ConnectSidebarSignIn` in the footer shows a +**Sign in to T3 Connect** button while signed out, and `T3ConnectSidebarAvatar` shows a Clerk +`UserButton` account control while signed in. Both are gated on cloud public configuration. +Desktop renders the same web bundle, so it has them too. The waitlist enrollment flow from the +private beta was removed when Connect went GA; sign-up is open unless a Clerk restriction below is +enabled. -For a closed beta where all permitted users are known in advance, use an allowlist instead of a -request-and-approval waitlist: +## Restricting Sign-ups: Known-User Allowlist -To restrict the beta to permitted email addresses or domains: +For a closed deployment where all permitted users are known in advance, restrict sign-up to +permitted email addresses or domains: 1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email domain. 2. Enable the allowlist and save. 3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or - manually created without a waitlist request flow. + manually created. Do not enable an empty allowlist: it blocks all new sign-ups. diff --git a/docs/internals/workspace-layout.md b/docs/internals/workspace-layout.md new file mode 100644 index 000000000000..e933e4528915 --- /dev/null +++ b/docs/internals/workspace-layout.md @@ -0,0 +1,63 @@ +# Workspace layout + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +A pnpm workspace driven by [vite-plus](https://vite.plus) (`vp`). See [scripts.md](./scripts.md) for +the task commands. + +## apps + +- `apps/server` (`t3`): the execution runtime and the published CLI. Owns orchestration, provider + drivers, checkpointing, VCS, terminals, filesystem access, auth, and the HTTP + WebSocket surface. + Also serves the built web app. +- `apps/web` (`@t3tools/web`): React + Vite UI. Consumes the shared client runtime and adds routing, + components, and web-specific platform layers. +- `apps/desktop` (`@t3tools/desktop`): Electron shell. Supervises a desktop-scoped `t3` backend, + loads the web bundle over the `t3code://` protocol, and owns SSH-managed remote environments. +- `apps/mobile` (`@t3tools/mobile`): Expo/React Native client. Same client runtime composition as + web, different platform layer and UI. +- `apps/marketing` (`@t3tools/marketing`): Astro marketing site. + +## packages + +- `packages/contracts` (`@t3tools/contracts`): shared Effect Schema definitions. RPC group, + orchestration commands/events/read model, auth scopes, environment descriptors, settings. +- `packages/shared` (`@t3tools/shared`): framework-agnostic utilities used by server and clients + (`DrainableWorker`, git and source-control helpers, relay auth and signing, DPoP, semver, logging, + observability, and more). +- `packages/client-runtime` (`@t3tools/client-runtime`): connection lifecycle, authorization, RPC + session, environment registry, and Atom-based domain state shared by web and mobile. See its + [README](../../packages/client-runtime/README.md). +- `packages/ssh` (`@t3tools/ssh`): SSH config parsing, auth prompts, command execution, and the + tunnel/environment manager behind desktop-managed SSH environments. +- `packages/tailscale` (`@t3tools/tailscale`): Tailscale CLI wrapper, including the + `ensureTailscaleServe` / `disableTailscaleServe` serve lifecycle the server drives. +- `packages/effect-acp` (`effect-acp`): Effect client and agent implementation of the Agent Client + Protocol, used by ACP-speaking provider drivers. +- `packages/effect-codex-app-server` (`effect-codex-app-server`): Effect client for the + `codex app-server` JSON-RPC protocol. + +## infra + +- `infra/relay` (`t3code-relay`): the hosted T3 Connect relay, deployed with Alchemy. Handles + environment discovery, cloud-side records, and mobile notifications. It is not in the hot path; + after connect, client traffic goes directly to the environment. See + [t3-connect.md](./t3-connect.md). + +## Other top-level directories + +- `scripts/`: workspace tooling run through `vp run`. Dev runner, desktop artifact builds, release + helpers, mobile static checks and showcase capture, update-manifest merging. +- `assets/`: brand and app icon sources per channel (`dev`, `nightly`, `prod`). +- `patches/`: pnpm patches for pinned upstream dependencies. +- `oxlint-plugin-t3code/`: repo-specific lint rules. +- `experiments/`: throwaway prototypes. Not part of the shipped build. +- `docs/`: this documentation tree. + +## Import conventions + +`@t3tools/shared` and `@t3tools/client-runtime` use explicit subpath exports with no barrel index and +no root export. Import the narrow path (`@t3tools/shared/DrainableWorker`, +`@t3tools/client-runtime/state/threads`) rather than the package root. Files that are not exported +are implementation details. `@t3tools/contracts` does export a root alongside `./settings` and +`./relay`. diff --git a/docs/operations/ci.md b/docs/operations/ci.md deleted file mode 100644 index 6cd64b345768..000000000000 --- a/docs/operations/ci.md +++ /dev/null @@ -1,6 +0,0 @@ -# CI quality gates - -- `.github/workflows/ci.yml` runs `vp check`, `vpr typecheck`, and `vp run test` on pull requests and pushes to `main`. -- `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), Windows (`x64`), and an installable Android APK from a single `v*.*.*` tag and publishes one GitHub release. -- The release workflow auto-enables persistent signing when platform credentials are present. macOS passkey builds additionally require `APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. Android uses the configured release keystore or, when all Android signing secrets are absent, an ephemeral CI key that produces an installable APK but cannot upgrade APKs signed by another release. -- See [Release Checklist](./release.md) for the full release/signing setup checklist. diff --git a/docs/operations/effect-fn-checklist.md b/docs/operations/effect-fn-checklist.md deleted file mode 100644 index 90d040fbe5a1..000000000000 --- a/docs/operations/effect-fn-checklist.md +++ /dev/null @@ -1,194 +0,0 @@ -# Effect.fn Refactor Checklist - -Generated from a repo scan for non-test wrapper-style candidates matching either `=> Effect.gen(function* ...)` or `return Effect.gen(function* ...)`. - -Refactor Method: - -```ts -// Old -function old () { - return Effect.gen(function* () { - ... - }); -} - -const old2 = () => Effect.gen(function* () { - ... -}); -``` - -```ts -// New -const new = Effect.fn('functionName')(function* () { - ... -}) -``` - -- Use `Effect.fn('name')(function* (input: Input): Effect.fn.Return {})` to annotate the return type of the function if needed. - -- The 2nd argument works as a pipe, and it gets the effect and input as arguments: - -```ts -Effect.fn("name")( - function* (input: Input): Effect.fn.Return {}, - (effect, input) => Effect.catch(effect, (reason) => Effect.logWarning("Err", { input, reason })), -); -``` - -## Summary - -- Total non-test candidates: `322` - -## Suggested Order - -- [ ] `apps/server/src/provider/Layers/ProviderService.ts` -- [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts` -- [x] `apps/server/src/provider/Layers/CodexAdapter.ts` -- [x] `apps/server/src/git/Layers/GitCore.ts` -- [x] `apps/server/src/git/Layers/GitManager.ts` -- [x] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- [x] `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` -- [ ] `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` -- [ ] `apps/server/src/provider/Layers/EventNdjsonLogger.ts` -- [ ] `Everything else` - -## Checklist - -### `apps/server/src/provider/Layers/ClaudeAdapter.ts` (`62`) - -- [x] [buildUserMessageEffect](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L554) -- [x] [makeClaudeAdapter](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L913) -- [x] [startSession](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2414) -- [x] [sendTurn](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2887) -- [x] [interruptTurn](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2975) -- [x] [readThread](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2984) -- [x] [rollbackThread](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L2990) -- [x] [stopSession](../../apps/server/src/provider/Layers/ClaudeAdapter.ts#L3039) -- [x] Internal helpers and callback wrappers in this file - -### `apps/server/src/git/Layers/GitCore.ts` (`58`) - -- [x] [makeGitCore](../../apps/server/src/git/Layers/GitCore.ts#L513) -- [x] [handleTraceLine](../../apps/server/src/git/Layers/GitCore.ts#L324) -- [x] [emitCompleteLines](../../apps/server/src/git/Layers/GitCore.ts#L455) -- [x] [commit](../../apps/server/src/git/Layers/GitCore.ts#L1190) -- [x] [pushCurrentBranch](../../apps/server/src/git/Layers/GitCore.ts#L1223) -- [x] [pullCurrentBranch](../../apps/server/src/git/Layers/GitCore.ts#L1323) -- [x] [checkoutBranch](../../apps/server/src/git/Layers/GitCore.ts#L1727) -- [x] Service methods and callback wrappers in this file - -### `apps/server/src/git/Layers/GitManager.ts` (`28`) - -- [x] [configurePullRequestHeadUpstream](../../apps/server/src/git/Layers/GitManager.ts#L387) -- [x] [materializePullRequestHeadBranch](../../apps/server/src/git/Layers/GitManager.ts#L428) -- [x] [findOpenPr](../../apps/server/src/git/Layers/GitManager.ts#L576) -- [x] [findLatestPr](../../apps/server/src/git/Layers/GitManager.ts#L602) -- [x] [runCommitStep](../../apps/server/src/git/Layers/GitManager.ts#L728) -- [x] [runPrStep](../../apps/server/src/git/Layers/GitManager.ts#L842) -- [x] [runFeatureBranchStep](../../apps/server/src/git/Layers/GitManager.ts#L1106) -- [x] Remaining helpers and nested callback wrappers in this file - -### `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` (`25`) - -- [x] [runProjectorForEvent](../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L1161) -- [x] [applyProjectsProjection](../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L357) -- [x] [applyThreadsProjection](../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L415) -- [x] `Effect.forEach(..., threadId => Effect.gen(...))` callbacks around `L250` -- [x] `Effect.forEach(..., entry => Effect.gen(...))` callbacks around `L264` -- [x] `Effect.forEach(..., entry => Effect.gen(...))` callbacks around `L305` -- [x] Remaining apply helpers in this file - -### `apps/server/src/provider/Layers/ProviderService.ts` (`24`) - -- [ ] [makeProviderService](../../apps/server/src/provider/Layers/ProviderService.ts#L134) -- [ ] [recoverSessionForThread](../../apps/server/src/provider/Layers/ProviderService.ts#L196) -- [ ] [resolveRoutableSession](../../apps/server/src/provider/Layers/ProviderService.ts#L255) -- [ ] [startSession](../../apps/server/src/provider/Layers/ProviderService.ts#L284) -- [ ] [sendTurn](../../apps/server/src/provider/Layers/ProviderService.ts#L347) -- [ ] [interruptTurn](../../apps/server/src/provider/Layers/ProviderService.ts#L393) -- [ ] [respondToRequest](../../apps/server/src/provider/Layers/ProviderService.ts#L411) -- [ ] [respondToUserInput](../../apps/server/src/provider/Layers/ProviderService.ts#L430) -- [ ] [stopSession](../../apps/server/src/provider/Layers/ProviderService.ts#L445) -- [ ] [listSessions](../../apps/server/src/provider/Layers/ProviderService.ts#L466) -- [ ] [rollbackConversation](../../apps/server/src/provider/Layers/ProviderService.ts#L516) -- [ ] [runStopAll](../../apps/server/src/provider/Layers/ProviderService.ts#L538) - -### `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (`14`) - -- [x] [finalizeAssistantMessage](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L680) -- [x] [upsertProposedPlan](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L722) -- [x] [finalizeBufferedProposedPlan](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L761) -- [x] [clearTurnStateForSession](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L800) -- [x] [processRuntimeEvent](../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L908) -- [x] Nested callback wrappers in this file - -### `apps/server/src/provider/Layers/CodexAdapter.ts` (`12`) - -- [x] [makeCodexAdapter](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1317) -- [x] [sendTurn](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1399) -- [x] [writeNativeEvent](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1546) -- [x] [listener](../../apps/server/src/provider/Layers/CodexAdapter.ts#L1555) -- [x] Remaining nested callback wrappers in this file - -### `apps/server/src/checkpointing/CheckpointStore.ts` (`10`) - -- [ ] [captureCheckpoint](../../apps/server/src/checkpointing/CheckpointStore.ts#L123) -- [ ] [restoreCheckpoint](../../apps/server/src/checkpointing/CheckpointStore.ts#L137) -- [ ] [diffCheckpoints](../../apps/server/src/checkpointing/CheckpointStore.ts#L144) -- [ ] [deleteCheckpointRefs](../../apps/server/src/checkpointing/CheckpointStore.ts#L151) -- [ ] Nested callback wrappers in this file - -### `apps/server/src/provider/Layers/EventNdjsonLogger.ts` (`9`) - -- [ ] [toLogMessage](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L77) -- [ ] [makeThreadWriter](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L102) -- [ ] [makeEventNdjsonLogger](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L174) -- [ ] [write](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L231) -- [ ] [close](../../apps/server/src/provider/Layers/EventNdjsonLogger.ts#L247) -- [ ] Flush and writer-resolution callback wrappers in this file - -### `apps/server/scripts/cli.ts` (`8`) - -- [ ] Command handlers around [cli.ts](../../apps/server/scripts/cli.ts#L125) -- [ ] Command handlers around [cli.ts](../../apps/server/scripts/cli.ts#L170) -- [ ] Resource callbacks around [cli.ts](../../apps/server/scripts/cli.ts#L221) -- [ ] Resource callbacks around [cli.ts](../../apps/server/scripts/cli.ts#L239) - -### `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` (`7`) - -- [ ] [processEnvelope](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L64) -- [ ] [dispatch](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L218) -- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L162) -- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L200) - -### `apps/server/src/orchestration/projector.ts` (`5`) - -- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L242) -- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L336) -- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L397) -- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L446) -- [ ] `switch` branch wrapper at [projector.ts](../../apps/server/src/orchestration/projector.ts#L478) - -### Smaller clusters - -- [ ] [packages/shared/src/DrainableWorker.ts](../../packages/shared/src/DrainableWorker.ts) (`4`) -- [ ] [apps/server/src/wsServer/pushBus.ts](../../apps/server/src/wsServer/pushBus.ts) (`4`) -- [ ] [apps/server/src/wsServer.ts](../../apps/server/src/wsServer.ts) (`4`) -- [ ] [apps/server/src/provider/Layers/ProviderRegistry.ts](../../apps/server/src/provider/Layers/ProviderRegistry.ts) (`4`) -- [ ] [apps/server/src/persistence/Layers/Sqlite.ts](../../apps/server/src/persistence/Layers/Sqlite.ts) (`4`) -- [ ] [apps/server/src/orchestration/Layers/ProviderCommandReactor.ts](../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) (`4`) -- [ ] [apps/server/src/main.ts](../../apps/server/src/main.ts) (`4`) -- [ ] [apps/server/src/keybindings.ts](../../apps/server/src/keybindings.ts) (`4`) -- [ ] [apps/server/src/git/Layers/CodexTextGeneration.ts](../../apps/server/src/git/Layers/CodexTextGeneration.ts) (`4`) -- [ ] [apps/server/src/serverLayers.ts](../../apps/server/src/serverLayers.ts) (`3`) -- [ ] [apps/server/src/telemetry/Layers/AnalyticsService.ts](../../apps/server/src/telemetry/Layers/AnalyticsService.ts) (`2`) -- [ ] [apps/server/src/telemetry/Identify.ts](../../apps/server/src/telemetry/Identify.ts) (`2`) -- [ ] [apps/server/src/provider/Layers/ProviderAdapterRegistry.ts](../../apps/server/src/provider/Layers/ProviderAdapterRegistry.ts) (`2`) -- [ ] [apps/server/src/provider/Layers/CodexProvider.ts](../../apps/server/src/provider/Layers/CodexProvider.ts) (`2`) -- [ ] [apps/server/src/provider/Layers/ClaudeProvider.ts](../../apps/server/src/provider/Layers/ClaudeProvider.ts) (`2`) -- [ ] [apps/server/src/persistence/NodeSqliteClient.ts](../../apps/server/src/persistence/NodeSqliteClient.ts) (`2`) -- [ ] [apps/server/src/persistence/Migrations.ts](../../apps/server/src/persistence/Migrations.ts) (`2`) -- [ ] [apps/server/src/open.ts](../../apps/server/src/open.ts) (`2`) -- [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](../../apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`) -- [ ] [apps/server/src/checkpointing/CheckpointDiffQuery.ts](../../apps/server/src/checkpointing/CheckpointDiffQuery.ts) (`2`) -- [ ] [apps/server/src/provider/makeManagedServerProvider.ts](../../apps/server/src/provider/makeManagedServerProvider.ts) (`1`) diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 2c36ab9d0086..27891cb5d638 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -1,5 +1,7 @@ # Mobile app-store screenshot harness +> For maintainers. Using T3 Code? See [docs/user](../user/). + The screenshot harness runs the real mobile application against three disposable local T3 environments. It creates an isolated base directory and server for each environment, real Git projects with deterministic content, seeded orchestration projections, and persisted terminal @@ -44,35 +46,44 @@ delay allows native terminal and Git review data to finish rendering. A full capture regenerates the selected native project with Expo's clean development prebuild before building it. Use --skip-build for repeated captures after the first build. -The harness uses its own Metro port (8199 by default), so an ordinary mobile server or another -worktree cannot accidentally provide the bundle being photographed. +The harness uses fixed Metro port `8199`, which separates it from Expo's normal default port but is +shared across every checkout. The readiness check only verifies that the port is open; it does not +verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore +collide or attach to the wrong Metro process. + +Every configured device defaults to dark appearance, so plain `pnpm screenshots:mobile` produces +30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or `--appearance both` to override the +configured appearance; `both` produces 60 PNGs. The default matrix is: -| Output folder | Capture target | Upload dimensions | Store slot | -| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- | -| `apple/iphone-6.9/{light,dark}/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | -| `apple/iphone-6.5/{light,dark}/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | -| `apple/ipad-13/{light,dark}/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch | -| `google-play/phone/{light,dark}/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | -| `google-play/tablet-7/{light,dark}/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | -| `google-play/tablet-10/{light,dark}/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | - -Each target captures thread, terminal, review, thread list, and environments, producing 30 PNG -files for one appearance or 60 for both. Each appearance folder's five screenshots satisfy the configured Apple limit of 1–10, Google +| Output folder | Capture target | Upload dimensions | Store slot | +| ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- | +| `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | +| `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | +| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch | +| `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | +| `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | +| `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | + +Each target captures thread, terminal, review, thread list, and environments. Each appearance +folder's five screenshots satisfy the configured Apple limit of 1–10, Google phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. The generated tree is deliberately aligned with the store upload fields: artifacts/app-store/screenshots/ ├── apple/ - │ ├── iphone-6.9/{light,dark}/{thread,terminal,review,threads,environments}.png - │ ├── iphone-6.5/{light,dark}/{thread,terminal,review,threads,environments}.png - │ └── ipad-13/{light,dark}/{thread,terminal,review,threads,environments}.png + │ ├── iphone-6.9/dark/{thread,terminal,review,threads,environments}.png + │ ├── iphone-6.5/dark/{thread,terminal,review,threads,environments}.png + │ └── ipad-13/dark/{thread,terminal,review,threads,environments}.png └── google-play/ - ├── phone/{light,dark}/{thread,terminal,review,threads,environments}.png - ├── tablet-7/{light,dark}/{thread,terminal,review,threads,environments}.png - └── tablet-10/{light,dark}/{thread,terminal,review,threads,environments}.png + ├── phone/dark/{thread,terminal,review,threads,environments}.png + ├── tablet-7/dark/{thread,terminal,review,threads,environments}.png + └── tablet-10/dark/{thread,terminal,review,threads,environments}.png + +A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance +folders. Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. @@ -85,10 +96,11 @@ runs iOS and Android concurrently: iPhone and iPad capture on a 12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a 16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. -Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for -diagnosis. Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow -run's Artifacts section. Each job runs validation again immediately before upload. Artifacts are -retained for 14 days. +Every job uploads its PNGs even when capture fails, which makes partial runs useful for diagnosis. +The separate validation step is success-gated: it runs before upload only when capture succeeds. If +capture fails, the `always()` upload still publishes partial PNGs without re-validating them. +Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow run's +Artifacts section. Artifacts are retained for 14 days. The workflow uses the same checked-in device and scene matrix as local capture. Android remains ARM64 by default for local Apple Silicon development; CI sets `T3_SHOWCASE_ANDROID_ABI=x86_64` so the @@ -111,11 +123,19 @@ Reuse the native build and retain the disposable environment: pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running -Run Metro separately: +By default, let the screenshot runner start Metro on port `8199`. To keep Metro in a separate +terminal, start it with the same showcase environment and explicit harness port: + + cd apps/mobile + APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 pnpm exec expo start --dev-client --port 8199 + +Then run the capture from the repository root: - pnpm --filter @t3tools/mobile showcase pnpm screenshots:mobile --skip-build --skip-metro --device iphone-6.9 +`pnpm --filter @t3tools/mobile showcase` starts Expo on its normal port, so it is not compatible with +the harness's `--skip-metro` mode. + List the matrix and flags: pnpm screenshots:mobile --list @@ -151,7 +171,9 @@ remote-first while the harness retains reliable loopback connections to its ephe ## Local prerequisites - iOS: Xcode command-line tools, the configured simulator runtimes, and installed CocoaPods. -- Android: ANDROID_HOME (or the default macOS SDK path), adb, emulator, and the configured AVD. +- Android: SDK resolution checks `ANDROID_HOME`, then `ANDROID_SDK_ROOT`, then defaults to + `$HOME/Library/Android/sdk` on macOS or `$HOME/Android/Sdk` on other platforms. The resolved SDK + must provide `adb` and `emulator`, and the configured AVD must exist. The harness is the source of truth for upload dimensions; do not resize its output. If store rules change, update the target's `storeAsset` specification. Capture fails when a PNG is the wrong size, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 1d55894b1824..7341bfb5edac 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -1,39 +1,53 @@ # Observability +> For maintainers. Using T3 Code? See [docs/user](../user/). + T3 Code has one server-side observability model: - pretty logs go to stdout for humans - completed spans go to a local NDJSON trace file - traces and metrics can also be exported over OTLP to a real backend like Grafana LGTM -The local trace file is the persisted source of truth. There is no separate persisted server log file anymore. +The local trace file is the persisted source of truth for normal local launches. Those launches do not +write a separate server log file, but SSH-managed launches also persist the remote process's +stdout/stderr at `~/.t3/ssh-launch//server.log`. ## Where To Find Things ### Logs -Logs are human-facing only: +Logs are human-facing: - destination: stdout - format: `Logger.consolePretty()` -- persistence: none +- normal local persistence: none +- SSH-managed launch persistence: `~/.t3/ssh-launch//server.log` If you want a log message to show up in the trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event. ### Traces -Completed spans are written as NDJSON records to `serverTracePath` (by default, `~/.t3/userdata/logs/server.trace.ndjson`). +Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the +server starts: production and explicitly configured homes use +`/userdata/logs/server.trace.ndjson` (so `~/.t3/userdata/...` by default, or +`/custom/path/userdata/...` with `--home-dir /custom/path`), a linked worktree dev run uses +`/.t3/userdata/logs/server.trace.ndjson`, and an implicit dev run outside a linked +worktree uses `~/.t3/dev/logs/server.trace.ndjson`. -Important fields in each record: +Important fields common to both record types: +- `type`: `effect-span` or `otlp-span` - `name`: span name - `traceId`, `spanId`, `parentSpanId`: correlation - `durationMs`: elapsed time - `attributes`: structured context - `events`: embedded logs and custom events -- `exit`: `Success`, `Failure`, or `Interrupted` -The schema lives in `apps/server/src/observability/TraceRecord.ts`. +`effect-span` records also contain `exit` with `Success`, `Failure`, or `Interrupted`. `otlp-span` +records instead carry OTLP resource, scope, and optional status fields. + +The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in +`packages/shared/src/observability.ts`. ### Metrics @@ -165,30 +179,35 @@ The backend reads observability config at process start. If you change OTLP env The trace file is the fastest way to inspect raw span data. -Resolve the production or explicitly configured trace file once. Runtime state lives under the -base directory's `userdata` folder: +Resolve the path for the launch mode once. Production and explicitly configured homes store runtime +state under the base directory's `userdata` folder: ```bash TRACE_FILE="${T3CODE_HOME:-$HOME/.t3}/userdata/logs/server.trace.ndjson" ``` -Tail it: +A dev server started from a linked worktree defaults to that worktree's local home: ```bash -tail -f "$TRACE_FILE" +TRACE_FILE="$WORKTREE/.t3/userdata/logs/server.trace.ndjson" ``` -For an implicit monorepo dev server, use: +Only an implicit dev run outside a linked worktree uses the shared dev directory: ```bash TRACE_FILE="$HOME/.t3/dev/logs/server.trace.ndjson" +``` + +Tail the selected file: + +```bash tail -f "$TRACE_FILE" ``` Show failed spans: ```bash -jq -c 'select(.exit._tag != "Success") | { +jq -c 'select(.type == "effect-span" and .exit._tag != "Success") | { name, durationMs, exit, @@ -281,10 +300,12 @@ Recommended flow in Grafana: Good first searches: - service name such as `t3-local`, `t3-dev`, or `t3-desktop` -- span names like `sql.execute`, `git.runCommand`, `provider.sendTurn` +- span names like `sendTurn` or a Git operation such as `GitVcsDriver.statusDetails.status` +- Git spans whose `git.operation` attribute identifies the operation - orchestration spans with attributes like `orchestration.command_type` -Once you know traces are arriving, narrower TraceQL queries like `name = "sql.execute"` become useful. +Once you know traces are arriving, narrower TraceQL queries for names such as `sendTurn` or Git +operation names become useful. ### Use Metrics To See Systemic Problems @@ -297,7 +318,6 @@ Good metric families to watch: - `t3_orchestration_command_ack_duration` - `t3_provider_turn_duration` - `t3_git_command_duration` -- `t3_db_query_duration` Counters tell you volume and failure rate: @@ -305,7 +325,6 @@ Counters tell you volume and failure rate: - `t3_orchestration_commands_total` - `t3_provider_turns_total` - `t3_git_commands_total` -- `t3_db_queries_total` Use metrics when the question is: @@ -339,7 +358,7 @@ If you need those later, add client-side instrumentation or a dedicated server f ### "Why did this request fail?" 1. Start with the local NDJSON file. -2. Find spans where `exit._tag != "Success"`. +2. Find `effect-span` records where `exit._tag != "Success"`. 3. Group by `traceId`. 4. Inspect sibling spans and span events. 5. If needed, move to Tempo for the full trace tree. @@ -522,6 +541,8 @@ Current high-value span and metric boundaries include: ### Current Constraints -- logs outside spans are not persisted +- logs outside spans are not persisted in the trace file; SSH-managed launch stdout/stderr is still + captured in its launcher log - metrics are not snapshotted locally -- the old `serverLogPath` still exists in config for compatibility, but the trace file is the persisted artifact that matters +- the old `serverLogPath` still exists in config for compatibility, but the trace file is the primary + structured persisted artifact diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md index dafad2155af2..2bc697b2ef1f 100644 --- a/docs/operations/relay-observability.md +++ b/docs/operations/relay-observability.md @@ -1,9 +1,14 @@ # Relay observability -The relay Alchemy stack owns a focused Axiom trace setup: +> For maintainers. Using T3 Code? See [docs/user](../user/). -- `t3-code-relay-traces-prod`, an OpenTelemetry trace dataset for Worker requests -- `t3-code-relay-otel-ingest-prod`, a dataset-scoped ingest token bound to the Worker +The relay Alchemy stack owns a shared Axiom trace setup: + +- `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and + first-party relay clients +- `t3-code-relay-otel-ingest-prod`, the dataset-scoped Worker ingest token +- `t3-code-mobile-otel-ingest-prod`, the dataset-scoped mobile ingest token +- `t3-code-relay-client-otel-ingest-prod`, the dataset-scoped first-party relay-client ingest token - `t3-code-relay-recent-spans-prod`, a view of recent request and endpoint spans Alchemy stages append their sanitized stage name to isolate resources, for example @@ -15,8 +20,9 @@ Deploy from `infra/relay` with the normal Alchemy workflow: vp run deploy ``` -Alchemy resolves Axiom deployment credentials through its provider. At runtime, the Worker -receives only the scoped ingest token; it does not receive the diagnostics query token. +Alchemy resolves account-level Axiom deployment credentials through its provider. At runtime, the +Worker receives only its scoped ingest token. Mobile and relay clients use their own separately +provisioned scoped ingest tokens. The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans. Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix. @@ -25,18 +31,23 @@ For example: ```apl ['t3-code-relay-traces-prod'] | where name startswith 'http.server' +| extend endpoint = column_ifexists('attributes.http.route', ''), + customAttributes = column_ifexists('attributes.custom', dynamic({})) | project _time, name, trace_id, duration, ['attributes.http.request.method'], ['attributes.url.path'], - ['attributes.http.response.status_code'] + ['attributes.http.response.status_code'], + endpoint, + relayOperation = customAttributes['relay']['operation'] | order by _time desc | limit 200 ``` -Endpoint failure annotations and other relay-specific attributes are also emitted in the -`attributes.custom` map when present on a span, for example -`['attributes.custom']['relay.endpoint']`. +The provisioned view also reads the endpoint from `attributes.http.route`. Relay-specific span +annotations are stored under `attributes.custom`; `relay.operation` is one of the emitted custom +attributes. Agents should prefer the provisioned view or APL queries for completed incidents instead of -tailing the Cloudflare Worker. Use the read-only query token when scripted access is needed; -keep the ingest token reserved for the Worker. +tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who +need scripted query access use the authorized account-level `AXIOM_TOKEN` together with +`AXIOM_ORG_ID`; scoped ingest tokens remain write-only credentials for their producers. diff --git a/docs/operations/release.md b/docs/operations/release.md index 48c0647b0fe8..e70b14fbf7ab 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -1,5 +1,7 @@ # Release Checklist +> For maintainers. Using T3 Code? See [docs/user](../user/). + This document covers the unified release workflow for stable and nightly desktop and Android releases. ## What the workflow does @@ -46,6 +48,17 @@ The job fails on a partially configured secret set. When all four are absent, it The production job also reads `CLERK_PUBLISHABLE_KEY`, `CLERK_JWT_TEMPLATE`, `RELAY_DOMAIN` (or `RELAY_API_ZONE_NAME`), and optional Clerk Google client IDs from GitHub environment variables and compiles them into the mobile app. +## Required release credentials + +The release workflow requires these GitHub Actions secrets in addition to the platform and deployment +credentials documented below: + +- `RELEASE_APP_ID` +- `RELEASE_APP_PRIVATE_KEY` + +The GitHub Release job uses them to mint the token that publishes release assets. Stable releases use +them again in the finalize job, which can commit and push aligned package versions to `main`. + ## T3 Connect relay deployment The relay is a shared control plane versioned separately from client releases. Stable and nightly @@ -164,7 +177,8 @@ One-time Vercel dashboard setup: - manual `workflow_dispatch` with `channel=nightly` - Runs the same desktop quality gates and artifact matrix as the tagged release flow. - Publishes a GitHub prerelease only: - - tag format: `nightly-vX.Y.Z-nightly.YYYYMMDD.` + - current tag format: `vX.Y.Z-nightly.YYYYMMDD.` + - `nightly-v...` is accepted only as a legacy previous-nightly tag - release name includes the short commit SHA - `make_latest` is always `false` - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. @@ -194,7 +208,9 @@ guidance when those environments are available. ## Desktop auto-update notes -- Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`. +- Updater runtime: `apps/desktop/src/updates/DesktopUpdates.ts`. +- `electron-updater` adapter: `apps/desktop/src/electron/ElectronUpdater.ts`. +- `apps/desktop/src/main.ts` only wires the updater layers into the desktop runtime. - Update UX: - Background checks run on startup delay + interval. - No automatic download or install. @@ -203,9 +219,6 @@ guidance when those environments are available. - Repository slug source: - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set. - otherwise `GITHUB_REPOSITORY` from GitHub Actions. -- Temporary private-repo auth workaround: - - set `T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN` (or `GH_TOKEN`) in the desktop app runtime environment. - - the app forwards it as an `Authorization: Bearer ` request header for updater HTTP calls. - Required release assets for updater: - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases @@ -216,8 +229,9 @@ guidance when those environments are available. ## 0) npm OIDC trusted publishing setup (CLI) -The workflow publishes the CLI with `npm publish` from `apps/server` after bumping -the package version to the release tag version. +The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That +script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the +repository root so workspace publish configuration is applied correctly. Checklist: @@ -229,22 +243,27 @@ Checklist: - Environment (if used): match your npm trusted publishing config 3. Ensure npm account and org policies allow trusted publishing for the package. 4. Create release tag `vX.Y.Z` and push; workflow will: - - set `apps/server/package.json` version to `X.Y.Z` + - align the release package versions to `X.Y.Z` - build web + server - - run `npm publish --access public --tag latest` -5. Nightly runs from the same workflow file publish with `npm publish --access public --tag nightly`. + - invoke the CLI publish script with npm dist-tag `latest` +5. Nightly runs invoke the same publish script with npm dist-tag `nightly`. + +## 1) Release validation and unsigned builds -## 1) Dry-run release without signing +There is no dry-run tag path. Pushing any accepted non-nightly tag, including +`v0.0.0-test.1`, classifies the run as the stable channel. It publishes `t3` with npm dist-tag +`latest`, creates a real GitHub Release, aliases the hosted app to `latest.app.t3.codes` and +`app.t3.codes`, and can commit a version bump to `main` in the finalize job. Do not push a test tag +to validate the workflow. -Use this first to validate the release pipeline. +The workflow has no non-publishing `workflow_dispatch` mode. Use normal CI or local quality gates to +validate checks and builds without shipping. To exercise the complete release graph at lower stable +risk, manually dispatch `channel=nightly`; this still publishes a real nightly npm package, GitHub +prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or +commit a version bump to `main`. Only run it when a real nightly release is acceptable. -1. Confirm no signing secrets are required for this test. -2. Create a test tag: - - `git tag v0.0.0-test.1` - - `git push origin v0.0.0-test.1` -3. Wait for `.github/workflows/release.yml` to finish. -4. Verify the GitHub Release contains all platform artifacts. -5. Download each artifact and sanity-check installation on each OS. +Manual `channel=stable` with a version input is also a real stable-channel release. Omitting signing +secrets only makes platform artifacts unsigned; it does not prevent publication. ## 2) Apple signing + notarization setup (macOS) @@ -283,7 +302,7 @@ Checklist: - `APPLE_API_KEY`: contents of the downloaded `.p8` - `APPLE_API_KEY_ID`: Key ID - `APPLE_API_ISSUER`: Issuer ID -10. Complete the Clerk Native API and AASA setup in [T3 Connect Clerk Setup](../cloud/t3-connect-clerk.md#desktop-passkeys). +10. Complete the Clerk Native API and AASA setup in [T3 Connect Clerk Setup](../internals/t3-connect.md#desktop-passkeys). 11. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected `com.apple.developer.associated-domains` entitlement. diff --git a/docs/project/todo.md b/docs/project/todo.md deleted file mode 100644 index 3d856996d8da..000000000000 --- a/docs/project/todo.md +++ /dev/null @@ -1,13 +0,0 @@ -# TODO - -## Small things - -- [ ] Submitting new messages should scroll to bottom -- [ ] Only show last 10 threads for a given project -- [ ] Thread archiving -- [ ] New projects should go on top -- [ ] Projects should be sorted by latest thread update - -## Bigger things - -- [ ] Queueing messages diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md deleted file mode 100644 index 3927adcfe295..000000000000 --- a/docs/reference/scripts.md +++ /dev/null @@ -1,56 +0,0 @@ -# Scripts - -- `vp run dev` — Starts contracts, server, and web in watch mode. -- `vp run dev --share` — Also publishes the web port over HTTPS on this machine's tailnet. The startup pairing URL is built against the shared origin, and the mapping is removed on exit. -- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. -- `vp run dev:web` — Starts just the Vite dev server for the web app. -- Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even when `T3CODE_HOME` is set, storing state in `/.t3/userdata`. Pass `--home-dir ` to choose another isolated directory explicitly. Submodules are not worktrees and keep the normal precedence. -- From the **main checkout**, dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under `/userdata`; the base directory remains available for caches, worktrees, and other shared data. -- Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional. -- Pass dev-runner flags directly after the root task name, for example: - `vp run dev --home-dir /tmp/t3code-dev` -- `vp run start` — Runs the production server (serves built web app as static files). -- `vp run build` — Builds contracts, web app, and server. -- `vp run typecheck` — Strict TypeScript checks for all packages. -- `vp run test` — Runs workspace tests. -- `node apps/server/scripts/t3-sqlite-state.ts --base-dir ...` — Inspects or seeds an isolated T3 SQLite database; writes create a private backup first. -- `vp run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. -- `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. -- `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. -- `vp run dist:desktop:linux` — Builds a Linux AppImage into `./release`. -- `vp run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. - -## Desktop `.dmg` packaging notes - -- Default build is unsigned/not notarized for local sharing. -- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source. -- Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL). - -- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. -- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. -- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage` -- To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. -- Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and - `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from - `T3CODE_CLERK_PUBLISHABLE_KEY` unless `T3CODE_CLERK_PASSKEY_RP_DOMAINS` overrides it. -- Windows `--signed` uses Azure Trusted Signing and expects: - `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, - `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. -- Azure authentication env vars are also required (for example service principal with secret): - `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. - -## Browser development - -`dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the server, allowing the same bundle to work from localhost or a tailnet hostname. - -Worktrees derive a preferred port offset from their path. The runner shifts both ports together when either is occupied or the web port is blocked by browsers, so treat the `[dev-runner]` output as authoritative. - -## Running multiple dev instances - -Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. - -- Default ports: server `13773`, web `5733` -- Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`) -- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` - -If you want full control instead of hashing, set `T3CODE_PORT_OFFSET` to a numeric offset. diff --git a/docs/reference/workspace-layout.md b/docs/reference/workspace-layout.md deleted file mode 100644 index be88f2b603b5..000000000000 --- a/docs/reference/workspace-layout.md +++ /dev/null @@ -1,7 +0,0 @@ -# Workspace layout - -- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start. -- `/apps/web`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. -- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared web app. -- `/packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. -- `/packages/shared`: Shared runtime utilities consumed by both server and web. Uses explicit subpath exports (e.g. `@t3tools/shared/git`, `@t3tools/shared/DrainableWorker`) — no barrel index. diff --git a/docs/user/install.md b/docs/user/install.md new file mode 100644 index 000000000000..fe0b418ca1e5 --- /dev/null +++ b/docs/user/install.md @@ -0,0 +1,84 @@ +# Install T3 Code + +T3 Code is a web and desktop GUI for running coding agents on your machine. + +## Requirements + +Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the T3 Code server. + +At least one provider CLI, installed and authenticated. See [Providers](#providers) below. + +## Run Without Installing + +```bash +npx t3@latest +``` + +This starts the T3 Code server on your machine and opens the local web app. Use +`npx t3@latest --help` for the full CLI reference. + +## Desktop App + +Download the latest release from +[GitHub Releases](https://github.com/pingdotgg/t3code/releases), or install from a package +registry. + +Windows: + +```bash +winget install T3Tools.T3Code +``` + +macOS: + +```bash +brew install --cask t3-code +``` + +Arch Linux: + +```bash +yay -S t3code-bin +``` + +## Providers + +T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want +to use, then authenticate it. + +| Provider | CLI | Default binary | Log in with | +| ---------- | ----------------------------------------------------- | -------------- | --------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | + +Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that +T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. + +Run the login command on the machine running the T3 Code server, not on the device you browse +from. + +### Binary Discovery + +Each provider CLI must be on the server's `PATH`, or have an explicit binary path set in +**Settings** → the provider instance → **Binary path**. Use the explicit path when a version +manager or a non-standard install location keeps the CLI off the `PATH` of the shell that +started T3 Code. + +### When Auth Is Needed + +Provider auth is required before you start a session with that provider, not before you start +T3 Code. You can install T3 Code, open it, and add providers afterwards. A provider that is not +authenticated shows its status in **Settings** and fails at session start with the login command +to run. + +For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). + +## Next Steps + +- [Permission modes](./permission-modes.md): how much T3 Code asks before acting +- [Remote access](./remote-access.md): connect from a phone, tablet, or another desktop +- [Keeping T3 Code in sync](./updating.md): client and server version skew +- [Running in the background](./background-service.md): Linux background service diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 5ef47b38fb91..d94d3dc2dba9 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -1,10 +1,14 @@ # Keybindings -T3 Code reads keybindings from: +Edit keybindings from **Settings** → **Keybindings**. That page lists every command, its current +shortcut, whether it is a default or your own, and warns about conflicts. -- `~/.t3/keybindings.json` +The same configuration lives in `~/.t3/userdata/keybindings.json` on the machine running the +server, if you prefer editing it directly. T3 Code writes the built-in defaults into that file on +first run, and adds any new defaults on later startups unless a rule of yours already claims the +command or the shortcut. -The file must be a JSON array of rules: +The file is a JSON array of rules. ```json [ @@ -13,61 +17,25 @@ The file must be a JSON array of rules: ] ``` -See the full schema for more details: [`packages/contracts/src/keybindings.ts`](../../packages/contracts/src/keybindings.ts) +Invalid rules are ignored. An invalid file is ignored entirely, and the server logs a warning. -## Defaults +## Rule Shape -```json -[ - { "key": "mod+j", "command": "terminal.toggle" }, - { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, - { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, - { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, - { "key": "mod+shift+j", "command": "preview.toggle" }, - { "key": "mod+r", "command": "preview.refresh", "when": "previewFocus" }, - { "key": "mod+l", "command": "preview.focusUrl", "when": "previewFocus" }, - { "key": "mod+=", "command": "preview.zoomIn", "when": "previewFocus" }, - { "key": "mod+-", "command": "preview.zoomOut", "when": "previewFocus" }, - { "key": "mod+0", "command": "preview.resetZoom", "when": "previewFocus" }, - { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, - { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, - { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, - { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, - { "key": "mod+o", "command": "editor.openFavorite" } -] -``` - -For most up to date defaults, see [`DEFAULT_KEYBINDINGS` in `apps/server/src/keybindings.ts`](../../apps/server/src/keybindings.ts) +- `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d` +- `command` (required): the command ID to run +- `when` (optional): boolean expression controlling when the shortcut is active -## Configuration +## Key Syntax -### Rule Shape +Modifiers: `mod` (`cmd` on macOS, `ctrl` elsewhere), `cmd` / `meta`, `ctrl` / `control`, `shift`, +`alt` / `option`. -Each entry supports: +Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`. -- `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d` -- `command` (required): action ID -- `when` (optional): boolean expression controlling when the shortcut is active +## Commands -Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server. - -### Available Commands - -- `terminal.toggle`: open/close terminal drawer -- `terminal.split`: split terminal (in focused terminal context by default) -- `terminal.new`: create new terminal (in focused terminal context by default) -- `terminal.close`: close/kill the focused terminal (in focused terminal context by default) -- `preview.toggle`: open/close the in-app browser preview panel (desktop app only) -- `preview.refresh`: reload the active preview tab (in focused preview context by default) -- `preview.focusUrl`: focus the URL input of the preview panel (in focused preview context by default) -- `preview.zoomIn`: zoom the preview viewport in one step (in focused preview context by default) -- `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default) -- `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default) -- `commandPalette.toggle`: open or close the global command palette -- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state -- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) -- `editor.openFavorite`: open current project/worktree in the last-used editor -- `script.{id}.run`: run a project script by id (for example `script.test.run`) +Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and +`chat.new`. Project scripts are addressable as `script.{id}.run`, for example `script.test.run`. `filePicker.toggle` opens file search for the active project and defaults to `mod+p`. `projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. @@ -78,49 +46,34 @@ agent responses across connected environments. Message matches show one labeled keeping the thread's project, branch, and machine context visible. Message search begins after two characters and uses SQLite's ASCII case-insensitive matching. -### Key Syntax - -Supported modifiers: - -- `mod` (`cmd` on macOS, `ctrl` on non-macOS) -- `cmd` / `meta` -- `ctrl` / `control` -- `shift` -- `alt` / `option` - -Examples: - -- `mod+j` -- `mod+shift+d` -- `ctrl+l` -- `cmd+k` +The full command list and the current defaults are shown in **Settings** → **Keybindings**, which +always matches the build you are running. Use that rather than a copied list. -### `when` Conditions +Note that `chat.new` and `chat.newLocal` both create a thread through the same path. A new thread +inherits the project you were in, along with model and mode selections. Branch, worktree, and +environment mode always come from your configured defaults, not from the thread you were looking +at. To keep a worktree, use the explicit "new thread in this worktree" action in the branch +toolbar. The only difference between the two commands: with the current sidebar and more than one +project, `chat.new` opens a project chooser first. -Currently available context keys: +## `when` Conditions -- `terminalFocus` -- `terminalOpen` -- `previewFocus` -- `previewOpen` +A `when` expression is evaluated against context keys describing the current UI state. The keys +the app supplies today are `terminalFocus`, `terminalOpen`, `previewFocus`, `previewOpen`, and +`modelPickerOpen`. The set is open and grows over time, so treat that as the current list rather +than a fixed one. Any key the running app does not supply evaluates to `false`. -Supported operators: - -- `!` (not) -- `&&` (and) -- `||` (or) -- parentheses: `(` `)` +Operators: `!` (not), `&&` (and), `||` (or), and parentheses. Examples: - `"when": "terminalFocus"` - `"when": "terminalOpen && !terminalFocus"` -- `"when": "terminalFocus || terminalOpen"` - -Unknown condition keys evaluate to `false`. +- `"when": "!terminalFocus"` -### Precedence +## Precedence - Rules are evaluated in array order. - For a key event, the last rule where both `key` matches and `when` evaluates to `true` wins. -- That means precedence is across commands, not only within the same command. +- Precedence is across commands, not only within the same command. A later rule for a different + command can take a key away from an earlier one. diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md new file mode 100644 index 000000000000..cb69e45b5d7b --- /dev/null +++ b/docs/user/permission-modes.md @@ -0,0 +1,48 @@ +# Permission Modes + +A permission mode controls how much the agent does on its own and when it stops to ask you. + +The mode is set per thread, from the mode control in the message composer. Changing it in one +thread does not change any other thread. A thread created from inside another thread keeps that +thread's mode; otherwise new threads start in **Full access** unless you pick something else +before sending. + +## The Modes + +**Supervised**: ask before commands and file changes. The agent pauses and shows you what it +wants to run or edit, and waits for approval. Work outside the workspace is restricted. + +**Auto-accept edits**: auto-approve edits, ask before other actions. File changes go through +without prompting; commands and anything else still stop for approval. + +**Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends +on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto +permission mode, and providers without an equivalent (such as OpenCode) fall back to asking, like +Supervised. + +**Full access**: allow commands and edits without prompts. The default. The agent runs +unattended until it finishes or asks a question of its own. + +Approvals appear inline in the conversation. Approve or reject one and the agent continues from +there. + +## Choosing a Mode + +Use **Full access** for work in a worktree or a sandbox you can throw away. + +Use **Supervised** on a repository where an unwanted command is expensive, or the first time you +run an unfamiliar task. + +**Auto-accept edits** suits refactors where the edits are the point and you only care about the +shell commands. + +## Provider Behavior + +Each provider maps these modes onto its own approval and sandbox settings. Codex, for example, +translates the mode into its approval policy and sandbox level, so **Supervised** runs the CLI +with prompting enabled and a restricted workspace while **Full access** disables both. The +labels above describe what you get; the exact per-provider translation is internal and may +change. + +Mobile offers the same four modes. It labels the first one **Approve actions** rather than +**Supervised**. diff --git a/docs/providers/claude.md b/docs/user/providers-claude.md similarity index 72% rename from docs/providers/claude.md rename to docs/user/providers-claude.md index bbf72722cf1f..79f1211cf40d 100644 --- a/docs/providers/claude.md +++ b/docs/user/providers-claude.md @@ -1,6 +1,7 @@ # Claude -This guide is for people who want to use more than one Claude setup in T3 Code. +This guide is for people who want to use more than one Claude setup in T3 Code. For Codex, see +[Codex](./providers-codex.md). For first-time setup, see [Install T3 Code](./install.md). Common reasons: @@ -24,20 +25,24 @@ In T3 Code Settings, your Claude provider can stay like this: ```text Display name: Claude Binary path: claude -Claude HOME path: empty +CLAUDE_CONFIG_DIR path: empty ``` -An empty `Claude HOME path` means T3 Code uses your normal home directory. +An empty `CLAUDE_CONFIG_DIR path` means T3 Code uses Claude Code's normal config directory. + +When you set this field, T3 Code points Claude Code at that directory with the +`CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and +the rest of your environment stay as they are. ## I Want Work And Personal Claude Accounts -Use a different Claude home for each account. +Use a different Claude config directory for each account. Example: ```text -default home work account -~/.claude_personal_home personal account +default config dir work account +~/.claude_personal_home personal account ``` ### Set Up The First Account @@ -53,24 +58,27 @@ In T3 Code Settings: ```text Display name: Claude Work Binary path: claude -Claude HOME path: empty +CLAUDE_CONFIG_DIR path: empty ``` ### Set Up The Second Account -Log in with a separate home: +Log in with a separate config directory: ```bash mkdir -p ~/.claude_personal_home -HOME=~/.claude_personal_home claude auth login +CLAUDE_CONFIG_DIR=~/.claude_personal_home claude auth login ``` +Use `CLAUDE_CONFIG_DIR`, not `HOME`. Setting `HOME` writes the login to +`~/.claude_personal_home/.claude`, which is not where T3 Code looks. + Then add another Claude provider in T3 Code: ```text Display name: Claude Personal Binary path: claude -Claude HOME path: ~/.claude_personal_home +CLAUDE_CONFIG_DIR path: ~/.claude_personal_home ``` Use the email shown in Settings to confirm each provider is using the intended account. Emails are @@ -80,12 +88,12 @@ blurred by default; click the blurred email to reveal it. Usually, no. -T3 Code only offers Claude providers that use the same Claude home for an existing thread. A -different Claude home is treated as a different Claude environment. +T3 Code only offers Claude providers that use the same config directory for an existing thread. A +different config directory is treated as a different Claude environment. This is different from the recommended Codex setup. Claude Code keeps account and local state across -multiple files under its home directory, so T3 Code keeps separate Claude homes isolated instead of -trying to share part of the state. +multiple files under its config directory, so T3 Code keeps separate config directories isolated +instead of trying to share part of the state. ## I Want To Use OpenRouter @@ -102,7 +110,7 @@ Add or edit a Claude provider in T3 Code Settings: ```text Display name: Claude OpenRouter Binary path: claude -Claude HOME path: ~/.claude_openrouter_home +CLAUDE_CONFIG_DIR path: ~/.claude_openrouter_home ``` In that provider's Environment variables section, add: @@ -173,40 +181,18 @@ Claude Code Router is useful when you want a local routing layer with more contr OpenRouter setup. T3 Code does not need a special Claude Code Router provider. Treat the router as a Claude -environment. - -Use this when you want Claude Code Router to decide which upstream model or provider handles Claude -requests. - -High-level flow: - -1. Start Claude Code Router. -2. Add or configure a Claude provider in T3 Code. -3. Put the router's required variables on that provider instance. - -Configure a Claude provider: +environment: give a Claude provider its own `CLAUDE_CONFIG_DIR path`, and put whatever variables +the router tells you to export into that provider's Environment variables section. Mark tokens +and API keys as sensitive. ```text Display name: Claude Router Binary path: claude -Claude HOME path: ~/.claude_router_home -``` - -Then copy the variables that `ccr activate` would export into the provider's Environment variables -section. Mark tokens and API keys as sensitive. - -If you want the router-backed setup to stay separate from your normal Claude account, create and log -in with a dedicated home first: - -```bash -mkdir -p ~/.claude_router_home -ccr start -ccr activate -HOME=~/.claude_router_home claude auth login +CLAUDE_CONFIG_DIR path: ~/.claude_router_home ``` -Claude Code Router's setup can change over time. Use its upstream README for the current install and -configuration steps: . +Follow the upstream project's README for the router's own install, startup, and configuration +steps: . ## I Want Different Claude Settings, Not A Different Account @@ -218,7 +204,7 @@ Examples: - "Claude Router" - "Claude Experimental" -If the preset needs different Claude files, give it a different `Claude HOME path`. If it needs +If the preset needs different Claude files, give it a different `CLAUDE_CONFIG_DIR path`. If it needs different API keys, base URLs, or router settings, use Environment variables. Do not put environment variable assignments in `Launch arguments`. diff --git a/docs/providers/codex.md b/docs/user/providers-codex.md similarity index 96% rename from docs/providers/codex.md rename to docs/user/providers-codex.md index cc9e84e484ae..7c5ea91f043b 100644 --- a/docs/providers/codex.md +++ b/docs/user/providers-codex.md @@ -1,6 +1,7 @@ # Codex -This guide is for people who want to use more than one Codex account in T3 Code. +This guide is for people who want to use more than one Codex account in T3 Code. For Claude, see +[Claude](./providers-claude.md). For first-time setup, see [Install T3 Code](./install.md). Common reasons: diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 62a60a9adc0f..bec3070745af 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -20,14 +20,15 @@ That gives you: - Treat the token like a password. - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. -There are two ways to expose your server for remote connections: from the desktop app or from the CLI. +There are three ways to reach your server from another device: expose the desktop app's backend, +run a headless server from the CLI, or have the desktop app launch T3 Code over SSH. ### Option 1: Desktop App If you are already running the desktop app and want to make it reachable from other devices: 1. Open **Settings** → **Connections**. -2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. +2. Under **This environment**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. 3. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints. 4. Use **Create Link** to generate a pairing link you can share with another device. @@ -54,10 +55,10 @@ Depending on your Tailscale setup, this may include: - an HTTPS MagicDNS endpoint when Tailscale Serve is configured for this backend The Tailscale HTTPS endpoint uses the clean MagicDNS URL, such as -`https://machine.tailnet.ts.net/`, and is disabled until the app verifies that the URL reaches this -backend. Use **Setup** on the Tailscale HTTPS row to opt in. The desktop app restarts the backend -with the same server-side behavior as `t3 serve --tailscale-serve`, then the server asks Tailscale -Serve to proxy HTTPS traffic to the local backend. +`https://machine.tailnet.ts.net/`, and is off until you opt in. Turn on **Enable Tailscale HTTPS** +on the **Tailscale HTTPS** row in **Settings** → **Connections**. The desktop app restarts the +backend with the same server-side behavior as `t3 serve --tailscale-serve`, then the server asks +Tailscale Serve to proxy HTTPS traffic to the local backend. Turn the same switch off to stop it. The Tailscale support is an endpoint provider add-on. The core remote model still works without Tailscale: LAN HTTP endpoints, custom HTTPS endpoints, future tunnels, and SSH-launched environments all use the same saved environment and pairing flow. @@ -102,10 +103,8 @@ By default this configures Tailscale Serve on HTTPS port 443 and advertises npx t3 serve --tailscale-serve --tailscale-serve-port 8443 ``` -> Note -> The GUIs do not currently support adding projects on remote environments. -> For now, use `t3 project ...` on the server machine instead. -> Full GUI support for remote project management is coming soon. +Once paired, add projects normally: open the Command Palette and choose **Add Project**, then pick +the environment the project lives on. Every saved environment is offered, not only the local one. ### Option 3: Desktop-Managed SSH Launch @@ -131,16 +130,10 @@ The remote host must have a compatible Node.js runtime. T3 Code uses the server ^22.16 || ^23.11 || >=24.10 ``` -During SSH launch, T3 Code first checks whether `node` is already available on `PATH`. If it is missing, the launcher tries common non-interactive shell locations and version-manager shims/activation hooks: - -- `~/.local/bin`, `~/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` -- Volta via `~/.volta/bin` -- asdf via `~/.asdf/shims`, `~/.asdf/bin`, or `~/.asdf/asdf.sh` -- mise via `~/.local/share/mise/shims`, `~/.mise/shims`, or `mise activate sh` -- fnm via `fnm env --use-on-cd --shell sh` or `fnm env --shell sh` -- nodenv via `~/.nodenv/bin`, `~/.nodenv/shims`, or `nodenv init -` -- nvm via `$NVM_DIR/nvm.sh`, then `nvm use default`, `nvm use node`, or `nvm use --lts` -- installed nvm versions under `$NVM_DIR/versions/node/*/bin` +During SSH launch, T3 Code first checks whether `node` is on `PATH`. If it is missing, the launcher +looks in the usual install directories and tries to activate a version manager if it finds one +(Volta, asdf, mise, fnm, nodenv, nvm). That covers most setups, but a version manager that only +initializes from an interactive shell profile will not be picked up. If launch fails with `node: command not found`, a port-scan failure, or a message that the remote Node version does not satisfy the required range, SSH into the host and check the same non-interactive shell path T3 Code uses: @@ -154,7 +147,7 @@ If that does not print a compatible Node version, configure your version manager nvm alias default 24 ``` -With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and points at a Node version satisfying the range above. +With mise, asdf, fnm, or nodenv, make sure the tool's shim directory is installed and resolves to a Node version satisfying the range above without an interactive shell. If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually. @@ -166,7 +159,7 @@ be able to update and reconnect the server for you, or it may ask you to update run a copied command on the server machine. Finish active work before updating because the server restarts briefly. For step-by-step guidance, -see [Keeping T3 Code in Sync](./server-updates.md). +see [Keeping T3 Code in Sync](./updating.md). On a Linux host, you can keep the server running after logout and manage it independently of the connection method. See [Running T3 Code in the Background](./background-service.md). diff --git a/docs/integrations/source-control-providers.md b/docs/user/source-control.md similarity index 74% rename from docs/integrations/source-control-providers.md rename to docs/user/source-control.md index c496d5516a68..6d81d2b33ab0 100644 --- a/docs/integrations/source-control-providers.md +++ b/docs/user/source-control.md @@ -1,6 +1,6 @@ # Source Control Integrations -T3 Code connects directly to your Git hosting provider so you can create pull requests, review code, and manage repositories without leaving your editor. Work stays in flow—no more jumping between browser tabs and terminal windows. +T3 Code connects to your Git hosting provider so you can create pull requests, review code, and manage repositories without leaving the app. ## Supported Providers @@ -24,16 +24,16 @@ T3 Code works with the platforms your team already uses: **Publish local projects to the cloud** - Have a local Git repository without a remote? -- Use the **Publish Repository** action to create a new hosted repository (GitHub, GitLab, Bitbucket, or Azure DevOps), add it as your origin remote, and push—all in one flow -- Perfect for turning a weekend prototype into a real project +- Use the **Publish Repository** action to create a new hosted repository (GitHub, GitLab, Bitbucket, or Azure DevOps), add it as your origin remote, and push, in one flow +- If the local repository has no commits yet, publishing creates the remote and wires it up but does not push. Make a commit, then push normally. ### Manage Code Reviews Without Context Switching **Create pull requests while you work** -- Push a branch and create a pull request from the Git panel +- Push a branch and create a pull request from the Git actions controls in the toolbar - T3 Code can suggest titles and descriptions based on your commits -- Supports GitHub Pull Requests, GitLab Merge Requests, and Bitbucket Pull Requests +- Supports GitHub Pull Requests, GitLab Merge Requests, Bitbucket Pull Requests, and Azure DevOps Pull Requests **Stay on top of open reviews** @@ -65,7 +65,7 @@ Run a quick **Rescan** after setting up a new machine or changing credentials. ``` 3. Open **Settings → Source Control** in T3 Code and verify GitHub shows as authenticated -That's it—you can now clone, publish, and create pull requests. +You can now clone, publish, and create pull requests. ### For GitLab @@ -81,15 +81,25 @@ That's it—you can now clone, publish, and create pull requests. ### For Bitbucket -Bitbucket uses API tokens instead of a CLI tool: +Bitbucket uses tokens instead of a CLI tool. Two options, both set as environment variables on the +machine running T3 Code. -1. Create an API token in your Atlassian account with read/write access to pull requests and repositories -2. Add these environment variables to the environment running T3 Code: - ```bash - export T3CODE_BITBUCKET_EMAIL="you@example.com" - export T3CODE_BITBUCKET_API_TOKEN="your-token" - ``` -3. Restart T3 Code and verify the connection in **Source Control settings** +Recommended, a Bitbucket access token: + +```bash +export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" +``` + +Or an Atlassian account email plus API token, with read/write access to pull requests and +repositories: + +```bash +export T3CODE_BITBUCKET_EMAIL="you@example.com" +export T3CODE_BITBUCKET_API_TOKEN="your-token" +``` + +If both are set, the access token wins. Restart T3 Code and verify the connection in **Source +Control settings**. ### For Azure DevOps @@ -124,4 +134,4 @@ Bitbucket uses API tokens instead of a CLI tool: - [GitHub CLI](https://cli.github.com/) - [GitLab CLI](https://gitlab.com/gitlab-org/cli) -- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) diff --git a/docs/user/server-updates.md b/docs/user/updating.md similarity index 84% rename from docs/user/server-updates.md rename to docs/user/updating.md index 27577c6f948b..8e1fac818546 100644 --- a/docs/user/server-updates.md +++ b/docs/user/updating.md @@ -36,12 +36,20 @@ After selecting **Update server**, the warning becomes a three-step progress rai Connections, so navigating between them does not lose the update. A failed step remains visible with its error and an option to retry. -If the server uses the T3 Code background service, you can also update it directly on the host: +**Copy update command** gives you `npx t3@`, which relaunches the server directly +at the matching version. Add whatever startup options you normally use. + +If the server instead runs as the T3 Code background service, update the service on the host and +pin the same version: ```sh -npx t3@latest service update +npx t3@ service update ``` +`service update` installs the version of the CLI that invoked it, so `npx t3@latest service update` +only resolves the skew when your client happens to be on the latest release. The exact version from +the warning always works. + See [Running T3 Code in the Background](./background-service.md) for install, status, and removal commands. diff --git a/infra/relay/README.md b/infra/relay/README.md index 114d5e9b07f8..0085c9c5b6b7 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -1,7 +1,7 @@ # T3 Connect Relay -> [!WARNING] -> T3 Connect is currently in private beta. Join the waitlist in the app under Settings > T3 Connect. +> [!NOTE] +> Sign in to T3 Connect from the app under Settings > Connections. The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to remote environments, manages the cloud-side records needed for those connections, and delivers @@ -9,7 +9,7 @@ optional mobile notifications and Live Activities. The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects, regular API and WebSocket traffic goes directly between that client and the selected environment. -See the [T3 Connect architecture overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the larger system +See the [T3 Connect architecture overview](../../docs/internals/t3-code-connect-auth-flow.html) for the larger system design. ## Responsibilities @@ -25,7 +25,7 @@ The relay currently owns: - Persisting relay state and exposing relay-specific traces for diagnostics. The environment server and relay have separate credentials and trust boundaries. Read -[Environment Authentication Profile](../../docs/environment-auth.md) before changing token, +[Environment Authentication Profile](../../docs/internals/environment-auth.md) before changing token, credential, or authorization behavior. ## Code Map @@ -159,8 +159,8 @@ and hosted web builds. See: -- [T3 Connect Clerk Setup](../../docs/cloud/t3-connect-clerk.md) for Clerk keys, JWT templates, and waitlist +- [T3 Connect Clerk Setup](../../docs/internals/t3-connect.md) for Clerk keys, JWT templates, and sign-up restrictions setup. -- [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics. -- [T3 Connect Architecture Overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the full link, +- [Relay Observability](../../docs/operations/relay-observability.md) for deployment tracing and diagnostics. +- [T3 Connect Architecture Overview](../../docs/internals/t3-code-connect-auth-flow.html) for the full link, connect, endpoint, and notification flows. From 2ddcaf4f26b9d4eeac18c3d8f471273ddf7afaf5 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:37:18 +0000 Subject: [PATCH 12/15] chore(release): prepare v0.0.29 (cherry picked from commit 831eb66fee91e397f5f8d6ae179408291e8f28b0) --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5edacdc9deb6..ea5ae160f47b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.28", + "version": "0.0.29", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 8626a7d0a29d..2e8fc338b4e3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 82e944bd6436..92073a80f6ff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.28", + "version": "0.0.29", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e1acf1e948e8..2e39763d7be2 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.28", + "version": "0.0.29", "private": true, "files": [ "dist" From ea75bc01959afe29b8eb87570e7c5d60fb8145bf Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:12:24 +0000 Subject: [PATCH 13/15] chore(release): prepare v0.0.30 (cherry picked from commit 694f8d1c6eaaabafbf5c2861ae524174919ef625) --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ea5ae160f47b..b58056fc4a04 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.29", + "version": "0.0.30", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 2e8fc338b4e3..a2a228a0385c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.29", + "version": "0.0.30", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 92073a80f6ff..c55f6e0ea4b8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.29", + "version": "0.0.30", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2e39763d7be2..3b0c9714ea74 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.29", + "version": "0.0.30", "private": true, "files": [ "dist" From 0742647b122b6801f59f184ac8a106c61a7e0af4 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:56:43 +0000 Subject: [PATCH 14/15] chore(release): prepare v0.0.31 (cherry picked from commit a8e05cbb92633a1351529f2bc402071f615e5051) --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b58056fc4a04..85748b7ab3e2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.30", + "version": "0.0.31", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index a2a228a0385c..ba0dbbd6c254 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.30", + "version": "0.0.31", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index c55f6e0ea4b8..9921f9b8431c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.30", + "version": "0.0.31", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 3b0c9714ea74..c3bd819023b7 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.30", + "version": "0.0.31", "private": true, "files": [ "dist" From 1b79641df4005c1c80f57ca1be239284abbc31b7 Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Fri, 31 Jul 2026 08:13:53 +0530 Subject: [PATCH 15/15] fix(test): align upstream tests with fork logger label and migration ids Two fork deviations that upstream's new tests do not know about: - The native event-log stream tag is `NATIVE`, not upstream's `NTIVE`. The header validator accepts both so pre-existing logs still parse. - The fork's migration registry appends upstream's 035 as ID 38, so the title-regeneration migration test has to run through 37 then 38. --- .../035_ProjectionThreadTitleRegeneration.test.ts | 6 ++++-- apps/server/src/provider/Layers/EventNdjsonLogger.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts index 755591201de2..3aacaa45a47a 100644 --- a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts @@ -13,8 +13,10 @@ layer("035_ProjectionThreadTitleRegeneration", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 34 }); - yield* runMigrations({ toMigrationInclusive: 35 }); + // The fork's registry renumbers upstream migrations after its own, so + // upstream's 035 file is installed as ID 38 (see Migrations.ts). + yield* runMigrations({ toMigrationInclusive: 37 }); + yield* runMigrations({ toMigrationInclusive: 38 }); const columns = yield* sql<{ readonly name: string }>` PRAGMA table_info(projection_threads) diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 71060cf23a46..425edf0d5795 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -187,7 +187,7 @@ describe("EventNdjsonLogger", () => { assert.deepEqual( lines.map(({ stream, payload }) => ({ stream, payload })), [ - { stream: "NTIVE", payload: '{"id":"native-event"}' }, + { stream: "NATIVE", payload: '{"id":"native-event"}' }, { stream: "CANON", payload: '{"type":"item.completed","id":"canonical-event"}', @@ -224,7 +224,7 @@ describe("EventNdjsonLogger", () => { assert.deepEqual( lines.map(({ stream, payload }) => ({ stream, payload })), [ - { stream: "NTIVE", payload: '{"id":"before-close"}' }, + { stream: "NATIVE", payload: '{"id":"before-close"}' }, { stream: "CANON", payload: '{"type":"item.completed","id":"after-close"}', @@ -313,7 +313,7 @@ describe("EventNdjsonLogger", () => { lines.map(({ stream, payload }) => ({ stream, payload })), [ { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, + { stream: "NATIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, ], ); } finally {